From f951c53c1132daab10d8277c4408352046608c16 Mon Sep 17 00:00:00 2001 From: Gale W Date: Thu, 20 Aug 2026 23:25:18 -0400 Subject: [PATCH 1/5] repository: replace docs maintenance with fsx automation Why: Canonical documentation and repository maintenance required a deterministic managed surface with two documentation recipes and no legacy Python or shell implementations. Breaking: Removes per-document scripts, persistent docs customization, shell release helpers, and nested repository-skill tests. Verification: just docs-check just repo-sync just test git diff --check --- .../workflows/validate-repo-maintenance.yml | 24 + README.md | 68 +- ROADMAP.md | 94 +- global.json | 7 + justfile | 17 + .../shared/project-docs/DocsCoordinator.fsx | 116 ++ .../shared/project-docs/ProjectDocs.fsx | 663 +++++++ .../skills/maintain-project-agents/SKILL.md | 115 +- .../assets/document.contract.json | 35 + .../config/agents-customization.template.yaml | 102 - .../references/agents-config-schema.md | 28 - .../references/agents-customization.md | 18 - .../references/fix-policies.md | 8 - .../references/output-contract.md | 17 - ...t-agents-maintenance-automation-prompts.md | 18 - .../references/section-schema.md | 45 - .../references/style-rules.md | 11 - .../scripts/maintain_project_agents.py | 773 -------- .../maintain-project-contributing/SKILL.md | 102 +- .../assets/document.contract.json | 30 + .../contributing-customization.template.yaml | 97 - .../references/contributing-config-schema.md | 28 - .../references/contributing-customization.md | 19 - .../references/fix-policies.md | 8 - .../references/output-contract.md | 16 - ...ributing-maintenance-automation-prompts.md | 18 - .../references/section-schema.md | 43 - .../references/style-rules.md | 11 - .../scripts/maintain_project_contributing.py | 825 --------- .../skills/maintain-project-readme/SKILL.md | 108 +- .../assets/document.contract.json | 18 + .../config/readme-customization.template.yaml | 71 - .../references/fix-policies.md | 26 - .../references/output-contract.md | 29 - ...t-readme-maintenance-automation-prompts.md | 86 - .../references/readme-config-schema.md | 32 - .../references/readme-customization.md | 44 - .../references/section-schema.md | 29 - .../references/style-rules.md | 16 - .../references/verification-checklist.md | 13 - .../scripts/maintain_project_readme.py | 885 --------- .../skills/maintain-project-repo/SKILL.md | 302 ++- .../validate-repo-maintenance.yml | 22 - .../github/validate-repo-maintenance.yml | 24 + .../assets/managed-assets.json | 21 + .../validate-repo-maintenance.yml | 26 - .../repo-maintenance/hooks/pre-commit.sample | 35 - .../validations/40-xcode-workspace-layout.fsx | 17 + .../validations/40-xcode-workspace-layout.sh | 29 - .../workspace/validate-components.fsx | 26 + .../workspace/validate-components.sh | 33 - .../repo-maintenance/config/release.env | 16 - .../repo-maintenance/config/validation.env | 2 - .../repo-maintenance/hooks/pre-commit.sample | 5 - .../assets/repo-maintenance/lib/common.sh | 168 -- .../maintain-project-docs.fsx | 23 + .../assets/repo-maintenance/release.sh | 499 ----- .../repo-maintenance/release/10-preflight.sh | 35 - .../release/20-tag-release.sh | 23 - .../release/30-push-release.sh | 19 - .../release/40-github-release.sh | 34 - .../repo-maintenance/repo-maintenance.fsx | 281 +++ .../repo-maintenance/repo-maintenance.just | 20 + .../assets/repo-maintenance/sync-shared.sh | 12 - .../assets/repo-maintenance/syncing/README.md | 34 +- .../assets/repo-maintenance/validate-all.sh | 16 - .../validations/10-toolkit-layout.sh | 16 - .../validations/20-agents-guidance.sh | 23 - .../validations/30-ci-wrapper.sh | 15 - .../references/automation-prompts.md | 23 +- .../references/customization-flow.md | 31 - .../references/customization.template.yaml | 4 - .../references/pre-commit-vs-ci.md | 21 +- ...ect-docs-maintenance-automation-prompts.md | 34 +- .../references/release-modes.md | 78 +- .../references/repo-maintenance-layout.md | 53 +- .../scripts/customization_config.py | 213 --- .../scripts/install_maintain_project_repo.py | 418 ----- .../scripts/maintain-project-docs.fsx | 20 + .../scripts/maintain-project-repo.fsx | 147 ++ .../scripts/maintain_project_docs.py | 441 ----- .../scripts/run_workflow.py | 144 -- .../skills/maintain-project-roadmap/SKILL.md | 164 +- .../assets/document.contract.json | 26 + .../roadmap-customization.template.yaml | 70 - .../references/roadmap-automation-prompts.md | 125 -- .../references/roadmap-config-schema.md | 37 - .../references/roadmap-customization.md | 66 - .../scripts/maintain_project_roadmap.py | 1647 ----------------- scripts/repo-maintenance/config/profile.json | 4 + .../docs/agents/AGENTS.template.md | 65 + .../docs/agents/document.contract.json | 35 + .../contributing/CONTRIBUTING.template.md | 69 + .../docs/contributing/document.contract.json | 30 + .../docs/readme/README.template.md | 57 + .../docs/readme/document.contract.json | 18 + .../docs/roadmap/ROADMAP.template.md | 58 + .../docs/roadmap/document.contract.json | 26 + .../repo-maintenance/lib/DocsCoordinator.fsx | 116 ++ scripts/repo-maintenance/lib/ProjectDocs.fsx | 663 +++++++ .../maintain-project-docs.fsx | 23 + scripts/repo-maintenance/repo-maintenance.fsx | 281 +++ .../repo-maintenance/repo-maintenance.just | 20 + .../syncing/40-repository-skills-exports.fsx | 51 + shared/project-docs/DocsCoordinator.fsx | 116 ++ shared/project-docs/ProjectDocs.fsx | 663 +++++++ skills/maintain-project-agents/SKILL.md | 115 +- .../assets/document.contract.json | 35 + .../config/agents-customization.template.yaml | 102 - .../references/agents-config-schema.md | 28 - .../references/agents-customization.md | 18 - .../references/fix-policies.md | 8 - .../references/output-contract.md | 17 - ...t-agents-maintenance-automation-prompts.md | 18 - .../references/section-schema.md | 45 - .../references/style-rules.md | 11 - .../scripts/maintain_project_agents.py | 773 -------- skills/maintain-project-contributing/SKILL.md | 102 +- .../assets/document.contract.json | 30 + .../contributing-customization.template.yaml | 97 - .../references/contributing-config-schema.md | 28 - .../references/contributing-customization.md | 19 - .../references/fix-policies.md | 8 - .../references/output-contract.md | 16 - ...ributing-maintenance-automation-prompts.md | 18 - .../references/section-schema.md | 43 - .../references/style-rules.md | 11 - .../scripts/maintain_project_contributing.py | 825 --------- skills/maintain-project-readme/SKILL.md | 108 +- .../assets/document.contract.json | 18 + .../config/readme-customization.template.yaml | 71 - .../references/fix-policies.md | 26 - .../references/output-contract.md | 29 - ...t-readme-maintenance-automation-prompts.md | 86 - .../references/readme-config-schema.md | 32 - .../references/readme-customization.md | 44 - .../references/section-schema.md | 29 - .../references/style-rules.md | 16 - .../references/verification-checklist.md | 13 - .../scripts/maintain_project_readme.py | 885 --------- skills/maintain-project-repo/SKILL.md | 302 ++- .../validate-repo-maintenance.yml | 22 - .../github/validate-repo-maintenance.yml | 24 + .../assets/managed-assets.json | 21 + .../validate-repo-maintenance.yml | 26 - .../repo-maintenance/hooks/pre-commit.sample | 35 - .../validations/40-xcode-workspace-layout.fsx | 17 + .../validations/40-xcode-workspace-layout.sh | 29 - .../workspace/validate-components.fsx | 26 + .../workspace/validate-components.sh | 33 - .../repo-maintenance/config/release.env | 16 - .../repo-maintenance/config/validation.env | 2 - .../repo-maintenance/hooks/pre-commit.sample | 5 - .../assets/repo-maintenance/lib/common.sh | 168 -- .../maintain-project-docs.fsx | 23 + .../assets/repo-maintenance/release.sh | 499 ----- .../repo-maintenance/release/10-preflight.sh | 35 - .../release/20-tag-release.sh | 23 - .../release/30-push-release.sh | 19 - .../release/40-github-release.sh | 34 - .../repo-maintenance/repo-maintenance.fsx | 281 +++ .../repo-maintenance/repo-maintenance.just | 20 + .../assets/repo-maintenance/sync-shared.sh | 12 - .../assets/repo-maintenance/syncing/README.md | 34 +- .../assets/repo-maintenance/validate-all.sh | 16 - .../validations/10-toolkit-layout.sh | 16 - .../validations/20-agents-guidance.sh | 23 - .../validations/30-ci-wrapper.sh | 15 - .../references/automation-prompts.md | 23 +- .../references/customization-flow.md | 31 - .../references/customization.template.yaml | 4 - .../references/pre-commit-vs-ci.md | 21 +- ...ect-docs-maintenance-automation-prompts.md | 34 +- .../references/release-modes.md | 78 +- .../references/repo-maintenance-layout.md | 53 +- .../scripts/customization_config.py | 213 --- .../scripts/install_maintain_project_repo.py | 418 ----- .../scripts/maintain-project-docs.fsx | 20 + .../scripts/maintain-project-repo.fsx | 147 ++ .../scripts/maintain_project_docs.py | 441 ----- .../scripts/run_workflow.py | 144 -- skills/maintain-project-roadmap/SKILL.md | 164 +- .../assets/document.contract.json | 26 + .../roadmap-customization.template.yaml | 70 - .../references/roadmap-automation-prompts.md | 125 -- .../references/roadmap-config-schema.md | 37 - .../references/roadmap-customization.md | 66 - .../scripts/maintain_project_roadmap.py | 1647 ----------------- tests/repository-maintenance-e2e.fsx | 54 + 189 files changed, 5291 insertions(+), 16571 deletions(-) create mode 100644 .github/workflows/validate-repo-maintenance.yml create mode 100644 global.json create mode 100644 justfile create mode 100644 plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx create mode 100644 plugins/repository-skills/shared/project-docs/ProjectDocs.fsx create mode 100644 plugins/repository-skills/skills/maintain-project-agents/assets/document.contract.json delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/config/agents-customization.template.yaml delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/agents-config-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/agents-customization.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/fix-policies.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/output-contract.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/section-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/references/style-rules.md delete mode 100644 plugins/repository-skills/skills/maintain-project-agents/scripts/maintain_project_agents.py create mode 100644 plugins/repository-skills/skills/maintain-project-contributing/assets/document.contract.json delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/config/contributing-customization.template.yaml delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/contributing-config-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/contributing-customization.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/fix-policies.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/output-contract.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/section-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/references/style-rules.md delete mode 100644 plugins/repository-skills/skills/maintain-project-contributing/scripts/maintain_project_contributing.py create mode 100644 plugins/repository-skills/skills/maintain-project-readme/assets/document.contract.json delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/config/readme-customization.template.yaml delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/fix-policies.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/output-contract.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/readme-config-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/readme-customization.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/section-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/style-rules.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/references/verification-checklist.md delete mode 100644 plugins/repository-skills/skills/maintain-project-readme/scripts/maintain_project_readme.py delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/managed-assets.json delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/release.env delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx create mode 100644 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/references/customization-flow.md delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/references/customization.template.yaml delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/scripts/customization_config.py delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/scripts/install_maintain_project_repo.py create mode 100644 plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-docs.fsx create mode 100644 plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx delete mode 100644 plugins/repository-skills/skills/maintain-project-repo/scripts/maintain_project_docs.py delete mode 100755 plugins/repository-skills/skills/maintain-project-repo/scripts/run_workflow.py create mode 100644 plugins/repository-skills/skills/maintain-project-roadmap/assets/document.contract.json delete mode 100644 plugins/repository-skills/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml delete mode 100644 plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md delete mode 100644 plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-config-schema.md delete mode 100644 plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-customization.md delete mode 100644 plugins/repository-skills/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py create mode 100644 scripts/repo-maintenance/config/profile.json create mode 100644 scripts/repo-maintenance/docs/agents/AGENTS.template.md create mode 100644 scripts/repo-maintenance/docs/agents/document.contract.json create mode 100644 scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md create mode 100644 scripts/repo-maintenance/docs/contributing/document.contract.json create mode 100644 scripts/repo-maintenance/docs/readme/README.template.md create mode 100644 scripts/repo-maintenance/docs/readme/document.contract.json create mode 100644 scripts/repo-maintenance/docs/roadmap/ROADMAP.template.md create mode 100644 scripts/repo-maintenance/docs/roadmap/document.contract.json create mode 100644 scripts/repo-maintenance/lib/DocsCoordinator.fsx create mode 100644 scripts/repo-maintenance/lib/ProjectDocs.fsx create mode 100644 scripts/repo-maintenance/maintain-project-docs.fsx create mode 100644 scripts/repo-maintenance/repo-maintenance.fsx create mode 100644 scripts/repo-maintenance/repo-maintenance.just create mode 100644 scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx create mode 100644 shared/project-docs/DocsCoordinator.fsx create mode 100644 shared/project-docs/ProjectDocs.fsx create mode 100644 skills/maintain-project-agents/assets/document.contract.json delete mode 100644 skills/maintain-project-agents/config/agents-customization.template.yaml delete mode 100644 skills/maintain-project-agents/references/agents-config-schema.md delete mode 100644 skills/maintain-project-agents/references/agents-customization.md delete mode 100644 skills/maintain-project-agents/references/fix-policies.md delete mode 100644 skills/maintain-project-agents/references/output-contract.md delete mode 100644 skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md delete mode 100644 skills/maintain-project-agents/references/section-schema.md delete mode 100644 skills/maintain-project-agents/references/style-rules.md delete mode 100644 skills/maintain-project-agents/scripts/maintain_project_agents.py create mode 100644 skills/maintain-project-contributing/assets/document.contract.json delete mode 100644 skills/maintain-project-contributing/config/contributing-customization.template.yaml delete mode 100644 skills/maintain-project-contributing/references/contributing-config-schema.md delete mode 100644 skills/maintain-project-contributing/references/contributing-customization.md delete mode 100644 skills/maintain-project-contributing/references/fix-policies.md delete mode 100644 skills/maintain-project-contributing/references/output-contract.md delete mode 100644 skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md delete mode 100644 skills/maintain-project-contributing/references/section-schema.md delete mode 100644 skills/maintain-project-contributing/references/style-rules.md delete mode 100644 skills/maintain-project-contributing/scripts/maintain_project_contributing.py create mode 100644 skills/maintain-project-readme/assets/document.contract.json delete mode 100644 skills/maintain-project-readme/config/readme-customization.template.yaml delete mode 100644 skills/maintain-project-readme/references/fix-policies.md delete mode 100644 skills/maintain-project-readme/references/output-contract.md delete mode 100644 skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md delete mode 100644 skills/maintain-project-readme/references/readme-config-schema.md delete mode 100644 skills/maintain-project-readme/references/readme-customization.md delete mode 100644 skills/maintain-project-readme/references/section-schema.md delete mode 100644 skills/maintain-project-readme/references/style-rules.md delete mode 100644 skills/maintain-project-readme/references/verification-checklist.md delete mode 100644 skills/maintain-project-readme/scripts/maintain_project_readme.py delete mode 100644 skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml create mode 100644 skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml create mode 100644 skills/maintain-project-repo/assets/managed-assets.json delete mode 100644 skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml delete mode 100755 skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample create mode 100644 skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx delete mode 100644 skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh create mode 100644 skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx delete mode 100644 skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh delete mode 100644 skills/maintain-project-repo/assets/repo-maintenance/config/release.env delete mode 100644 skills/maintain-project-repo/assets/repo-maintenance/config/validation.env delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh create mode 100644 skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/release.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh create mode 100644 skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx create mode 100644 skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh delete mode 100755 skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh delete mode 100644 skills/maintain-project-repo/references/customization-flow.md delete mode 100644 skills/maintain-project-repo/references/customization.template.yaml delete mode 100755 skills/maintain-project-repo/scripts/customization_config.py delete mode 100755 skills/maintain-project-repo/scripts/install_maintain_project_repo.py create mode 100644 skills/maintain-project-repo/scripts/maintain-project-docs.fsx create mode 100644 skills/maintain-project-repo/scripts/maintain-project-repo.fsx delete mode 100644 skills/maintain-project-repo/scripts/maintain_project_docs.py delete mode 100755 skills/maintain-project-repo/scripts/run_workflow.py create mode 100644 skills/maintain-project-roadmap/assets/document.contract.json delete mode 100644 skills/maintain-project-roadmap/config/roadmap-customization.template.yaml delete mode 100644 skills/maintain-project-roadmap/references/roadmap-automation-prompts.md delete mode 100644 skills/maintain-project-roadmap/references/roadmap-config-schema.md delete mode 100644 skills/maintain-project-roadmap/references/roadmap-customization.md delete mode 100644 skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py create mode 100644 tests/repository-maintenance-e2e.fsx diff --git a/.github/workflows/validate-repo-maintenance.yml b/.github/workflows/validate-repo-maintenance.yml new file mode 100644 index 000000000..5586cf296 --- /dev/null +++ b/.github/workflows/validate-repo-maintenance.yml @@ -0,0 +1,24 @@ +name: Validate Repo Maintenance + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: validate + runs-on: macos-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-dotnet@v6.0.0 + with: + global-json-file: global.json + - name: Install just + run: brew install just + - name: Validate repository + run: just repo-validate diff --git a/README.md b/README.md index 67281eb91..33cea0e1e 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ Promo audio: [Socket Codex Marketplace Promo](./docs/media/socket-codex-marketpl - [Overview](#overview) - [Quick Start](#quick-start) - [Usage](#usage) -- [Plugin Status](#plugin-status) - [Development](#development) - [Repo Structure](#repo-structure) - [Release Notes](#release-notes) - [License](#license) +- [Plugin Status](#plugin-status) ## Overview @@ -146,6 +146,39 @@ Currently available from the catalog: - `swiftasb-skills` - `web-dev-skills` +## Development + +For setup, local workflow, validation, review, release, and maintainer expectations, see [CONTRIBUTING.md](./CONTRIBUTING.md). For the consolidated child backlog, see [ROADMAP.md](./ROADMAP.md). For agent-facing repo rules, see [AGENTS.md](./AGENTS.md). + +For Xcode 27 beta Markdown editing and repository browsing, open [`Socket.xcworkspace`](./Socket.xcworkspace). It is a browse-only workspace for docs, plugin payloads, scripts, and marketplace metadata; it is not a root build surface. + +## Repo Structure + +```text +. +├── .agents/ +│ └── plugins/marketplace.json +├── docs/ +│ ├── agents/ +│ ├── media/ +│ └── maintainers/ +├── plugins/ +├── scripts/ +├── AGENTS.md +├── CONTRIBUTING.md +├── README.md +├── Socket.xcworkspace +└── ROADMAP.md +``` + +## Release Notes + +Use GitHub releases and Git history for root `socket` changes. Child plugins may carry their own release notes and maintainer docs. + +## License + +The `socket` superproject, and all nested projects, are licensed under the Apache License 2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). + ## Plugin Status Apple Dev Skills is Socket-owned under `plugins/apple-dev-skills` and keeps its public README because existing users can still arrive through the standalone compatibility marketplace. Other child planning now lives in [ROADMAP.md](./ROADMAP.md). @@ -181,36 +214,3 @@ Current Socket catalog shape: - `speak-swiftly`: Git-backed Speak Swiftly plugin from the standalone SpeakSwiftlyServer repository - `swiftasb-skills`: SwiftASB companion guidance - `web-dev-skills`: Expo SDK 56+ inline native modules, type generation, native-boundary inspection, and validation handoff guidance - -## Development - -For setup, local workflow, validation, review, release, and maintainer expectations, see [CONTRIBUTING.md](./CONTRIBUTING.md). For the consolidated child backlog, see [ROADMAP.md](./ROADMAP.md). For agent-facing repo rules, see [AGENTS.md](./AGENTS.md). - -For Xcode 27 beta Markdown editing and repository browsing, open [`Socket.xcworkspace`](./Socket.xcworkspace). It is a browse-only workspace for docs, plugin payloads, scripts, and marketplace metadata; it is not a root build surface. - -## Repo Structure - -```text -. -├── .agents/ -│ └── plugins/marketplace.json -├── docs/ -│ ├── agents/ -│ ├── media/ -│ └── maintainers/ -├── plugins/ -├── scripts/ -├── AGENTS.md -├── CONTRIBUTING.md -├── README.md -├── Socket.xcworkspace -└── ROADMAP.md -``` - -## Release Notes - -Use GitHub releases and Git history for root `socket` changes. Child plugins may carry their own release notes and maintainer docs. - -## License - -The `socket` superproject, and all nested projects, are licensed under the Apache License 2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). diff --git a/ROADMAP.md b/ROADMAP.md index b1e51b0ef..9b52bbacc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,7 @@ - [Milestone 23: Cloud Inference Skills plugin](#milestone-23-cloud-inference-skills-plugin) - [Milestone 24: Apple system integration, runtime evidence, and distribution workflows](#milestone-24-apple-system-integration-runtime-evidence-and-distribution-workflows) - [Milestone 25: Apple Creator Studio operator workflows](#milestone-25-apple-creator-studio-operator-workflows) -- [Milestone 26: Messaging collaboration skills plugin](#milestone-26-messaging-collaboration-skills-plugin) +- [Milestone 26: Messaging Collaboration Skills plugin](#milestone-26-messaging-collaboration-skills-plugin) - [Milestone 27: Cybersecurity skills plugin](#milestone-27-cybersecurity-skills-plugin) - [Milestone 28: Swift language tooling expansion](#milestone-28-swift-language-tooling-expansion) - [Milestone 29: Model Lab skills plugin](#milestone-29-model-lab-skills-plugin) @@ -60,28 +60,28 @@ - Milestone 9: Rust skills plugin - Completed - Milestone 10: Expo inline native modules workflow - Completed - Milestone 11: AgentDeck plugin - In Progress -- Milestone 12: Xcode 27 agentic tooling workflows - In Progress +- Milestone 12: Xcode 27 agentic tooling workflows - Completed - Milestone 13: Reverse Engineering skills plugin - Completed - Milestone 14: Core AI and Foundation Models workflow ownership - Completed - Milestone 15: Android Dev Skills plugin - Completed - Milestone 16: Server-Side JVM skills plugin - In Progress - Milestone 17: Cross-agent skill and plugin portability - In Progress - Milestone 18: Swift Lang shared language plugin - Completed -- Milestone 19: Project audit skills plugin - Planned +- Milestone 19: Project audit skills plugin - In Progress - Milestone 20: Game Dev Skills plugin - Completed - Milestone 21: Cloud Deployment Skills plugin - Completed - Milestone 22: Network Protocol Skills plugin - Completed - Milestone 23: Cloud Inference Skills plugin - Completed - Milestone 24: Apple system integration, runtime evidence, and distribution workflows - Completed - Milestone 25: Apple Creator Studio operator workflows - Planned -- Milestone 26: Messaging collaboration skills plugin - Completed +- Milestone 26: Messaging Collaboration Skills plugin - Completed - Milestone 27: Cybersecurity skills plugin - Completed - Milestone 28: Swift language tooling expansion - In Progress - Milestone 29: Model Lab skills plugin - Completed - Milestone 30: macOS virtualization and container skills expansion - Completed - Milestone 31: macOS platform security skills expansion - Completed - Milestone 32: tvOS app experience and media playback workflows - Completed -- Milestone 33: Unified Swift workspace and CI-owned cloud deployment - Completed +- Milestone 33: Unified Swift workspace and CI-owned cloud deployment - In Progress ## Milestone 5: SwiftASB skills plugin @@ -295,7 +295,7 @@ Decision note: the root marketplace entry is installable now that `web-dev-skill ### Status -Implementation Complete; Release Pending +In Progress ### Scope @@ -532,7 +532,7 @@ In Progress ### Status -In progress +In Progress ### Scope @@ -623,7 +623,7 @@ Implemented Milestone 18 by adding the `swift-lang` child plugin with shared Swi ### Status -Release candidate +In Progress ### Scope @@ -934,6 +934,17 @@ In Progress - [x] Keep the expansion as portable instruction skills without bundling a compiler, SourceKit service, language server, MCP server, or native host plugin. - [ ] Add the second-wave API-surface workflow after the first-wave routing and fixtures establish a stable base. +### Tickets + + + +### Exit Criteria + +- [x] An agent selects syntax, compiler, semantics, index, or LSP by required information rather than tool name alone. +- [x] Every first-wave workflow reports whether Swiftly or Xcode owns each selected binary and refuses mixed-toolchain proof. +- [x] Codex, Claude, Cowork, and Hermes compatibility surfaces agree on the portable first-wave skill inventory. +- [ ] The second-wave workflow can compare public API and ABI artifacts with explicit version and toolchain evidence. + ### First Wave - [x] Add `swift-lang:choose-swift-language-tooling` for information-model, project-model, and toolchain routing. @@ -950,13 +961,6 @@ In Progress - [ ] Route DocC authoring to Apple Dev while retaining language-level symbol-graph and public-API analysis in `swift-lang`. - [ ] Update Swift Lang metadata, Hermes export, Claude marketplace description, and root documentation when the second-wave skill ships. -### Exit Criteria - -- [x] An agent selects syntax, compiler, semantics, index, or LSP by required information rather than tool name alone. -- [x] Every first-wave workflow reports whether Swiftly or Xcode owns each selected binary and refuses mixed-toolchain proof. -- [x] Codex, Claude, Cowork, and Hermes compatibility surfaces agree on the portable first-wave skill inventory. -- [ ] The second-wave workflow can compare public API and ABI artifacts with explicit version and toolchain evidence. - ## Milestone 29: Model Lab skills plugin ### Status @@ -971,6 +975,19 @@ Completed - [x] Treat the experiment manifest as the durable primitive connecting immutable model source, dataset provenance, recipe or intervention, checkpoint, evaluation suite, runtime evidence, and artifact decision. - [x] Include a first-class Apple model-runtime lane that compares Foundation Models, Core AI, Core ML, direct MLX, ExecuTorch Core ML, and the experimental ExecuTorch MLX delegate without duplicating Apple-owned Core AI skills. +### Tickets + + + +### Exit Criteria + +- [x] Socket exposes one coherent model-research plugin rather than overlapping training, evaluation, MLX, Core ML, and Core AI plugins. +- [x] The first skills preserve source-model, dataset, recipe, checkpoint, evaluation, runtime, and artifact provenance end to end. +- [x] Cloud inference, agent evaluation, host portability, Apple app integration, Python implementation, and real-system security testing retain explicit owner handoffs. +- [x] Apple runtime selection distinguishes stable, beta, experimental, and exploratory surfaces and reuses Apple-owned skills rather than copying them. +- [x] Refusal ablation and jailbreak workflows measure ordinary behavior, regressions, and uncertainty in addition to bypass outcomes. +- [x] Root docs, marketplace wiring, Codex/Hermes/Claude compatibility, plugin metadata, and validation agree on the shipped inventory. + ### First Slice - [x] Create `plugins/model-lab-skills/` with `.codex-plugin/plugin.json`, `AGENTS.md`, an icon asset, authored `skills/` source, and no speculative MCP server. @@ -1004,15 +1021,6 @@ Completed - [x] Forward-test the training, evaluation, Apple runtime, ablation, and jailbreak workflows against isolated representative tasks before treating their contracts as stable. - [x] Wire `model-lab-skills` into the Socket marketplace only after useful skill content exists, then update root README, contributor docs, plugin metadata, and root validation together. -### Exit Criteria - -- [x] Socket exposes one coherent model-research plugin rather than overlapping training, evaluation, MLX, Core ML, and Core AI plugins. -- [x] The first skills preserve source-model, dataset, recipe, checkpoint, evaluation, runtime, and artifact provenance end to end. -- [x] Cloud inference, agent evaluation, host portability, Apple app integration, Python implementation, and real-system security testing retain explicit owner handoffs. -- [x] Apple runtime selection distinguishes stable, beta, experimental, and exploratory surfaces and reuses Apple-owned skills rather than copying them. -- [x] Refusal ablation and jailbreak workflows measure ordinary behavior, regressions, and uncertainty in addition to bypass outcomes. -- [x] Root docs, marketplace wiring, Codex/Hermes/Claude compatibility, plugin metadata, and validation agree on the shipped inventory. - ## Milestone 30: macOS virtualization and container skills expansion ### Status @@ -1028,14 +1036,9 @@ Completed - [x] Add a Cybersecurity lab-preparation workflow that turns an isolation decision into verified mount, clipboard, credential, device, network, baseline, evidence-export, revert, and teardown controls. - [x] Keep the expansion guidance-only: no VM images, restore images, kernels, malware samples, privileged helpers, daemons, guest agents, MCP servers, remote credentials, or automatic third-party tool installation. -### Planned Slices +### Tickets + -- [x] Phase 1: add `apple-dev-skills:choose-macos-virtualization-shape` and `apple-dev-skills:virtualization-framework-workflow`, then align Cybersecurity isolation handoffs. -- [x] Phase 2: update `server-side-swift:apple-containerization-workflow` for `container` 1.x and add `apple-dev-skills:linux-development-vm-workflow`. -- [x] Phase 3: add `apple-dev-skills:macos-development-vm-workflow` with restore-image, VM-bundle, identity, clean-baseline, and reset guidance. -- [x] Phase 4: add `cybersecurity-skills:prepare-isolated-analysis-lab` and align dynamic-analysis and macOS-investigation workflows around its lab record. -- [x] Forward-test the ten planned stable guidance paths through scenario contract tests before adding any tool-specific adapter skill; treat Lima, Colima, Tart, UTM, VMware Fusion, Parallels Desktop, OrbStack, and similar products as discovered adapters until repeated tasks justify a dedicated surface. -- [x] Regenerate portable Hermes exports, update Claude and Cowork classifications, refresh user-facing inventory text, and run affected child plus root validation with each shipped phase. ### Exit Criteria @@ -1048,6 +1051,15 @@ Completed Completed Milestone 30 by shipping four Apple Dev virtualization workflows, a disposable Cybersecurity lab-preparation workflow, Apple `container` 1.x and `container machine` guidance, guest-versus-host evidence rules, Hermes exports, Claude and Cowork compatibility metadata, and ten scenario-level forward tests. The rebased `9.19.0` release candidate preserves the concurrent Model Lab inventory and passed 268 Apple Dev tests, 126 Socket tests with one intentional skip, Apple and Cybersecurity child validators, Socket marketplace validation, Hermes parity, and Claude/Cowork validation. +### Planned Slices + +- [x] Phase 1: add `apple-dev-skills:choose-macos-virtualization-shape` and `apple-dev-skills:virtualization-framework-workflow`, then align Cybersecurity isolation handoffs. +- [x] Phase 2: update `server-side-swift:apple-containerization-workflow` for `container` 1.x and add `apple-dev-skills:linux-development-vm-workflow`. +- [x] Phase 3: add `apple-dev-skills:macos-development-vm-workflow` with restore-image, VM-bundle, identity, clean-baseline, and reset guidance. +- [x] Phase 4: add `cybersecurity-skills:prepare-isolated-analysis-lab` and align dynamic-analysis and macOS-investigation workflows around its lab record. +- [x] Forward-test the ten planned stable guidance paths through scenario contract tests before adding any tool-specific adapter skill; treat Lima, Colima, Tart, UTM, VMware Fusion, Parallels Desktop, OrbStack, and similar products as discovered adapters until repeated tasks justify a dedicated surface. +- [x] Regenerate portable Hermes exports, update Claude and Cowork classifications, refresh user-facing inventory text, and run affected child plus root validation with each shipped phase. + ## Milestone 31: macOS platform security skills expansion ### Status @@ -1062,13 +1074,9 @@ Completed - [x] Align existing Cybersecurity macOS workflows around explicit developer, research, threat-assessment, and isolation handoffs without moving defensive ownership. - [x] Keep the first implementation instruction-only: no TCC database mutation, permission grant service, privileged helper, daemon, endpoint agent, MCP server, protection bypass, or live-host prompt automation. -### Planned Slices +### Tickets + -- [x] Slice 1: ship `apple-dev-skills:macos-privacy-permissions-workflow` with responsible-code attribution, public API, prompt/settings, reset-only, PPPC, and disposable-fixture contracts. -- [x] Slice 2: ship `apple-dev-skills:macos-sandbox-file-access-workflow` and `apple-dev-skills:diagnose-apple-entitlements`, then align provisioning, distribution, extension, File Provider, and Xcode handoffs. -- [x] Slice 3: ship `reverse-engineering-skills:research-macos-security-control`, connect exact-build research and technical notes to existing artifact/signing/dynamic-analysis workflows, and align Cybersecurity handoffs. -- [x] Slice 4: forward-test the complete scenario matrix, remediate trigger and handoff gaps, update discovery metadata and user-facing inventory, export portable skills through Hermes, record Claude/Cowork compatibility, and run full affected validation. -- [x] Reconsider a standalone `macos-security-skills` plugin or shared read-only diagnostic collector only after repeated implementation use satisfies the explicit gate in the maintainer plan; retain the focused existing owners for this release. ### Exit Criteria @@ -1081,6 +1089,14 @@ Completed Completed Milestone 31 by shipping four focused, instruction-only macOS platform-security workflows across Apple Dev and Reverse Engineering Skills, aligning defensive Cybersecurity handoffs, preserving visible prompt and protection-state approval gates, exporting the portable skill set through Hermes, and recording Claude Code/Cowork compatibility without introducing a permission manager, privileged service, or duplicate plugin. +### Planned Slices + +- [x] Slice 1: ship `apple-dev-skills:macos-privacy-permissions-workflow` with responsible-code attribution, public API, prompt/settings, reset-only, PPPC, and disposable-fixture contracts. +- [x] Slice 2: ship `apple-dev-skills:macos-sandbox-file-access-workflow` and `apple-dev-skills:diagnose-apple-entitlements`, then align provisioning, distribution, extension, File Provider, and Xcode handoffs. +- [x] Slice 3: ship `reverse-engineering-skills:research-macos-security-control`, connect exact-build research and technical notes to existing artifact/signing/dynamic-analysis workflows, and align Cybersecurity handoffs. +- [x] Slice 4: forward-test the complete scenario matrix, remediate trigger and handoff gaps, update discovery metadata and user-facing inventory, export portable skills through Hermes, record Claude/Cowork compatibility, and run full affected validation. +- [x] Reconsider a standalone `macos-security-skills` plugin or shared read-only diagnostic collector only after repeated implementation use satisfies the explicit gate in the maintainer plan; retain the focused existing owners for this release. + ## Milestone 32: tvOS app experience and media playback workflows ### Status @@ -1096,6 +1112,10 @@ Completed - [x] Keep tvOS 27 claims beta-qualified and recheck them at release candidate/GM; do not claim direct Core AI or Foundation Models inference support for tvOS without an official platform contract. - [x] Deliver the two backward-compatible skills in Socket v9.23.0, with Apple Dev metadata, portability exports, compatibility records, child/root validation, release evidence, and branch accounting. +### Tickets + + + ### Exit Criteria - [x] The two workflows have distinct triggers, documented Apple behavior, stable/beta evidence labels, platform restrictions, and no duplicate general-framework ownership. diff --git a/global.json b/global.json new file mode 100644 index 000000000..f7772fee4 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.300", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/justfile b/justfile new file mode 100644 index 000000000..4b5967277 --- /dev/null +++ b/justfile @@ -0,0 +1,17 @@ +set shell := ["zsh", "-cu"] + +repo-report repo_root="." profile="generic": + dotnet fsi plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx --repo-root {{ quote(repo_root) }} --operation report-only --profile {{ quote(profile) }} + +repo-install repo_root="." profile="generic": + dotnet fsi plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx --repo-root {{ quote(repo_root) }} --operation install --profile {{ quote(profile) }} + +repo-refresh repo_root="." profile="generic": + dotnet fsi plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx --repo-root {{ quote(repo_root) }} --operation refresh --profile {{ quote(profile) }} + +test: + dotnet fsi tests/repository-maintenance-e2e.fsx + +# BEGIN managed repo-maintenance +import 'scripts/repo-maintenance/repo-maintenance.just' +# END managed repo-maintenance diff --git a/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx b/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx new file mode 100644 index 000000000..901a8a1c9 --- /dev/null +++ b/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx @@ -0,0 +1,116 @@ +module DocsCoordinator + +open System +open System.IO +open System.Text.Json +open ProjectDocs + +type DocumentAsset = { Name: string; Target: string; Contract: string; Template: string } + +type DocsReport = { + Mode: string + DocumentOrder: string list + Documents: DocumentReport list + ResponsibilityIssues: Finding list + Applied: bool + Errors: string list +} + +let private parseArgs argv = + let mutable projectRoot = "." + let mutable mode = CheckOnly + let mutable format = "markdown" + let mutable failOnIssues = false + let rec loop args = + match args with + | [] -> () + | "--project-root" :: value :: tail -> projectRoot <- value; loop tail + | "--run-mode" :: "check-only" :: tail -> mode <- CheckOnly; loop tail + | "--run-mode" :: "apply" :: tail -> mode <- Apply; loop tail + | "--format" :: value :: tail -> format <- value; loop tail + | "--fail-on-issues" :: tail -> failOnIssues <- true; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + projectRoot, mode, format, failOnIssues + +let private headingPresent (heading: string) (text: string) = + let pattern = $"(?im)^#+\\s+{System.Text.RegularExpressions.Regex.Escape(heading)}\\s*$" + System.Text.RegularExpressions.Regex.IsMatch(text, pattern) + +let private auditResponsibilities root = + let read file = let path = Path.Combine(root, file) in if File.Exists(path) then File.ReadAllText(path) else "" + let findings = ResizeArray() + let check file headings owner id = + let text = read file + for heading in headings do + if headingPresent heading text then + findings.Add({ Id = id; Severity = "warning"; Message = $"{file} contains '{heading}', whose canonical owner is {owner}." }) + check "README.md" [ "Contribution Workflow"; "Review Expectations"; "Release Process" ] "CONTRIBUTING.md or maintainer docs" "readme-responsibility-drift" + check "CONTRIBUTING.md" [ "Product Principles"; "Milestones"; "Small Tickets" ] "ROADMAP.md" "contributing-responsibility-drift" + check "AGENTS.md" [ "Quick Start"; "Usage"; "Known Gaps" ] "README.md or ROADMAP.md" "agents-responsibility-drift" + check "ROADMAP.md" [ "Contribution Workflow"; "Local Setup"; "Safety Boundaries" ] "CONTRIBUTING.md or AGENTS.md" "roadmap-responsibility-drift" + List.ofSeq findings + +let private jsonOptions = + let value = JsonSerializerOptions(WriteIndented = true) + value.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + value + +let private renderMarkdown report = + let lines = ResizeArray() + lines.Add("# Project documentation maintenance report") + lines.Add("") + lines.Add($"- Mode: `{report.Mode}`") + lines.Add($"- Applied: `{report.Applied.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Documents") + lines.Add("") + for document in report.Documents do + lines.Add($"- `{document.Document}`: {document.Findings.Length} finding(s), {document.Fixes.Length} fix(es), changed `{document.Changed.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Responsibility issues") + lines.Add("") + if List.isEmpty report.ResponsibilityIssues then lines.Add("- None.") + else for issue in report.ResponsibilityIssues do lines.Add($"- `{issue.Id}`: {issue.Message}") + lines.Add("") + lines.Add("## Errors") + lines.Add("") + if List.isEmpty report.Errors then lines.Add("- None.") + else for error in report.Errors do lines.Add($"- {error}") + String.Join("\n", lines) + "\n" + +let execute (assets: DocumentAsset list) argv = + try + let rootArg, mode, format, failOnIssues = parseArgs argv + let root = Path.GetFullPath(rootArg) + let options target = { + ProjectRoot = root; TargetPath = Some target; RunMode = mode; Format = format + FailOnIssues = failOnIssues; CollectSourceTickets = false; CollectGithubIssues = false + GithubRepo = None; TicketSection = None; TicketText = None; TicketState = None + TicketSource = None; TicketMatch = None; AllowDuplicate = false + } + let plans = assets |> List.map (fun asset -> planDocument asset.Contract asset.Template (options asset.Target)) + let planningErrors = plans |> List.collect (fun plan -> plan.Report.Errors) + let applyErrors, applied = + if mode = Apply && List.isEmpty planningErrors then + match applyPlans plans with | Ok () -> [], true | Error errors -> errors, false + else [], false + let responsibilityIssues = auditResponsibilities root + let report = { + Mode = if mode = Apply then "apply" else "check-only" + DocumentOrder = assets |> List.map (fun asset -> asset.Target) + Documents = plans |> List.map (fun plan -> plan.Report) + ResponsibilityIssues = responsibilityIssues + Applied = applied + Errors = planningErrors @ applyErrors + } + Console.Out.Write(if format = "json" then JsonSerializer.Serialize(report, jsonOptions) + "\n" else renderMarkdown report) + let issueCount = + report.Documents + |> List.sumBy (fun document -> document.Findings |> List.filter (fun finding -> finding.Severity = "error") |> List.length) + if not (List.isEmpty report.Errors) then 1 + elif failOnIssues && (issueCount > 0 || not (List.isEmpty responsibilityIssues)) then 2 + else 0 + with error -> + Console.Error.WriteLine($"ERROR: {error.Message}") + 1 diff --git a/plugins/repository-skills/shared/project-docs/ProjectDocs.fsx b/plugins/repository-skills/shared/project-docs/ProjectDocs.fsx new file mode 100644 index 000000000..d5733cb0c --- /dev/null +++ b/plugins/repository-skills/shared/project-docs/ProjectDocs.fsx @@ -0,0 +1,663 @@ +module ProjectDocs + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json +open System.Text.RegularExpressions + +type RunMode = + | CheckOnly + | Apply + +type DocumentKind = + | Readme + | Contributing + | Agents + | Roadmap + +type Section = { + Heading: string + Body: string +} + +type ParsedDocument = { + Preamble: string + Sections: Section list +} + +type Alias = { + Canonical: string + Values: string list +} + +type Contract = { + SchemaVersion: int + Kind: DocumentKind + TargetFile: string + RequireTableOfContents: bool + PreservePreamble: bool + AllowAdditionalSections: bool + RequiredSections: string list + SectionOrder: string list + RequiredSubsections: Map + SectionAliases: Alias list + SubsectionAliases: Alias list + AllowedStatuses: string list + StatusAliases: Alias list +} + +type Finding = { + Id: string + Severity: string + Message: string +} + +type Fix = { + Id: string + Message: string +} + +type DocumentReport = { + Document: string + Path: string + Mode: string + Findings: Finding list + Fixes: Fix list + Changed: bool + Errors: string list +} + +type DocumentPlan = { + Report: DocumentReport + TargetPath: string + Original: string option + Rendered: string +} + +type CliOptions = { + ProjectRoot: string + TargetPath: string option + RunMode: RunMode + Format: string + FailOnIssues: bool + CollectSourceTickets: bool + CollectGithubIssues: bool + GithubRepo: string option + TicketSection: string option + TicketText: string option + TicketState: string option + TicketSource: string option + TicketMatch: string option + AllowDuplicate: bool +} + +let private normalizeNewlines (text: string) = + text.Replace("\r\n", "\n").Replace("\r", "\n") + +let private normalizedBody (text: string) = + normalizeNewlines text + |> fun value -> value.Trim('\n') + +let private canonicalText (text: string) = + normalizeNewlines text + |> fun value -> value.TrimEnd() + |> fun value -> value + "\n" + +let private headingRegex level = + Regex($"^#{{{level}}}\\s+(.+?)\\s*$", RegexOptions.Compiled) + +let private splitAtHeadings level (text: string) = + let lines = normalizeNewlines text |> fun value -> value.Split('\n') + let regex = headingRegex level + let mutable inFence = false + let mutable preamble = ResizeArray() + let sections = ResizeArray
() + let mutable currentHeading: string option = None + let mutable currentBody = ResizeArray() + + let flush () = + match currentHeading with + | Some heading -> + sections.Add({ Heading = heading; Body = String.Join("\n", currentBody) |> normalizedBody }) + | None -> preamble <- ResizeArray(currentBody) + currentBody <- ResizeArray() + + for line in lines do + if line.TrimStart().StartsWith("```") || line.TrimStart().StartsWith("~~~") then + inFence <- not inFence + + let matched = if inFence then Match.Empty else regex.Match(line) + if matched.Success then + flush () + currentHeading <- Some(matched.Groups[1].Value.Trim()) + else + currentBody.Add(line) + + flush () + normalizedBody (String.Join("\n", preamble)), List.ofSeq sections + +let parseDocument text = + let preamble, sections = splitAtHeadings 2 text + { Preamble = preamble; Sections = sections } + +let private parseSubsections body = + let intro, sections = splitAtHeadings 3 body + intro, sections + +let private slugify (heading: string) = + let lowered = heading.Trim().ToLowerInvariant() + Regex.Replace(lowered, "[^a-z0-9\\s-]", "") + |> fun value -> Regex.Replace(value, "[\\s-]+", "-") + |> fun value -> value.Trim('-') + +let private sectionMap sections = + sections + |> List.map (fun section -> section.Heading, section) + |> Map.ofList + +let private aliasMap aliases = + aliases + |> List.collect (fun alias -> alias.Values |> List.map (fun value -> value, alias.Canonical)) + |> Map.ofList + +let private parseKind value = + match value with + | "readme" -> Readme + | "contributing" -> Contributing + | "agents" -> Agents + | "roadmap" -> Roadmap + | unsupported -> failwith $"Unsupported managed document kind: {unsupported}" + +let private stringList (element: JsonElement) = + element.EnumerateArray() + |> Seq.map (fun item -> item.GetString() |> Option.ofObj |> Option.defaultValue "") + |> Seq.toList + +let private aliases (root: JsonElement) (propertyName: string) = + match root.TryGetProperty(propertyName) with + | true, value -> + value.EnumerateObject() + |> Seq.map (fun property -> { Canonical = property.Name; Values = stringList property.Value }) + |> Seq.toList + | false, _ -> [] + +let loadContract path = + use document = JsonDocument.Parse(File.ReadAllText(path)) + let root = document.RootElement + let requiredSubsections = + match root.TryGetProperty("requiredSubsections") with + | true, value -> + value.EnumerateObject() + |> Seq.map (fun property -> property.Name, stringList property.Value) + |> Map.ofSeq + | false, _ -> Map.empty + + let optionalList (propertyName: string) = + match root.TryGetProperty(propertyName) with + | true, value -> stringList value + | false, _ -> [] + + { + SchemaVersion = root.GetProperty("schemaVersion").GetInt32() + Kind = root.GetProperty("document").GetString() |> parseKind + TargetFile = root.GetProperty("targetFile").GetString() + RequireTableOfContents = root.GetProperty("requireTableOfContents").GetBoolean() + PreservePreamble = root.GetProperty("preservePreamble").GetBoolean() + AllowAdditionalSections = root.GetProperty("allowAdditionalSections").GetBoolean() + RequiredSections = stringList (root.GetProperty("requiredSections")) + SectionOrder = stringList (root.GetProperty("sectionOrder")) + RequiredSubsections = requiredSubsections + SectionAliases = aliases root "sectionAliases" + SubsectionAliases = aliases root "subsectionAliases" + AllowedStatuses = optionalList "allowedStatuses" + StatusAliases = aliases root "statusAliases" + } + +let private sectionAliasLookup contract = aliasMap contract.SectionAliases + +let private subsectionAliasLookup contract = aliasMap contract.SubsectionAliases + +let private canonicalizeHeading lookup heading = + lookup |> Map.tryFind heading |> Option.defaultValue heading + +let private canonicalizeSections contract sections = + let lookup = sectionAliasLookup contract + sections + |> List.map (fun section -> { section with Heading = canonicalizeHeading lookup section.Heading }) + +let private milestoneRegex = Regex("^Milestone\\s+(\\d+)\\s*:\\s*(.+?)\\s*$", RegexOptions.Compiled) + +let private isMilestone (heading: string) = milestoneRegex.IsMatch(heading) + +let private requiredSubsectionsFor contract sectionHeading = + match contract.RequiredSubsections |> Map.tryFind sectionHeading with + | Some required -> Some required + | None when isMilestone sectionHeading -> contract.RequiredSubsections |> Map.tryFind "__MILESTONE__" + | None -> None + +let private renderSubsections contract sectionHeading existingBody templateBody = + match requiredSubsectionsFor contract sectionHeading with + | None -> existingBody, [] + | Some required -> + let intro, existing = parseSubsections existingBody + let _, templates = parseSubsections templateBody + let lookup = subsectionAliasLookup contract + let normalizedExisting = + existing + |> List.map (fun section -> { section with Heading = canonicalizeHeading lookup section.Heading }) + let existingMap = sectionMap normalizedExisting + let templateMap = sectionMap templates + let fixes = ResizeArray() + let ordered = + required + |> List.map (fun heading -> + match existingMap |> Map.tryFind heading with + | Some section -> section + | None -> + fixes.Add({ Id = "add-subsection"; Message = $"Added missing subsection '{sectionHeading} > {heading}'." }) + if isMilestone sectionHeading then { Heading = heading; Body = "" } + else + templateMap + |> Map.tryFind heading + |> Option.defaultValue { Heading = heading; Body = "TBD" }) + let extras = normalizedExisting |> List.filter (fun section -> not (List.contains section.Heading required)) + let rendered = + [ if not (String.IsNullOrWhiteSpace intro) then yield normalizedBody intro + for section in ordered @ extras do + yield $"### {section.Heading}\n\n{normalizedBody section.Body}" ] + |> String.concat "\n\n" + rendered, List.ofSeq fixes + +let private milestoneNumber (heading: string) = + let matched = milestoneRegex.Match(heading) + if matched.Success then Int32.Parse(matched.Groups[1].Value) else Int32.MaxValue + +let private topLevelOrder contract sections = + let byHeading = sectionMap sections + let milestones = sections |> List.filter (fun section -> isMilestone section.Heading) |> List.sortBy (fun section -> milestoneNumber section.Heading) + let required = Set.ofList contract.RequiredSections + let aliases = sectionAliasLookup contract |> Map.toSeq |> Seq.map fst |> Set.ofSeq + let extras = + sections + |> List.filter (fun section -> + section.Heading <> "Table of Contents" + && not (required.Contains section.Heading) + && not (aliases.Contains section.Heading) + && not (isMilestone section.Heading)) + contract.SectionOrder + |> List.collect (fun heading -> + if heading = "__MILESTONES__" then milestones + else byHeading |> Map.tryFind heading |> Option.toList) + |> fun ordered -> if contract.AllowAdditionalSections then ordered @ extras else ordered + +let private buildToc sections = + sections + |> List.filter (fun section -> section.Heading <> "Table of Contents") + |> List.map (fun section -> $"- [{section.Heading}](#{slugify section.Heading})") + |> String.concat "\n" + +let private textOutsideFences (text: string) = + let mutable inFence = false + normalizeNewlines text + |> fun value -> value.Split('\n') + |> Array.choose (fun line -> + if line.TrimStart().StartsWith("```") || line.TrimStart().StartsWith("~~~") then + inFence <- not inFence + None + elif inFence then None + else Some line) + |> String.concat "\n" + +let private containsManagedPlaceholder body = + let lines = textOutsideFences body |> fun value -> value.Split('\n') + lines + |> Array.exists (fun line -> + let value = line.Trim() + value = "TBD" + || value.StartsWith("Explain ") + || value.StartsWith("Describe ") + || value.StartsWith("Summarize ") + || value.StartsWith("State any ") + || value.StartsWith("Record ") + || value.StartsWith("Add the first ") + || value.StartsWith("Replace this ")) + +let private canonicalStatus contract (value: string) = + contract.AllowedStatuses + |> List.tryFind (fun allowed -> String.Equals(allowed, value.Trim(), StringComparison.OrdinalIgnoreCase)) + |> Option.orElseWith (fun () -> + contract.StatusAliases + |> List.tryPick (fun alias -> + if alias.Values |> List.exists (fun candidate -> String.Equals(candidate, value.Trim(), StringComparison.OrdinalIgnoreCase)) then Some alias.Canonical else None)) + +let private normalizeMilestoneStatus contract body = + let intro, children = parseSubsections body + let fixes = ResizeArray() + let normalized = + children + |> List.map (fun child -> + if child.Heading <> "Status" then child + else + match canonicalStatus contract child.Body with + | Some status when status <> child.Body.Trim() -> + fixes.Add({ Id = "normalize-milestone-status"; Message = $"Normalized milestone status '{child.Body.Trim()}' to '{status}'." }) + { child with Body = status } + | _ -> child) + let rendered = + [ if not (String.IsNullOrWhiteSpace intro) then yield normalizedBody intro + for child in normalized do yield $"### {child.Heading}\n\n{normalizedBody child.Body}" ] + |> String.concat "\n\n" + rendered, List.ofSeq fixes + +let private milestoneStatus body = + let _, children = parseSubsections body + children |> List.tryFind (fun child -> child.Heading = "Status") |> Option.map (fun child -> child.Body.Trim()) |> Option.defaultValue "Planned" + +let private audit contract (document: ParsedDocument) = + let findings = ResizeArray() + let normalizedSections = canonicalizeSections contract document.Sections + let headings = normalizedSections |> List.map (fun section -> section.Heading) + + for required in contract.RequiredSections do + if not (List.contains required headings) then + findings.Add({ Id = "missing-section"; Severity = "error"; Message = $"Missing required section '{required}'." }) + + if contract.RequireTableOfContents && not (List.contains "Table of Contents" headings) then + findings.Add({ Id = "missing-table-of-contents"; Severity = "error"; Message = "Missing required Table of Contents." }) + + if contract.RequireTableOfContents then + match normalizedSections |> List.tryFind (fun section -> section.Heading = "Table of Contents") with + | Some toc -> + let expected = normalizedSections |> List.filter (fun section -> section.Heading <> "Table of Contents") |> buildToc + if normalizedBody toc.Body <> normalizedBody expected then + findings.Add({ Id = "stale-table-of-contents"; Severity = "error"; Message = "Table of Contents does not match the canonical top-level heading order." }) + | None -> () + + for section in normalizedSections do + match requiredSubsectionsFor contract section.Heading with + | None -> () + | Some requiredChildren -> + let _, children = parseSubsections section.Body + let lookup = subsectionAliasLookup contract + let childHeadings = children |> List.map (fun child -> canonicalizeHeading lookup child.Heading) + for child in requiredChildren do + if not (List.contains child childHeadings) then + findings.Add({ Id = "missing-subsection"; Severity = "error"; Message = $"Missing required subsection '{section.Heading} > {child}'." }) + + for section in normalizedSections do + if containsManagedPlaceholder section.Body then + findings.Add({ Id = "placeholder-content"; Severity = "warning"; Message = $"Section '{section.Heading}' contains managed placeholder content." }) + + if contract.Kind = Roadmap then + for section in normalizedSections |> List.filter (fun value -> isMilestone value.Heading) do + let _, children = parseSubsections section.Body + match children |> List.tryFind (fun child -> child.Heading = "Status") with + | Some status when canonicalStatus contract status.Body |> Option.isNone -> + findings.Add({ Id = "invalid-milestone-status"; Severity = "error"; Message = $"{section.Heading} has unsupported status '{status.Body.Trim()}'." }) + | _ -> () + + List.ofSeq findings + +let private renderDocument preamble sections = + [ if not (String.IsNullOrWhiteSpace preamble) then yield normalizedBody preamble + for section in sections do + yield $"## {section.Heading}\n\n{normalizedBody section.Body}" ] + |> String.concat "\n\n" + |> canonicalText + +let private normalizeDocument contract template current = + let templateSections = template.Sections |> canonicalizeSections contract + let currentSections = current.Sections |> canonicalizeSections contract + let templateMap = sectionMap templateSections + let currentMap = sectionMap currentSections + let fixes = ResizeArray() + + let materialized = + contract.RequiredSections + |> List.map (fun heading -> + let templateSection = templateMap |> Map.tryFind heading |> Option.defaultValue { Heading = heading; Body = "TBD" } + let existing = + match currentMap |> Map.tryFind heading with + | Some section -> section + | None -> + fixes.Add({ Id = "add-section"; Message = $"Added missing section '{heading}'." }) + templateSection + let body, subsectionFixes = renderSubsections contract heading existing.Body templateSection.Body + fixes.AddRange(subsectionFixes) + { existing with Body = body }) + + let milestones = currentSections |> List.filter (fun section -> isMilestone section.Heading) + let milestoneTemplate = templateSections |> List.tryFind (fun section -> isMilestone section.Heading) + let normalizedMilestones = + milestones + |> List.map (fun milestone -> + match milestoneTemplate with + | None -> milestone + | Some templateMilestone -> + let body, subsectionFixes = renderSubsections contract milestone.Heading milestone.Body templateMilestone.Body + fixes.AddRange(subsectionFixes) + let statusBody, statusFixes = normalizeMilestoneStatus contract body + fixes.AddRange(statusFixes) + { milestone with Body = statusBody }) + + let extras = + currentSections + |> List.filter (fun section -> + section.Heading <> "Table of Contents" + && not (List.contains section.Heading contract.RequiredSections) + && not (isMilestone section.Heading)) + + let materializedWithProgress = + if contract.Kind <> Roadmap then materialized + else + materialized + |> List.map (fun section -> + if section.Heading <> "Milestone Progress" then section + else + let progress = + normalizedMilestones + |> List.map (fun milestone -> $"- {milestone.Heading} - {milestoneStatus milestone.Body}") + |> String.concat "\n" + if normalizedBody section.Body <> normalizedBody progress then + fixes.Add({ Id = "refresh-milestone-progress"; Message = "Regenerated Milestone Progress from canonical milestone headings and statuses." }) + { section with Body = progress }) + let allWithoutToc = materializedWithProgress @ normalizedMilestones @ extras |> topLevelOrder contract + let withToc = + if contract.RequireTableOfContents then + { Heading = "Table of Contents"; Body = buildToc allWithoutToc } :: allWithoutToc + else allWithoutToc + let preamble = + if contract.PreservePreamble && not (String.IsNullOrWhiteSpace current.Preamble) then current.Preamble + else template.Preamble + renderDocument preamble withToc, List.ofSeq fixes + +let private resolveInside (root: string) (requested: string option) (fallback: string) = + let rootPath = Path.GetFullPath(root) + let candidate = + requested + |> Option.map (fun path -> if Path.IsPathRooted(path) then path else Path.Combine(rootPath, path)) + |> Option.defaultValue (Path.Combine(rootPath, fallback)) + |> Path.GetFullPath + let prefix = rootPath.TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar + if candidate <> rootPath && not (candidate.StartsWith(prefix, StringComparison.Ordinal)) then + failwith $"Target path must remain inside project root: {candidate}" + candidate + +let private atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let planDocument contractPath templatePath options = + try + let root = Path.GetFullPath(options.ProjectRoot) + if not (Directory.Exists(root)) then failwith $"Project root does not exist: {root}" + let contract = loadContract contractPath + if contract.SchemaVersion <> 1 then failwith $"Unsupported document contract schema: {contract.SchemaVersion}" + let target = resolveInside root options.TargetPath contract.TargetFile + let template = File.ReadAllText(templatePath) |> parseDocument + let currentText = if File.Exists(target) then File.ReadAllText(target) else File.ReadAllText(templatePath) + let current = parseDocument currentText + let beforeFindings = audit contract current + let rendered, fixes = normalizeDocument contract template current + let changed = not (File.Exists(target)) || canonicalText currentText <> rendered + let finalDocument = if options.RunMode = Apply then parseDocument rendered else current + let finalFindings = audit contract finalDocument + let report = { + Document = contract.TargetFile + Path = Path.GetRelativePath(root, target) + Mode = if options.RunMode = Apply then "apply" else "check-only" + Findings = if options.RunMode = Apply then finalFindings else beforeFindings + Fixes = if options.RunMode = Apply then fixes else [] + Changed = options.RunMode = Apply && changed + Errors = [] + } + { + Report = report + TargetPath = target + Original = if File.Exists(target) then Some currentText else None + Rendered = rendered + } + with error -> + let target = options.TargetPath |> Option.defaultValue "" + { + Report = { + Document = Path.GetFileName(target) + Path = target + Mode = if options.RunMode = Apply then "apply" else "check-only" + Findings = [] + Fixes = [] + Changed = false + Errors = [ error.Message ] + } + TargetPath = target + Original = None + Rendered = "" + } + +let applyPlans plans = + let errors = plans |> List.collect (fun plan -> plan.Report.Errors) + if not (List.isEmpty errors) then Error errors + else + let changed = plans |> List.filter (fun plan -> plan.Report.Changed) + let completed = ResizeArray() + try + for plan in changed do + atomicWrite plan.TargetPath plan.Rendered + completed.Add(plan) + Ok () + with error -> + for plan in Seq.rev completed do + match plan.Original with + | Some content -> atomicWrite plan.TargetPath content + | None when File.Exists(plan.TargetPath) -> File.Delete(plan.TargetPath) + | None -> () + Error [ $"Documentation apply failed and completed writes were rolled back: {error.Message}" ] + +let runDocument contractPath templatePath options = + let plan = planDocument contractPath templatePath options + if options.RunMode = Apply then + match applyPlans [ plan ] with + | Ok () -> plan.Report + | Error errors -> { plan.Report with Changed = false; Errors = errors } + else plan.Report + +let private jsonOptions = + let options = JsonSerializerOptions(WriteIndented = true) + options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + options + +let reportJson report = JsonSerializer.Serialize(report, jsonOptions) + "\n" + +let reportMarkdown report = + let lines = ResizeArray() + lines.Add($"# {report.Document} maintenance report") + lines.Add("") + lines.Add($"- Mode: `{report.Mode}`") + lines.Add($"- Changed: `{report.Changed.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Findings") + lines.Add("") + if List.isEmpty report.Findings then lines.Add("- None.") + else for finding in report.Findings do lines.Add($"- `{finding.Severity}` `{finding.Id}`: {finding.Message}") + lines.Add("") + lines.Add("## Fixes") + lines.Add("") + if List.isEmpty report.Fixes then lines.Add("- None.") + else for fix in report.Fixes do lines.Add($"- `{fix.Id}`: {fix.Message}") + lines.Add("") + lines.Add("## Errors") + lines.Add("") + if List.isEmpty report.Errors then lines.Add("- None.") + else for error in report.Errors do lines.Add($"- {error}") + String.Join("\n", lines) + "\n" + +let parseCli defaultTarget argv = + let mutable root = "." + let mutable target: string option = None + let mutable mode = CheckOnly + let mutable format = "markdown" + let mutable failOnIssues = false + let mutable collectSource = false + let mutable collectGithub = false + let mutable githubRepo: string option = None + let mutable ticketSection: string option = None + let mutable ticketText: string option = None + let mutable ticketState: string option = None + let mutable ticketSource: string option = None + let mutable ticketMatch: string option = None + let mutable allowDuplicate = false + let args = List.ofArray argv + let rec loop remaining = + match remaining with + | [] -> () + | "--project-root" :: value :: tail -> root <- value; loop tail + | "--target-path" :: value :: tail -> target <- Some value; loop tail + | "--run-mode" :: "check-only" :: tail -> mode <- CheckOnly; loop tail + | "--run-mode" :: "apply" :: tail -> mode <- Apply; loop tail + | "--format" :: value :: tail -> format <- value; loop tail + | "--fail-on-issues" :: tail -> failOnIssues <- true; loop tail + | "--collect-source-tickets" :: tail -> collectSource <- true; loop tail + | "--collect-github-issues" :: tail -> collectGithub <- true; loop tail + | "--github-repo" :: value :: tail -> githubRepo <- Some value; loop tail + | "--ticket-section" :: value :: tail -> ticketSection <- Some value; loop tail + | "--ticket-text" :: value :: tail -> ticketText <- Some value; loop tail + | "--ticket-state" :: value :: tail -> ticketState <- Some value; loop tail + | "--ticket-source" :: value :: tail -> ticketSource <- Some value; loop tail + | "--ticket-match" :: value :: tail -> ticketMatch <- Some value; loop tail + | "--allow-duplicate" :: tail -> allowDuplicate <- true; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop args + { + ProjectRoot = root + TargetPath = target |> Option.orElse (Some defaultTarget) + RunMode = mode + Format = format + FailOnIssues = failOnIssues + CollectSourceTickets = collectSource + CollectGithubIssues = collectGithub + GithubRepo = githubRepo + TicketSection = ticketSection + TicketText = ticketText + TicketState = ticketState + TicketSource = ticketSource + TicketMatch = ticketMatch + AllowDuplicate = allowDuplicate + } + +let execute contractPath templatePath defaultTarget argv = + try + let options = parseCli defaultTarget argv + let report = runDocument contractPath templatePath options + let output = if options.Format = "json" then reportJson report else reportMarkdown report + Console.Out.Write(output) + if not (List.isEmpty report.Errors) then 1 + elif options.FailOnIssues && not (List.isEmpty report.Findings) then 2 + else 0 + with error -> + Console.Error.WriteLine($"ERROR: {error.Message}") + 1 diff --git a/plugins/repository-skills/skills/maintain-project-agents/SKILL.md b/plugins/repository-skills/skills/maintain-project-agents/SKILL.md index acdb4b9d4..01bc0baaa 100644 --- a/plugins/repository-skills/skills/maintain-project-agents/SKILL.md +++ b/plugins/repository-skills/skills/maintain-project-agents/SKILL.md @@ -1,106 +1,55 @@ --- name: maintain-project-agents -description: Maintain project-local AGENTS.md files with deterministic audit and bounded apply modes. Use for durable repository guidance, grounded commands, review expectations, safety boundaries, normalization, or targeted fixes. +description: Maintain AGENTS.md as the agent-policy member of the canonical four-document repository suite. --- # Maintain Project Agents -Maintain project-local `AGENTS.md` files through one deterministic AGENTS workflow. +## Purpose -This skill is the default baseline path for `AGENTS.md` maintenance across most repositories. Reach for a narrower plugin only when the target repo has a specialized shape that deserves its own maintainer contract, such as a skills-export or plugin-export repository. +Keep project-local `AGENTS.md` compact, durable, grounded, and specific while +README, CONTRIBUTING, AGENTS, and ROADMAP are maintained together. -## Inputs +## Commands -- Required: `--project-root ` -- Required: `--run-mode ` -- Optional: `--agents-path ` -- Optional: `--config ` +The complete documentation command surface is: -## Workflow +```text +just docs-check +just docs-apply +``` -1. Validate the project root and resolve the target `AGENTS.md`. -2. Load the canonical AGENTS schema from `config/agents-customization.template.yaml`, then merge any explicit override or project-local customization file. -3. In `check-only`, audit the required AGENTS sections, required subsection structure, command formatting, workflow routing guidance, and safety boundaries. -4. In `apply`, keep edits bounded to the target `AGENTS.md` while creating a missing file from the bundled template and normalizing the document to the canonical structure. -5. Re-run the same audit to confirm post-fix status. +Both commands process all four documents. Do not create per-file recipes or +document direct `.fsx` entrypoints. -## Canonical Base Contract +## Managed Contract -The source of truth for the base AGENTS contract lives in: +- `assets/document.contract.json` fixes structure, ordering, and aliases. +- `assets/AGENTS.template.md` supplies deterministic bootstrap scaffolding. +- No project-local structural customization is supported. +- Existing grounded policy and allowed additional sections are preserved. -- `config/agents-customization.template.yaml` -- `assets/AGENTS.template.md` - -The base contract requires: - -- a top-level title and short preamble -- canonical top-level sections for repo scope, working rules, commands, review and delivery, safety boundaries, and local overrides -- required subsection structure for those sections where specific guidance needs to be easy to scan and maintain - -## Writing Expectations - -- Keep the file compact, practical, and repo-specific. -- Keep the whole AGENTS file near 250 lines or less by default. Treat 300 lines as a soft ceiling that should trigger consolidation, trimming, or moving non-agent material to README, CONTRIBUTING, ROADMAP, or maintainer docs. -- Keep most top-level sections near 40 lines or less and most subsections near 20 lines or less. Prefer durable rules, routing, and commands over long explanations or historical context. -- `Repository Scope > Where To Look First` should route Codex toward the few highest-value files or directories, not try to summarize the whole repo. -- `Commands` should prefer fenced code blocks with language info strings for setup and validation commands. -- `Review and Delivery` should explain what good handoff looks like and what “done” means in this repo, including grounded verification and nearby updates when they matter. -- `Safety Boundaries` should stay concrete, high-signal, and easy to scan. -- `Local Overrides` should briefly explain whether more specific AGENTS files or fallback instruction files exist below this root, and make clear that closer guidance refines this root file later in the instruction chain. -- Keep AGENTS focused on durable agent-facing rules. Product explanation belongs in `README.md`; contributor workflow belongs in `CONTRIBUTING.md`; milestone, backlog, and small-ticket planning belong in `ROADMAP.md`; detailed architecture or release procedures belong in linked maintainer docs when they would bloat this file. - -## Alignment With Official Codex Guidance - -The base contract is shaped to match the official Codex `AGENTS.md` guidance: +## AGENTS Ownership -- keep repo-local guidance small and practical -- encode durable repo rules, commands, review expectations, and constraints -- add routing guidance when Codex reads too broadly -- update `AGENTS.md` when repeated mistakes or recurring review feedback reveal missing guidance -- acknowledge that more specific nested instruction files can refine the root guidance +AGENTS owns repository scope, source-of-truth routing, change boundaries, +commands, review and delivery rules, safety boundaries, and local overrides. +Product prose belongs in README, contributor workflow in CONTRIBUTING, and +planning in ROADMAP. -## Codex Subagent Fit +## Deterministic Workflow -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for bounded read-heavy checks before the main workflow edits or reports: auditing command accuracy, comparing repo instructions against nearby docs, checking safety boundaries, or reading nested guidance files in separate directories. - -Keep `apply` edits in the main thread because this skill owns one target `AGENTS.md` file and needs one coherent policy voice. If a target `AGENTS.md` mentions subagents, make the wording match OpenAI's current Codex rule: subagents need an explicit trigger, are best for bounded parallel discovery, tests, triage, and summarization, and may be called for by narrower plugin guidance that tells the agent to ask and receive permission before delegation. - -## Codex Hooks Fit - -When a target `AGENTS.md` mentions OpenAI Codex Hooks, keep the wording narrow and operational. Hooks are lifecycle scripts loaded from `hooks.json` or inline `[hooks]` config; they are enabled by default and can be disabled with `features.hooks = false`. Project-local hooks load only from trusted `.codex/` layers. - -Use hooks guidance in `AGENTS.md` only when the repo actually owns hook behavior or wants to warn contributors about repo-local Codex runtime checks. Name the event, matcher, script location, and user-visible effect. Do not present hooks as a replacement for `AGENTS.md`, approval policy, tests, or ordinary validation commands. - -## Output Contract - -- Return Markdown plus JSON with: - - `run_context` - - `schema_contract` - - `schema_violations` - - `workflow_drift_issues` - - `validation_drift_issues` - - `boundary_and_safety_issues` - - `fixes_applied` - - `post_fix_status` - - `errors` -- If there are no issues and no errors, output exactly `No findings.` +Use `just docs-check` for the full no-write audit and `just docs-apply` for the +atomic four-document apply. The coordinator validates every proposed document +before it writes any of them and rolls back completed replacements on failure. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent commands, toolchains, packaging surfaces, or project policy that are not grounded in the repo. -- Never edit files other than the target `AGENTS.md`. -- Preserve intentional repo-specific policy when it is already coherent and grounded. -- Treat `AGENTS.md` as maintainer and agent guidance, not as public README content. -- Treat this skill as a hard-enforced base template. Downstream plugins may specialize the schema, but the base skill should not do repo-profile inference. +- Never maintain AGENTS separately from the full document suite. +- Never invent commands, toolchains, packaging surfaces, or policy. +- Never add structural customization or alternate document modes. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `references/section-schema.md` -- `references/output-contract.md` -- `references/fix-policies.md` -- `references/style-rules.md` -- `references/agents-customization.md` -- `references/agents-config-schema.md` -- `references/project-agents-maintenance-automation-prompts.md` +- `assets/document.contract.json` +- `assets/AGENTS.template.md` diff --git a/plugins/repository-skills/skills/maintain-project-agents/assets/document.contract.json b/plugins/repository-skills/skills/maintain-project-agents/assets/document.contract.json new file mode 100644 index 000000000..c16e8ed64 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-agents/assets/document.contract.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "document": "agents", + "targetFile": "AGENTS.md", + "requireTableOfContents": false, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Repository Scope", "Working Rules", "Commands", "Review and Delivery", "Safety Boundaries", "Local Overrides"], + "sectionOrder": ["Repository Scope", "Working Rules", "Commands", "Review and Delivery", "Safety Boundaries", "Local Overrides"], + "requiredSubsections": { + "Repository Scope": ["What This File Covers", "Where To Look First"], + "Working Rules": ["Change Scope", "Source of Truth", "Communication and Escalation"], + "Commands": ["Setup", "Validation", "Optional Project Commands"], + "Review and Delivery": ["Review Expectations", "Definition of Done"], + "Safety Boundaries": ["Never Do", "Ask Before"] + }, + "sectionAliases": { + "Repository Scope": ["Repository Expectations"], + "Working Rules": ["Standards and Guidance"], + "Review and Delivery": ["Review"], + "Safety Boundaries": ["Safety and Boundaries"] + }, + "subsectionAliases": { + "What This File Covers": ["Purpose"], + "Where To Look First": ["Priority Files"], + "Change Scope": ["Scope"], + "Source of Truth": ["Truth Sources"], + "Communication and Escalation": ["Escalation"], + "Optional Project Commands": ["Project Commands"], + "Review Expectations": ["PR Expectations"], + "Definition of Done": ["Done"], + "Never Do": ["Never"], + "Ask Before": ["Approval Gates"] + } +} diff --git a/plugins/repository-skills/skills/maintain-project-agents/config/agents-customization.template.yaml b/plugins/repository-skills/skills/maintain-project-agents/config/agents-customization.template.yaml deleted file mode 100644 index 9bc27189e..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/config/agents-customization.template.yaml +++ /dev/null @@ -1,102 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - requiredSections: - - Repository Scope - - Working Rules - - Commands - - Review and Delivery - - Safety Boundaries - - Local Overrides - sectionOrder: - - Repository Scope - - Working Rules - - Commands - - Review and Delivery - - Safety Boundaries - - Local Overrides - requiredSubsections: - Repository Scope: - - What This File Covers - - Where To Look First - Working Rules: - - Change Scope - - Source of Truth - - Communication and Escalation - Commands: - - Setup - - Validation - - Optional Project Commands - Review and Delivery: - - Review Expectations - - Definition of Done - Safety Boundaries: - - Never Do - - Ask Before - sectionAliases: - Repository Scope: - - Repository Expectations - Working Rules: - - Standards and Guidance - Commands: - - Validation - Review and Delivery: - - Review - Safety Boundaries: - - Safety and Boundaries - subsectionAliases: - Repository Scope/What This File Covers: - - Purpose - Repository Scope/Where To Look First: - - Priority Files - Working Rules/Change Scope: - - Scope - Working Rules/Source of Truth: - - Truth Sources - Working Rules/Communication and Escalation: - - Escalation - Commands/Optional Project Commands: - - Project Commands - Review and Delivery/Review Expectations: - - PR Expectations - Review and Delivery/Definition of Done: - - Done - Safety Boundaries/Never Do: - - Never - Safety Boundaries/Ask Before: - - Approval Gates - sectionTemplates: - Local Overrides: | - Explain whether this repository uses more specific AGENTS files or fallback instruction files in subdirectories, and make clear that deeper guidance refines this root file when work happens there. If there are no deeper overrides, say that plainly. - subsectionTemplates: - Repository Scope/What This File Covers: | - Explain what this root-level AGENTS file governs for the repository. - Repository Scope/Where To Look First: | - Point to the few highest-value docs, directories, or files Codex should check first before it starts reading broadly. - Working Rules/Change Scope: | - Explain how to keep work bounded and what kinds of scope expansion should be surfaced explicitly. - Working Rules/Source of Truth: | - Explain which files, docs, or project surfaces Codex should trust first when there is ambiguity. - Working Rules/Communication and Escalation: | - Explain when Codex should stop, surface tradeoffs, or ask before widening scope, especially when the next step has non-obvious consequences. - Commands/Setup: | - ```bash - # Replace this with the grounded setup or sync command for the repository. - ``` - Commands/Validation: | - ```bash - # Replace this with the grounded validation command for the repository. - ``` - Commands/Optional Project Commands: | - State any other important repo-specific commands here, or say plainly that there are no additional project commands worth calling out. - Review and Delivery/Review Expectations: | - Explain what Codex should include or check before handing work back for review. - Review and Delivery/Definition of Done: | - Explain what must be true before work should be considered complete in this repository, including grounded verification and any nearby docs or tests that should be updated. - Safety Boundaries/Never Do: | - List the highest-signal actions Codex must not take in this repository. - Safety Boundaries/Ask Before: | - List the decisions or changes that require explicit approval first. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/agents-config-schema.md b/plugins/repository-skills/skills/maintain-project-agents/references/agents-config-schema.md deleted file mode 100644 index b10502672..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/agents-config-schema.md +++ /dev/null @@ -1,28 +0,0 @@ -# AGENTS Config Schema - -The AGENTS configuration file is YAML and uses this top-level shape: - -- `schemaVersion` -- `isCustomized` -- `profile` -- `settings` - -The `settings` map supports: - -- `preservePreamble` -- `allowAdditionalSections` -- `requiredSections` -- `sectionOrder` -- `requiredSubsections` -- `sectionAliases` -- `subsectionAliases` -- `sectionTemplates` -- `subsectionTemplates` - -Practical rules: - -- `requiredSections` defines the canonical top-level sections, excluding the title and preamble. -- `sectionOrder` defines the enforced top-level ordering. -- `requiredSubsections` defines required `###` headings under specific `##` sections. -- `sectionAliases` and `subsectionAliases` allow bounded migration from older heading names into canonical names. -- `sectionTemplates` and `subsectionTemplates` provide the base scaffolding used during apply mode when content is missing. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/agents-customization.md b/plugins/repository-skills/skills/maintain-project-agents/references/agents-customization.md deleted file mode 100644 index 72fbe5fa2..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/agents-customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# AGENTS Customization - -The base AGENTS contract is defined in `../config/agents-customization.template.yaml`. - -Downstream specializations may customize: - -- canonical section order -- required sections -- required subsection structure -- section and subsection aliases -- section and subsection scaffold text - -The base skill always requires: - -- a top-level title and short repo-local preamble -- deterministic normalization to the configured schema - -Use a project-local `config/agents-customization.yaml` or `--config ` override when a downstream plugin needs a narrower or expanded AGENTS structure. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/fix-policies.md b/plugins/repository-skills/skills/maintain-project-agents/references/fix-policies.md deleted file mode 100644 index af0530250..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/fix-policies.md +++ /dev/null @@ -1,8 +0,0 @@ -# Fix Policies - -- Keep edits bounded to the target `AGENTS.md`. -- Preserve existing section bodies whenever they already satisfy the schema. -- Create a missing `AGENTS.md` from the bundled template. -- Normalize canonical headings and subsection headings to the configured names and order. -- Add missing sections or subsections from the configured templates instead of inventing repo-fiction. -- Do not synthesize commands, toolchains, workflow policy, or review rules that are not grounded in the repository. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/output-contract.md b/plugins/repository-skills/skills/maintain-project-agents/references/output-contract.md deleted file mode 100644 index 37e0252b1..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/output-contract.md +++ /dev/null @@ -1,17 +0,0 @@ -# Output Contract - -Return Markdown plus JSON with: - -- `run_context` -- `schema_contract` -- `schema_violations` -- `workflow_drift_issues` -- `validation_drift_issues` -- `boundary_and_safety_issues` -- `fixes_applied` -- `post_fix_status` -- `errors` - -Clean-run rule: - -- Output exactly `No findings.` only when there are no remaining issues and no errors. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md b/plugins/repository-skills/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md deleted file mode 100644 index 3b49de687..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md +++ /dev/null @@ -1,18 +0,0 @@ -# Automation Prompts - -Use these prompts when validating or applying the skill through automation. - -## Check-only - -- Audit `AGENTS.md` for the canonical section order. -- Confirm the required subsection structure exists under the canonical sections. -- Flag placeholder content, weak routing guidance, malformed command blocks, and thin safety boundaries. -- Confirm setup and validation guidance uses grounded command examples with code-fence info strings when commands are present. - -## Apply - -- Create a missing `AGENTS.md` from the bundled template. -- Normalize `AGENTS.md` to the canonical section order. -- Preserve existing AGENTS guidance where it already matches the schema. -- Add missing sections or subsections from the configured templates only. -- Keep all edits bounded to `AGENTS.md`. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/section-schema.md b/plugins/repository-skills/skills/maintain-project-agents/references/section-schema.md deleted file mode 100644 index 32e9f3c02..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/section-schema.md +++ /dev/null @@ -1,45 +0,0 @@ -# Section Schema - -The canonical base `AGENTS.md` structure is defined by: - -- `../config/agents-customization.template.yaml` -- `../assets/AGENTS.template.md` - -Base top-level shape: - -1. top-level title -2. short repo-local preamble -3. `## Repository Scope` -4. `## Working Rules` -5. `## Commands` -6. `## Review and Delivery` -7. `## Safety Boundaries` -8. `## Local Overrides` - -Required subsection shape: - -- `Repository Scope` - - `What This File Covers` - - `Where To Look First` -- `Working Rules` - - `Change Scope` - - `Source of Truth` - - `Communication and Escalation` -- `Commands` - - `Setup` - - `Validation` - - `Optional Project Commands` -- `Review and Delivery` - - `Review Expectations` - - `Definition of Done` -- `Safety Boundaries` - - `Never Do` - - `Ask Before` - -Schema expectations: - -- Use `##` headings for top-level sections. -- Use `###` headings for required subsections. -- Preserve additional repo-specific sections when present, but keep canonical sections in canonical order. -- Keep the root AGENTS file compact and practical, in line with official Codex guidance. -- Treat `Local Overrides` as the place to explain whether deeper AGENTS files or fallback instruction files refine the root guidance. diff --git a/plugins/repository-skills/skills/maintain-project-agents/references/style-rules.md b/plugins/repository-skills/skills/maintain-project-agents/references/style-rules.md deleted file mode 100644 index 81595dcba..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/references/style-rules.md +++ /dev/null @@ -1,11 +0,0 @@ -# Style Rules - -- Keep AGENTS guidance direct, technical, durable, and repo-grounded. -- Prefer concise paragraphs and short bullet lists over sprawling policy prose. -- Keep the whole AGENTS file near 250 lines by default, with 300 lines as a soft ceiling for consolidation. -- Keep most top-level sections near 40 lines or less and most subsections near 20 lines or less. -- Treat source-of-truth rules, command guidance, and workflow boundaries as first-class AGENTS content. -- Use explicit section labels for commands, review expectations, and safety boundaries so maintainers and agents can scan quickly. -- Keep the root AGENTS file compact and practical, aligned with official Codex guidance for repo-local instructions. -- Treat routing guidance as a scan aid: point to the few highest-value files or directories first instead of trying to summarize the entire repo. -- Keep product overview, contributor workflow, backlog planning, and long maintainer procedures in their canonical docs instead of bloating AGENTS. diff --git a/plugins/repository-skills/skills/maintain-project-agents/scripts/maintain_project_agents.py b/plugins/repository-skills/skills/maintain-project-agents/scripts/maintain_project_agents.py deleted file mode 100644 index 2264bcbf5..000000000 --- a/plugins/repository-skills/skills/maintain-project-agents/scripts/maintain_project_agents.py +++ /dev/null @@ -1,773 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and apply bounded AGENTS.md maintenance from a hard-enforced schema.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -SHELL_FENCE_RE = re.compile(r"```([^\n`]*)\n(.*?)```", re.DOTALL) -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] - - -@dataclass -class Issue: - issue_id: str - category: str - severity: str - file: str - evidence: str - recommended_fix: str - auto_fixable: bool - fixed: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_id": self.issue_id, - "category": self.category, - "severity": self.severity, - "file": self.file, - "evidence": self.evidence, - "recommended_fix": self.recommended_fix, - "auto_fixable": self.auto_fixable, - "fixed": self.fixed, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded AGENTS.md maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--agents-path", help="Optional AGENTS path override") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional AGENTS config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when unresolved issues remain") - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - sections.append((match.group(1).strip(), text[start:end].strip("\n"))) - return preamble, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - subsections.append((match.group(1).strip(), body[start:end].strip("\n"))) - return preamble, subsections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, preserve_preamble: bool) -> str: - lines = [line.rstrip() for line in preamble.splitlines()] - title_present = any(line.startswith("# ") for line in lines) - summary: Optional[str] = None - extras: List[str] = [] - - for idx, line in enumerate(lines): - if line.startswith("# "): - for follow in lines[idx + 1 :]: - if follow.strip(): - summary = follow.strip() - break - break - - for line in lines: - if line.startswith("# "): - continue - if summary is None and line.strip(): - summary = line.strip() - continue - extras.append(line) - - normalized_title = "# AGENTS.md" if not title_present else next(line for line in lines if line.startswith("# ")) - normalized_summary = ( - summary - or "Use this file for durable repo-local guidance that Codex should follow before changing code, docs, or project workflow surfaces in this repository." - ) - output = [normalized_title, "", normalized_summary] - if preserve_preamble: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - output.extend(["", *extra_lines]) - return "\n".join(output).strip() - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "agents-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "agents-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - values = settings.get("requiredSections", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def canonical_order(settings: Dict[str, Any]) -> List[str]: - values = settings.get("sectionOrder", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def required_subsections(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("requiredSubsections", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def subsection_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("subsectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("subsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def section_alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - reverse: Dict[str, str] = {} - for canonical, aliases in section_aliases(settings).items(): - for alias in aliases: - reverse[alias] = canonical - return reverse - - -def subsection_alias_lookup(settings: Dict[str, Any]) -> Dict[Tuple[str, str], str]: - reverse: Dict[Tuple[str, str], str] = {} - for canonical_path, aliases in subsection_aliases(settings).items(): - if "/" not in canonical_path: - continue - parent, canonical_name = canonical_path.split("/", 1) - for alias in aliases: - reverse[(parent, alias)] = canonical_name - return reverse - - -def render_template_bootstrap() -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "AGENTS.template.md" - return normalize_whitespace(read_text(template_path)) - - -def render_section_body(heading: str, existing_body: str, settings: Dict[str, Any]) -> str: - required_children = required_subsections(settings).get(heading, []) - section_template_map = section_templates(settings) - subsection_template_map = subsection_templates(settings) - subsection_alias_map = subsection_alias_lookup(settings) - - if not required_children: - return existing_body.strip() or section_template_map.get(heading, "") - - preamble, subsections = split_subsections(existing_body) - subsection_lookup: Dict[str, str] = {} - extra_subsections: List[Tuple[str, str]] = [] - for name, body in subsections: - canonical_name = subsection_alias_map.get((heading, name), name) - if canonical_name in required_children and canonical_name not in subsection_lookup: - subsection_lookup[canonical_name] = body - else: - extra_subsections.append((name, body)) - - lines: List[str] = [] - if preamble.strip(): - lines.extend([preamble.strip(), ""]) - - for idx, child in enumerate(required_children): - body = subsection_lookup.get(child, "").strip() or subsection_template_map.get(f"{heading}/{child}", "") - lines.extend([f"### {child}", "", body.strip()]) - if idx < len(required_children) - 1 or extra_subsections: - lines.append("") - - for idx, (name, body) in enumerate(extra_subsections): - lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extra_subsections) - 1: - lines.append("") - - rendered = "\n".join(lines).strip() - return rendered or section_template_map.get(heading, "") - - -def validate_schema( - agents_path: Path, - agents_text: str, - config: Dict[str, Any], -) -> Tuple[List[Issue], List[Issue], List[Issue], List[Issue]]: - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - subsection_map = required_subsections(settings) - section_alias_map = section_alias_lookup(settings) - subsection_alias_map = subsection_alias_lookup(settings) - - schema_issues: List[Issue] = [] - workflow_issues: List[Issue] = [] - validation_issues: List[Issue] = [] - boundary_issues: List[Issue] = [] - - preamble, sections = split_sections(agents_text) - lookup = section_map(sections) - - if not any(line.startswith("# ") for line in preamble.splitlines()): - schema_issues.append( - Issue( - issue_id="missing-title", - category="schema", - severity="high", - file=str(agents_path), - evidence="AGENTS.md is missing a top-level '# AGENTS.md' title.", - recommended_fix="Add a clear AGENTS.md title at the top of the file.", - auto_fixable=True, - ) - ) - - if len([line for line in preamble.splitlines() if line.strip()]) < 2: - schema_issues.append( - Issue( - issue_id="missing-summary", - category="schema", - severity="medium", - file=str(agents_path), - evidence="AGENTS.md is missing a short repo-local preamble beneath the title.", - recommended_fix="Add a short preamble explaining what this AGENTS file governs.", - auto_fixable=True, - ) - ) - - observed = [heading for heading, _body in sections] - positions = {heading: idx for idx, heading in enumerate(observed)} - for heading in required: - if heading in lookup: - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in lookup), - None, - ) - if alias_found: - schema_issues.append( - Issue( - issue_id=f"non-canonical-heading-{heading.lower().replace(' ', '-')}", - category="schema", - severity="medium", - file=str(agents_path), - evidence=f"AGENTS.md uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - recommended_fix=f"Rename '## {alias_found}' to '## {heading}'.", - auto_fixable=True, - ) - ) - else: - schema_issues.append( - Issue( - issue_id=f"missing-section-{heading.lower().replace(' ', '-')}", - category="schema", - severity="high", - file=str(agents_path), - evidence=f"AGENTS.md is missing required section '## {heading}'.", - recommended_fix=f"Add the required '## {heading}' section.", - auto_fixable=True, - ) - ) - - order_positions: List[int] = [] - for heading in order: - if heading in positions: - order_positions.append(positions[heading]) - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in positions), - None, - ) - if alias_found: - order_positions.append(positions[alias_found]) - if order_positions and order_positions != sorted(order_positions): - schema_issues.append( - Issue( - issue_id="canonical-section-order", - category="schema", - severity="medium", - file=str(agents_path), - evidence="Canonical AGENTS sections are not in the configured order.", - recommended_fix="Normalize the top-level sections into canonical order.", - auto_fixable=True, - ) - ) - - for parent, children in subsection_map.items(): - body = lookup.get(parent, "") - if not body: - alias_parent = next( - (alias for alias, canonical in section_alias_map.items() if canonical == parent and alias in lookup), - None, - ) - body = lookup.get(alias_parent, "") if alias_parent else "" - if not body: - continue - _sub_preamble, subsections = split_subsections(body) - found = { - subsection_alias_map.get((parent, name), name): subsection_body - for name, subsection_body in subsections - } - for child in children: - if child not in found: - schema_issues.append( - Issue( - issue_id=f"missing-subsection-{parent.lower().replace(' ', '-')}-{child.lower().replace(' ', '-')}", - category="schema", - severity="high", - file=str(agents_path), - evidence=f"Section '## {parent}' is missing required subsection '### {child}'.", - recommended_fix=f"Add the required subsection '### {child}' under '## {parent}'.", - auto_fixable=True, - ) - ) - - if any(pattern.search(agents_text) for pattern in PLACEHOLDER_PATTERNS): - workflow_issues.append( - Issue( - issue_id="placeholder-content", - category="workflow-drift", - severity="medium", - file=str(agents_path), - evidence="AGENTS.md still contains TODO, TBD, or angle-bracket placeholder content.", - recommended_fix="Replace placeholder text with grounded repository guidance.", - auto_fixable=False, - ) - ) - - commands_body = lookup.get("Commands", "") - _cmd_preamble, cmd_subsections = split_subsections(commands_body) - cmd_lookup = {subsection_alias_map.get(("Commands", name), name): body for name, body in cmd_subsections} - for child in ("Setup", "Validation"): - body = cmd_lookup.get(child, "").strip() - if not body: - continue - fences = list(SHELL_FENCE_RE.finditer(body)) - if not fences: - validation_issues.append( - Issue( - issue_id=f"missing-command-block-{child.lower()}", - category="validation-drift", - severity="medium", - file=str(agents_path), - evidence=f"Commands > {child} should include grounded command examples, preferably in fenced code blocks.", - recommended_fix=f"Add a fenced code block with grounded {child.lower()} commands.", - auto_fixable=False, - ) - ) - continue - for match in fences: - info = match.group(1).strip() - block = match.group(2).strip() - if not info: - validation_issues.append( - Issue( - issue_id=f"missing-code-fence-info-string-{child.lower()}-{match.start()}", - category="validation-drift", - severity="low", - file=str(agents_path), - evidence=f"Commands > {child} uses a fenced code block without a language info string.", - recommended_fix="Use fenced code blocks with an info string such as ```bash for command examples.", - auto_fixable=False, - ) - ) - if not block: - validation_issues.append( - Issue( - issue_id=f"empty-command-block-{child.lower()}-{match.start()}", - category="validation-drift", - severity="medium", - file=str(agents_path), - evidence=f"Commands > {child} contains an empty fenced code block.", - recommended_fix="Remove the empty block or replace it with grounded commands.", - auto_fixable=True, - ) - ) - if any(pattern.search(block) for pattern in PLACEHOLDER_PATTERNS): - validation_issues.append( - Issue( - issue_id=f"placeholder-command-block-{child.lower()}-{match.start()}", - category="validation-drift", - severity="high", - file=str(agents_path), - evidence=f"Commands > {child} contains a placeholder command block.", - recommended_fix="Replace the placeholder command block with grounded commands or prose.", - auto_fixable=False, - ) - ) - - where_to_look_body = lookup.get("Repository Scope", "") - _scope_preamble, scope_subsections = split_subsections(where_to_look_body) - scope_lookup = {subsection_alias_map.get(("Repository Scope", name), name): body for name, body in scope_subsections} - if scope_lookup.get("Where To Look First", "").strip() and len(scope_lookup["Where To Look First"].split()) < 6: - workflow_issues.append( - Issue( - issue_id="thin-where-to-look-first", - category="workflow-drift", - severity="medium", - file=str(agents_path), - evidence="Repository Scope > Where To Look First is too thin to route agent reading behavior.", - recommended_fix="Point to the most important docs, directories, or files agents should check first.", - auto_fixable=False, - ) - ) - - safety_body = lookup.get("Safety Boundaries", "") - _safety_preamble, safety_subsections = split_subsections(safety_body) - safety_lookup = {subsection_alias_map.get(("Safety Boundaries", name), name): body for name, body in safety_subsections} - for child in ("Never Do", "Ask Before"): - body = safety_lookup.get(child, "").strip() - if body and len(body.split()) < 6: - boundary_issues.append( - Issue( - issue_id=f"thin-safety-guidance-{child.lower().replace(' ', '-')}", - category="boundary-and-safety", - severity="medium", - file=str(agents_path), - evidence=f"Safety Boundaries > {child} is too thin to act as a useful guardrail.", - recommended_fix=f"Expand '{child}' with concrete, repo-grounded boundaries.", - auto_fixable=False, - ) - ) - - return schema_issues, workflow_issues, validation_issues, boundary_issues - - -def apply_fixes( - agents_path: Path, - agents_text: str, - config: Dict[str, Any], -) -> Tuple[str, List[Dict[str, str]]]: - if not agents_text.strip(): - bootstrap = render_template_bootstrap() - write_text(agents_path, bootstrap) - return ( - bootstrap, - [ - { - "action": "create-agents-from-template", - "file": str(agents_path), - "reason": "Created a missing AGENTS.md from the bundled canonical template.", - } - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - preserve_preamble = bool(settings.get("preservePreamble", True)) - allow_additional = bool(settings.get("allowAdditionalSections", True)) - section_alias_map = section_alias_lookup(settings) - - preamble, sections = split_sections(agents_text) - normalized_preamble = normalize_preamble(preamble, preserve_preamble) - - canonical_lookup: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - for heading, body in sections: - canonical_heading = section_alias_map.get(heading, heading) - if canonical_heading in order or canonical_heading in required: - canonical_lookup[canonical_heading] = body - elif allow_additional: - extra_sections.append((heading, body)) - - rendered_sections: List[Tuple[str, str]] = [] - for heading in order: - body = render_section_body(heading, canonical_lookup.get(heading, ""), settings).strip() - rendered_sections.append((heading, body)) - if allow_additional: - rendered_sections.extend(extra_sections) - - parts = [normalized_preamble] - for heading, body in rendered_sections: - parts.extend(["", f"## {heading}", "", body.strip()]) - document = "\n".join(parts).strip() + "\n" - return normalize_whitespace(document), [ - { - "action": "normalize-agents-structure", - "file": str(agents_path), - "reason": "Normalized AGENTS.md to the canonical template-backed section schema.", - } - ] - - -def format_report(report: Dict[str, Any]) -> str: - total_issues = ( - len(report["schema_violations"]) - + len(report["workflow_drift_issues"]) - + len(report["validation_drift_issues"]) - + len(report["boundary_and_safety_issues"]) - ) - if total_issues == 0 and not report["errors"]: - return "No findings." - - lines = [ - "# AGENTS.md Maintenance Report", - "", - f"- Target: `{report['run_context']['agents_path']}`", - f"- Mode: `{report['run_context']['run_mode']}`", - f"- Config: `{report['schema_contract']['config_path']}`", - ] - for key, title in ( - ("schema_violations", "Schema Violations"), - ("workflow_drift_issues", "Workflow Drift Issues"), - ("validation_drift_issues", "Validation Drift Issues"), - ("boundary_and_safety_issues", "Boundary And Safety Issues"), - ("fixes_applied", "Fixes Applied"), - ("errors", "Errors"), - ): - items = report[key] - if not items: - continue - lines.extend(["", f"## {title}"]) - for item in items: - evidence = item.get("evidence") or item.get("reason") or item.get("message") - lines.append(f"- {item.get('issue_id', item.get('action', 'item'))}: {evidence}") - return "\n".join(lines).strip() + "\n" - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - if not project_root.is_dir(): - raise ValueError(f"Project root does not exist or is not a directory: {project_root}") - - agents_path = Path(args.agents_path).expanduser().resolve() if args.agents_path else project_root / "AGENTS.md" - config = load_config(project_root, args.config) - errors: List[str] = [] - fixes_applied: List[Dict[str, str]] = [] - existing_text = read_text(agents_path) if agents_path.is_file() else "" - - if args.run_mode == "apply": - new_text, applied = apply_fixes(agents_path, existing_text, config) - if not agents_path.parent.exists(): - agents_path.parent.mkdir(parents=True, exist_ok=True) - if normalize_whitespace(existing_text) != new_text: - write_text(agents_path, new_text) - fixes_applied.extend(applied) - existing_text = new_text - - if existing_text: - schema_issues, workflow_issues, validation_issues, boundary_issues = validate_schema( - agents_path, existing_text, config - ) - else: - schema_issues = [ - Issue( - issue_id="missing-agents-file", - category="schema", - severity="high", - file=str(agents_path), - evidence="AGENTS.md does not exist.", - recommended_fix="Create the canonical AGENTS.md file from the bundled template.", - auto_fixable=True, - ) - ] - workflow_issues = [] - validation_issues = [] - boundary_issues = [] - - report = { - "run_context": { - "project_root": str(project_root), - "agents_path": str(agents_path), - "run_mode": args.run_mode, - "generated_at": datetime.now(timezone.utc).isoformat(), - }, - "schema_contract": { - "config_path": config.get("configPath"), - "default_config_path": config.get("defaultConfigPath"), - "required_sections": required_sections(config_settings(config)), - "section_order": canonical_order(config_settings(config)), - "required_subsections": required_subsections(config_settings(config)), - }, - "schema_violations": [issue.to_dict() for issue in schema_issues], - "workflow_drift_issues": [issue.to_dict() for issue in workflow_issues], - "validation_drift_issues": [issue.to_dict() for issue in validation_issues], - "boundary_and_safety_issues": [issue.to_dict() for issue in boundary_issues], - "fixes_applied": fixes_applied, - "post_fix_status": { - "remaining_issue_count": len(schema_issues) + len(workflow_issues) + len(validation_issues) + len(boundary_issues), - "is_clean": not schema_issues and not workflow_issues and not validation_issues and not boundary_issues and not errors, - }, - "errors": errors, - } - markdown = format_report(report) - return report, markdown - - -def main() -> int: - args = parse_args() - try: - report, markdown = run_maintenance(args) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - if args.json_out: - Path(args.json_out).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - if args.md_out: - Path(args.md_out).write_text(markdown, encoding="utf-8") - if args.print_json: - print(json.dumps(report, indent=2)) - if args.print_md: - print(markdown, end="") - - has_issues = ( - bool(report["schema_violations"]) - or bool(report["workflow_drift_issues"]) - or bool(report["validation_drift_issues"]) - or bool(report["boundary_and_safety_issues"]) - or bool(report["errors"]) - ) - if args.fail_on_issues and has_issues: - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/repository-skills/skills/maintain-project-contributing/SKILL.md b/plugins/repository-skills/skills/maintain-project-contributing/SKILL.md index 4c196b211..c845b196a 100644 --- a/plugins/repository-skills/skills/maintain-project-contributing/SKILL.md +++ b/plugins/repository-skills/skills/maintain-project-contributing/SKILL.md @@ -1,91 +1,59 @@ --- name: maintain-project-contributing -description: Maintain canonical CONTRIBUTING.md files with deterministic audit and bounded apply modes. Use for contributor workflow, local setup, development expectations, review handoff, communication guidance, normalization, or targeted fixes. +description: Maintain CONTRIBUTING.md as the contributor-facing member of the canonical four-document repository suite. --- # Maintain Project Contributing -Maintain canonical `CONTRIBUTING.md` files through one deterministic contribution-guide workflow. +## Purpose -This skill is the default baseline path for `CONTRIBUTING.md` maintenance across most repositories. Reach for a narrower plugin only when the target repo has a specialized shape that deserves its own maintainer contract, such as a skills-export or plugin-export repository. +Keep `CONTRIBUTING.md` focused on human contributor setup, workflow, +verification, review, communication, and contribution terms while all four +canonical repository documents move together. -## Inputs +## Commands -- Required: `--project-root ` -- Required: `--run-mode ` -- Optional: `--contributing-path ` -- Optional: `--config ` +The only documentation commands are: -## Workflow +```text +just docs-check +just docs-apply +``` -1. Validate the project root and resolve the target `CONTRIBUTING.md`. -2. Load the canonical contributing-guide schema from `config/contributing-customization.template.yaml`, then merge any explicit override or project-local customization file. -3. In `check-only`, audit the required section schema, required subsection schema, required table of contents, placeholder content, and verification-command formatting. -4. In `apply`, keep edits bounded to the target `CONTRIBUTING.md` while creating a missing file from the bundled template and normalizing the document to the canonical structure. -5. Re-run the same audit to confirm post-fix status. +Both always process README, CONTRIBUTING, AGENTS, and ROADMAP. There are no +per-file recipes and no supported direct script commands. -## Canonical Base Contract +## Managed Contract -The source of truth for the base contributing-guide contract lives in: +- `assets/document.contract.json` fixes the canonical structure and aliases. +- `assets/CONTRIBUTING.template.md` supplies deterministic bootstrap content. +- Repositories cannot customize headings, order, aliases, or fix policy. +- Healthy existing prose and allowed additional sections remain preserved. -- `config/contributing-customization.template.yaml` -- `assets/CONTRIBUTING.template.md` - -The base contract requires: - -- a top-level title and short summary -- a required `## Table of Contents` -- canonical top-level sections for overview, workflow, setup, development expectations, PR expectations, communication, and contribution terms -- required subsection structure for `Overview`, `Contribution Workflow`, `Local Setup`, and `Development Expectations` - -## Writing Expectations +## CONTRIBUTING Ownership -- Keep the whole CONTRIBUTING guide near 300 lines or less by default. Treat 350 lines as a soft ceiling that should trigger consolidation into shorter sections or links to maintainer docs. -- Keep most top-level sections near 45 lines or less and most subsections near 25 lines or less. Prefer one clear rule plus a link to canonical detail over repeated process narration. -- `Overview > Who This Guide Is For` should stay short and plainly explain who this guide serves. -- `Overview > Before You Start` should call out the most important prerequisites before someone begins work. -- `Contribution Workflow` should describe how contributors choose work, make changes, and ask for review without drifting into repo history or product overview prose. -- `Local Setup > Runtime Config` should be explicit about config files, env vars, secrets, and local services. -- `Local Setup > Runtime Behavior` should explain what needs to be running locally and how contributors can tell the project is actually working. -- `Development Expectations > Accessibility Expectations` should keep the contributor contract short, point contributors back to `ACCESSIBILITY.md`, and make accessibility part of normal change quality for relevant work. -- `Development Expectations > Verification` should prefer fenced code blocks with language info strings when commands help contributors validate changes. -- `Communication` should stay practical and concise, focused on how contributors surface uncertainty or larger-scope questions. -- Keep CONTRIBUTING, README, AGENTS, and ACCESSIBILITY responsibilities distinct. Contributor workflow lives here; product overview belongs in `README.md`; durable agent rules belong in `AGENTS.md`; detailed accessibility standards belong in `ACCESSIBILITY.md`. +CONTRIBUTING owns who the guide serves, prerequisites, choosing work, making +changes, asking for review, runtime setup, development expectations, pull +request expectations, communication, and contribution terms. Product overview +belongs in README, durable agent policy in AGENTS, and planning in ROADMAP. -## Codex Subagent Fit +Placeholder checks ignore fenced examples, including generic DCO sign-off +examples. They apply only to prose that matches managed scaffold language. -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy contribution-guide discovery before the main workflow edits or reports: checking setup commands, comparing PR expectations against repo policy, reading workflow docs, or verifying contributor-facing tool requirements. +## Deterministic Workflow -Keep `apply` edits in the main thread because this skill owns one target `CONTRIBUTING.md` file and needs one coherent contributor contract. Ask workers for concise evidence and file references, not replacement guide prose. - -## Output Contract - -- Return Markdown plus JSON with: - - `run_context` - - `schema_contract` - - `schema_violations` - - `command_integrity_issues` - - `content_quality_issues` - - `fixes_applied` - - `post_fix_status` - - `errors` -- If there are no issues and no errors, output exactly `No findings.` +Use `just docs-check` for a no-write audit and `just docs-apply` for the atomic +four-document normalization transaction. Apply must be byte-idempotent. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent commands, setup steps, environment variables, branch rules, review policies, or contribution terms that are not grounded in the repo. -- Never edit files other than the target `CONTRIBUTING.md`. -- Keep `CONTRIBUTING.md` as the canonical contribution-guide filename for this skill. -- Treat this skill as a hard-enforced base template. Downstream plugins may specialize the schema, but the base skill should not do repo-profile inference. +- Never maintain CONTRIBUTING separately from the full document suite. +- Never invent environment variables, services, commands, branch policy, + review policy, or legal terms. +- Never expose project-local schema customization. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `references/section-schema.md` -- `references/output-contract.md` -- `references/fix-policies.md` -- `references/style-rules.md` -- `references/contributing-customization.md` -- `references/contributing-config-schema.md` -- `references/project-contributing-maintenance-automation-prompts.md` +- `assets/document.contract.json` +- `assets/CONTRIBUTING.template.md` diff --git a/plugins/repository-skills/skills/maintain-project-contributing/assets/document.contract.json b/plugins/repository-skills/skills/maintain-project-contributing/assets/document.contract.json new file mode 100644 index 000000000..dbc65d4d6 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-contributing/assets/document.contract.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "document": "contributing", + "targetFile": "CONTRIBUTING.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Overview", "Contribution Workflow", "Local Setup", "Development Expectations", "Pull Request Expectations", "Communication", "License and Contribution Terms"], + "sectionOrder": ["Overview", "Contribution Workflow", "Local Setup", "Development Expectations", "Pull Request Expectations", "Communication", "License and Contribution Terms"], + "requiredSubsections": { + "Overview": ["Who This Guide Is For", "Before You Start"], + "Contribution Workflow": ["Choosing Work", "Making Changes", "Asking For Review"], + "Local Setup": ["Runtime Config", "Runtime Behavior"], + "Development Expectations": ["Naming Conventions", "Accessibility Expectations", "Verification"] + }, + "sectionAliases": { + "Development Expectations": ["Development"], + "License and Contribution Terms": ["Contribution Terms", "License"] + }, + "subsectionAliases": { + "Who This Guide Is For": ["Audience"], + "Before You Start": ["Prerequisites"], + "Choosing Work": ["Picking Work"], + "Making Changes": ["Implementation Workflow"], + "Asking For Review": ["Requesting Review"], + "Naming Conventions": ["Naming"], + "Accessibility Expectations": ["Accessibility", "A11y Expectations"], + "Verification": ["Validation"] + } +} diff --git a/plugins/repository-skills/skills/maintain-project-contributing/config/contributing-customization.template.yaml b/plugins/repository-skills/skills/maintain-project-contributing/config/contributing-customization.template.yaml deleted file mode 100644 index 2724e5c63..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/config/contributing-customization.template.yaml +++ /dev/null @@ -1,97 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - requiredSections: - - Overview - - Contribution Workflow - - Local Setup - - Development Expectations - - Pull Request Expectations - - Communication - - License and Contribution Terms - sectionOrder: - - Overview - - Contribution Workflow - - Local Setup - - Development Expectations - - Pull Request Expectations - - Communication - - License and Contribution Terms - requiredSubsections: - Overview: - - Who This Guide Is For - - Before You Start - Contribution Workflow: - - Choosing Work - - Making Changes - - Asking For Review - Local Setup: - - Runtime Config - - Runtime Behavior - Development Expectations: - - Naming Conventions - - Accessibility Expectations - - Verification - sectionAliases: - Development Expectations: - - Development - License and Contribution Terms: - - Contribution Terms - - License - subsectionAliases: - Overview/Who This Guide Is For: - - Audience - Overview/Before You Start: - - Prerequisites - Contribution Workflow/Choosing Work: - - Picking Work - Contribution Workflow/Making Changes: - - Implementation Workflow - Contribution Workflow/Asking For Review: - - Requesting Review - Development Expectations/Naming Conventions: - - Naming - Development Expectations/Accessibility Expectations: - - Accessibility - - A11y Expectations - Development Expectations/Verification: - - Validation - sectionTemplates: - Pull Request Expectations: | - Summarize what changed, why it changed, and what reviewers should pay attention to first. - Communication: | - Explain how contributors should raise questions, flag risky scope changes, or ask for clarification before work drifts. - License and Contribution Terms: | - Refer contributors to the project license and note any practical contribution terms or sign-off requirements when they exist. - subsectionTemplates: - Overview/Who This Guide Is For: | - Explain who should use this guide and what kinds of contributions it is meant to support. - Overview/Before You Start: | - Call out the most important prerequisites before someone begins work, such as reading nearby docs, checking open work, or understanding repo constraints. - Contribution Workflow/Choosing Work: | - Explain how contributors should choose or confirm work before they begin. - Contribution Workflow/Making Changes: | - Explain the normal path for making changes in this repository, including how to keep work bounded and coherent. - Contribution Workflow/Asking For Review: | - Explain when a change is ready for review and what contributors should double-check first. - Local Setup/Runtime Config: | - Document the concrete local configuration contributors need, including files, secrets, environment variables, or local services. - Local Setup/Runtime Behavior: | - Explain what needs to be running locally and how contributors can tell the project is actually working. - Development Expectations/Naming Conventions: | - Describe the terminology, casing, and naming patterns contributors should match when extending the project. - Development Expectations/Accessibility Expectations: | - Contributors must keep changes aligned with the project's accessibility contract in [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). - - If a change affects UI semantics, input behavior, focus flow, labels, announcements, motion, contrast, zoom behavior, content structure, or assistive-technology compatibility, verify the affected surface against the documented accessibility standards before asking for review. - - If a change introduces a new accessibility limitation, exception, or remediation plan, update `ACCESSIBILITY.md` in the same pass unless maintainers have explicitly agreed on a different tracking path. - Development Expectations/Verification: | - ```bash - # Replace this with the grounded validation commands for the project. - ``` - - Prefer grounded validation commands with fenced code blocks and language info strings when examples help. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/contributing-config-schema.md b/plugins/repository-skills/skills/maintain-project-contributing/references/contributing-config-schema.md deleted file mode 100644 index e5bea0fdf..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/contributing-config-schema.md +++ /dev/null @@ -1,28 +0,0 @@ -# Contributing Config Schema - -The contributing-guide configuration file is YAML and uses this top-level shape: - -- `schemaVersion` -- `isCustomized` -- `profile` -- `settings` - -The `settings` map supports: - -- `preservePreamble` -- `allowAdditionalSections` -- `requiredSections` -- `sectionOrder` -- `requiredSubsections` -- `sectionAliases` -- `subsectionAliases` -- `sectionTemplates` -- `subsectionTemplates` - -Practical rules: - -- `requiredSections` defines the canonical top-level sections, excluding the title, summary, and `Table of Contents`. -- `sectionOrder` defines the enforced top-level ordering. -- `requiredSubsections` defines required `###` headings under specific `##` sections. -- `sectionAliases` and `subsectionAliases` allow bounded migration from older heading names into canonical names. -- `sectionTemplates` and `subsectionTemplates` provide the base scaffolding used during apply mode when content is missing. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/contributing-customization.md b/plugins/repository-skills/skills/maintain-project-contributing/references/contributing-customization.md deleted file mode 100644 index f565e0987..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/contributing-customization.md +++ /dev/null @@ -1,19 +0,0 @@ -# Contributing Customization - -The base contributing-guide contract is defined in `../config/contributing-customization.template.yaml`. - -Downstream specializations may customize: - -- canonical section order -- required sections -- required subsection structure -- section and subsection aliases -- section and subsection scaffold text - -The base skill always requires: - -- a top-level title and short summary -- a `Table of Contents` -- deterministic normalization to the configured schema - -Use a project-local `config/contributing-customization.yaml` or `--config ` override when a downstream plugin needs a narrower or expanded contributing guide structure. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/fix-policies.md b/plugins/repository-skills/skills/maintain-project-contributing/references/fix-policies.md deleted file mode 100644 index 630d4de75..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/fix-policies.md +++ /dev/null @@ -1,8 +0,0 @@ -# Fix Policies - -- Keep edits bounded to the target `CONTRIBUTING.md`. -- Preserve existing section bodies whenever they already satisfy the schema. -- Create a missing `CONTRIBUTING.md` from the bundled template. -- Normalize canonical headings and subsection headings to the configured names and order. -- Add missing sections or subsections from the configured templates instead of inventing repo-fiction. -- Do not synthesize environment variable names, service names, branch naming schemes, review policies, or legal terms. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/output-contract.md b/plugins/repository-skills/skills/maintain-project-contributing/references/output-contract.md deleted file mode 100644 index 5716d410b..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/output-contract.md +++ /dev/null @@ -1,16 +0,0 @@ -# Output Contract - -Return Markdown plus JSON with: - -- `run_context` -- `schema_contract` -- `schema_violations` -- `command_integrity_issues` -- `content_quality_issues` -- `fixes_applied` -- `post_fix_status` -- `errors` - -Clean-run rule: - -- Output exactly `No findings.` only when there are no remaining issues and no errors. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md b/plugins/repository-skills/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md deleted file mode 100644 index bf6abe828..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md +++ /dev/null @@ -1,18 +0,0 @@ -# Automation Prompts - -Use these prompts when validating or applying the skill through automation. - -## Check-only - -- Audit `CONTRIBUTING.md` for the canonical section order. -- Confirm the required table of contents is present and matches the canonical top-level headings. -- Confirm `Overview`, `Contribution Workflow`, `Local Setup`, and `Development Expectations` contain the required subsections. -- Flag placeholder content and malformed or weak verification command blocks. - -## Apply - -- Create a missing `CONTRIBUTING.md` from the bundled template. -- Normalize `CONTRIBUTING.md` to the canonical section order. -- Preserve existing contributor guidance where it already matches the schema. -- Add missing sections or subsections from the configured templates only. -- Keep all edits bounded to `CONTRIBUTING.md`. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/section-schema.md b/plugins/repository-skills/skills/maintain-project-contributing/references/section-schema.md deleted file mode 100644 index 7f95f1409..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/section-schema.md +++ /dev/null @@ -1,43 +0,0 @@ -# Section Schema - -The canonical base `CONTRIBUTING.md` structure is defined by: - -- `../config/contributing-customization.template.yaml` -- `../assets/CONTRIBUTING.template.md` - -Base top-level shape: - -1. top-level title -2. short contributor-facing summary -3. `## Table of Contents` -4. `## Overview` -5. `## Contribution Workflow` -6. `## Local Setup` -7. `## Development Expectations` -8. `## Pull Request Expectations` -9. `## Communication` -10. `## License and Contribution Terms` - -Required subsection shape: - -- `Overview` - - `Who This Guide Is For` - - `Before You Start` -- `Contribution Workflow` - - `Choosing Work` - - `Making Changes` - - `Asking For Review` -- `Local Setup` - - `Runtime Config` - - `Runtime Behavior` -- `Development Expectations` - - `Naming Conventions` - - `Accessibility Expectations` - - `Verification` - -Schema expectations: - -- Use `##` headings for top-level sections. -- Use `###` headings for required subsections. -- Always include a top-level `Table of Contents`. -- Preserve additional repo-specific sections when present, but keep canonical sections in canonical order. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/references/style-rules.md b/plugins/repository-skills/skills/maintain-project-contributing/references/style-rules.md deleted file mode 100644 index e17f62c61..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/references/style-rules.md +++ /dev/null @@ -1,11 +0,0 @@ -# Style Rules - -- Keep contributor guidance direct, concrete, and repo-grounded. -- Favor short explanatory paragraphs plus small bullet lists when they improve scanability. -- Keep the whole CONTRIBUTING guide near 300 lines by default, with 350 lines as a soft ceiling for consolidation. -- Keep most top-level sections near 45 lines or less and most subsections near 25 lines or less. -- Treat `CONTRIBUTING.md` as a contributor workflow guide, not as a product overview or contributor roster. -- Keep `Local Setup` operational and practical, with explicit `Runtime Config` and `Runtime Behavior` subsections. -- Keep `Development Expectations` focused on naming, accessibility expectations, validation, and everyday contribution hygiene. -- Prefer fenced code blocks with language info strings when showing verification commands. -- Keep contributor workflow here and link outward to README, AGENTS, ACCESSIBILITY, or maintainer docs instead of duplicating their content. diff --git a/plugins/repository-skills/skills/maintain-project-contributing/scripts/maintain_project_contributing.py b/plugins/repository-skills/skills/maintain-project-contributing/scripts/maintain_project_contributing.py deleted file mode 100644 index 183b83372..000000000 --- a/plugins/repository-skills/skills/maintain-project-contributing/scripts/maintain_project_contributing.py +++ /dev/null @@ -1,825 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and apply bounded CONTRIBUTING.md maintenance from a hard-enforced schema.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -SHELL_FENCE_RE = re.compile(r"```([^\n`]*)\n(.*?)```", re.DOTALL) -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] - - -@dataclass -class Issue: - issue_id: str - category: str - severity: str - file: str - evidence: str - recommended_fix: str - auto_fixable: bool - fixed: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_id": self.issue_id, - "category": self.category, - "severity": self.severity, - "file": self.file, - "evidence": self.evidence, - "recommended_fix": self.recommended_fix, - "auto_fixable": self.auto_fixable, - "fixed": self.fixed, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded CONTRIBUTING.md maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--contributing-path", help="Optional CONTRIBUTING path override") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional CONTRIBUTING config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when unresolved issues remain") - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def slugify_heading(heading: str) -> str: - slug = heading.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"\s+", "-", slug) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - sections.append((match.group(1).strip(), text[start:end].strip("\n"))) - return preamble, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - subsections.append((match.group(1).strip(), body[start:end].strip("\n"))) - return preamble, subsections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def parse_title_and_summary(preamble: str) -> Tuple[Optional[str], Optional[str], List[str]]: - lines = [line.rstrip() for line in preamble.splitlines()] - title: Optional[str] = None - summary: Optional[str] = None - extras: List[str] = [] - title_index: Optional[int] = None - summary_index: Optional[int] = None - - for idx, line in enumerate(lines): - if line.startswith("# "): - title = line[2:].strip() - title_index = idx - break - - if title_index is None: - return None, None, lines - - for idx in range(title_index + 1, len(lines)): - if lines[idx].strip(): - summary = lines[idx].strip() - summary_index = idx - break - - for idx, line in enumerate(lines): - if idx in {title_index, summary_index}: - continue - extras.append(line) - - return title, summary, extras - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, project_name: str, preserve_preamble: bool) -> str: - title, summary, extras = parse_title_and_summary(preamble) - normalized_title = title or f"Contributing to {project_name}" - normalized_summary = ( - summary - or "Use this guide when preparing changes so the project stays understandable, runnable, and reviewable for the next contributor." - ) - lines = [f"# {normalized_title}", "", normalized_summary] - if preserve_preamble: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - lines.extend(["", *extra_lines]) - return "\n".join(lines).strip() - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "contributing-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "contributing-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - values = settings.get("requiredSections", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def canonical_order(settings: Dict[str, Any]) -> List[str]: - values = settings.get("sectionOrder", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def required_subsections(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("requiredSubsections", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def subsection_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("subsectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("subsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def build_toc(headings: Sequence[str]) -> str: - return "\n".join(f"- [{heading}](#{slugify_heading(heading)})" for heading in headings) - - -def toc_entries(body: str) -> List[str]: - entries: List[str] = [] - for line in body.splitlines(): - match = re.match(r"^\s*-\s+\[(.+?)\]\(#(.+?)\)\s*$", line.strip()) - if match: - entries.append(match.group(1).strip()) - return entries - - -def section_alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - reverse: Dict[str, str] = {} - for canonical, aliases in section_aliases(settings).items(): - for alias in aliases: - reverse[alias] = canonical - return reverse - - -def subsection_alias_lookup(settings: Dict[str, Any]) -> Dict[Tuple[str, str], str]: - reverse: Dict[Tuple[str, str], str] = {} - for canonical_path, aliases in subsection_aliases(settings).items(): - if "/" not in canonical_path: - continue - parent, canonical_name = canonical_path.split("/", 1) - for alias in aliases: - reverse[(parent, alias)] = canonical_name - return reverse - - -def render_template_bootstrap(project_root: Path) -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "CONTRIBUTING.template.md" - template = read_text(template_path) - return normalize_whitespace(template.replace("{{PROJECT_NAME}}", project_root.name)) - - -def render_section_body(heading: str, existing_body: str, settings: Dict[str, Any]) -> str: - required_children = required_subsections(settings).get(heading, []) - section_template_map = section_templates(settings) - subsection_template_map = subsection_templates(settings) - subsection_alias_map = subsection_alias_lookup(settings) - - if not required_children: - return existing_body.strip() or section_template_map.get(heading, "") - - preamble, subsections = split_subsections(existing_body) - subsection_lookup: Dict[str, str] = {} - extra_subsections: List[Tuple[str, str]] = [] - for name, body in subsections: - canonical_name = subsection_alias_map.get((heading, name), name) - if canonical_name in required_children and canonical_name not in subsection_lookup: - subsection_lookup[canonical_name] = body - else: - extra_subsections.append((name, body)) - - lines: List[str] = [] - if preamble.strip(): - lines.extend([preamble.strip(), ""]) - - for idx, child in enumerate(required_children): - child_body = subsection_lookup.get(child, "").strip() or subsection_template_map.get(f"{heading}/{child}", "") - lines.extend([f"### {child}", "", child_body.strip()]) - if idx < len(required_children) - 1 or extra_subsections: - lines.append("") - - for idx, (name, body) in enumerate(extra_subsections): - lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extra_subsections) - 1: - lines.append("") - - rendered = "\n".join(lines).strip() - return rendered or section_template_map.get(heading, "") - - -def validate_schema( - contributing_path: Path, - contributing_text: str, - config: Dict[str, Any], -) -> Tuple[List[Issue], List[Issue], List[Issue], List[Tuple[str, str]]]: - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - subsection_map = required_subsections(settings) - section_alias_map = section_alias_lookup(settings) - - schema_issues: List[Issue] = [] - command_issues: List[Issue] = [] - content_issues: List[Issue] = [] - - preamble, sections = split_sections(contributing_text) - lookup = section_map(sections) - title, summary, _extras = parse_title_and_summary(preamble) - - if not title: - schema_issues.append( - Issue( - issue_id="missing-title", - category="schema", - severity="high", - file=str(contributing_path), - evidence="CONTRIBUTING.md is missing a top-level '# Contributing to ' title.", - recommended_fix="Add a clear top-level CONTRIBUTING title.", - auto_fixable=True, - ) - ) - if not summary: - schema_issues.append( - Issue( - issue_id="missing-summary", - category="schema", - severity="medium", - file=str(contributing_path), - evidence="CONTRIBUTING.md is missing a short contributor-facing summary beneath the title.", - recommended_fix="Add a short summary sentence beneath the top-level title.", - auto_fixable=True, - ) - ) - - if "Table of Contents" not in lookup: - schema_issues.append( - Issue( - issue_id="missing-table-of-contents", - category="schema", - severity="medium", - file=str(contributing_path), - evidence="CONTRIBUTING.md is missing the required '## Table of Contents' section.", - recommended_fix="Add a table of contents that mirrors the canonical top-level headings.", - auto_fixable=True, - ) - ) - - observed_headings = [heading for heading, _body in sections] - canonical_positions = {heading: idx for idx, heading in enumerate(observed_headings)} - for heading in required: - if heading in lookup: - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in lookup), - None, - ) - if alias_found: - schema_issues.append( - Issue( - issue_id=f"non-canonical-heading-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(contributing_path), - evidence=f"CONTRIBUTING.md uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - recommended_fix=f"Rename '## {alias_found}' to '## {heading}'.", - auto_fixable=True, - ) - ) - else: - schema_issues.append( - Issue( - issue_id=f"missing-section-{slugify_heading(heading)}", - category="schema", - severity="high", - file=str(contributing_path), - evidence=f"CONTRIBUTING.md is missing required section '## {heading}'.", - recommended_fix=f"Add the required '## {heading}' section.", - auto_fixable=True, - ) - ) - - order_positions: List[int] = [] - for heading in order: - if heading in canonical_positions: - order_positions.append(canonical_positions[heading]) - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in canonical_positions), - None, - ) - if alias_found: - order_positions.append(canonical_positions[alias_found]) - if order_positions and order_positions != sorted(order_positions): - schema_issues.append( - Issue( - issue_id="canonical-section-order", - category="schema", - severity="medium", - file=str(contributing_path), - evidence="Canonical CONTRIBUTING sections are not in the configured order.", - recommended_fix="Normalize the top-level sections into canonical order.", - auto_fixable=True, - ) - ) - - subsection_alias_map = subsection_alias_lookup(settings) - for parent, children in subsection_map.items(): - body = lookup.get(parent, "") - if not body: - alias_parent = next( - (alias for alias, canonical in section_alias_map.items() if canonical == parent and alias in lookup), - None, - ) - body = lookup.get(alias_parent, "") if alias_parent else "" - if not body: - continue - _preamble, subsections = split_subsections(body) - found = { - subsection_alias_map.get((parent, name), name): subsection_body - for name, subsection_body in subsections - } - for child in children: - if child not in found: - schema_issues.append( - Issue( - issue_id=f"missing-subsection-{slugify_heading(parent)}-{slugify_heading(child)}", - category="schema", - severity="high", - file=str(contributing_path), - evidence=f"Section '## {parent}' is missing required subsection '### {child}'.", - recommended_fix=f"Add the required subsection '### {child}' under '## {parent}'.", - auto_fixable=True, - ) - ) - - if "Table of Contents" in lookup: - expected_toc = [heading for heading in order if heading in required or heading in lookup] - actual_toc = toc_entries(lookup["Table of Contents"]) - if actual_toc != expected_toc: - schema_issues.append( - Issue( - issue_id="stale-table-of-contents", - category="schema", - severity="low", - file=str(contributing_path), - evidence="Table of contents entries do not match the canonical top-level section headings in order.", - recommended_fix="Regenerate the table of contents from the canonical section list.", - auto_fixable=True, - ) - ) - - for heading in required: - required_body = lookup.get(heading) - if not required_body: - continue - if not required_body.strip(): - schema_issues.append( - Issue( - issue_id=f"empty-section-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(contributing_path), - evidence=f"Section '## {heading}' is present but empty.", - recommended_fix=f"Add grounded content to '## {heading}'.", - auto_fixable=True, - ) - ) - if any(pattern.search(required_body) for pattern in PLACEHOLDER_PATTERNS): - content_issues.append( - Issue( - issue_id=f"placeholder-content-{slugify_heading(heading)}", - category="content-quality", - severity="medium", - file=str(contributing_path), - evidence=f"Section '## {heading}' contains placeholder-style content.", - recommended_fix="Replace placeholder content with repo-grounded contributor guidance.", - auto_fixable=False, - ) - ) - - verification_body = lookup.get("Development Expectations", "") - _preamble, dev_subsections = split_subsections(verification_body) - verification_lookup = {name: body for name, body in dev_subsections} - verification_text = verification_lookup.get("Verification", "").strip() - if verification_text: - shell_blocks = list(SHELL_FENCE_RE.finditer(verification_text)) - if shell_blocks: - for match in shell_blocks: - info = match.group(1).strip() - block = match.group(2).strip() - if not info: - command_issues.append( - Issue( - issue_id=f"missing-code-fence-info-string-{match.start()}", - category="command-integrity", - severity="low", - file=str(contributing_path), - evidence="Verification uses a fenced code block without a language info string.", - recommended_fix="Use fenced code blocks with an info string such as ```bash for verification commands.", - auto_fixable=False, - ) - ) - if not block: - command_issues.append( - Issue( - issue_id=f"empty-shell-block-{match.start()}", - category="command-integrity", - severity="medium", - file=str(contributing_path), - evidence="Verification contains an empty fenced code block.", - recommended_fix="Remove the empty block or replace it with grounded validation commands.", - auto_fixable=True, - ) - ) - if any(pattern.search(block) for pattern in PLACEHOLDER_PATTERNS): - command_issues.append( - Issue( - issue_id=f"placeholder-command-block-{match.start()}", - category="command-integrity", - severity="high", - file=str(contributing_path), - evidence="Verification contains a placeholder command block.", - recommended_fix="Replace the placeholder command block with grounded validation commands or prose.", - auto_fixable=False, - ) - ) - elif len(verification_text.split()) < 6: - content_issues.append( - Issue( - issue_id="thin-verification-guidance", - category="content-quality", - severity="medium", - file=str(contributing_path), - evidence="Development Expectations > Verification is too thin to help contributors validate changes.", - recommended_fix="Add grounded validation guidance, preferably with fenced code blocks and language info strings.", - auto_fixable=False, - ) - ) - - return schema_issues, command_issues, content_issues, sections - - -def apply_fixes( - project_root: Path, - contributing_path: Path, - contributing_text: str, - config: Dict[str, Any], -) -> Tuple[str, List[Dict[str, str]]]: - if not contributing_text.strip(): - bootstrap = render_template_bootstrap(project_root) - write_text(contributing_path, bootstrap) - return ( - bootstrap, - [ - { - "action": "create-contributing-from-template", - "file": str(contributing_path), - "reason": "Created a missing CONTRIBUTING.md from the bundled canonical template.", - } - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - preserve_preamble = bool(settings.get("preservePreamble", True)) - allow_additional = bool(settings.get("allowAdditionalSections", True)) - section_alias_map = section_alias_lookup(settings) - - preamble, sections = split_sections(contributing_text) - normalized_preamble = normalize_preamble(preamble, project_root.name, preserve_preamble) - - canonical_lookup: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - for heading, body in sections: - if heading == "Table of Contents": - continue - canonical_heading = section_alias_map.get(heading, heading) - if canonical_heading in order or canonical_heading in required: - canonical_lookup[canonical_heading] = body - elif allow_additional: - extra_sections.append((heading, body)) - - canonical_sections: List[Tuple[str, str]] = [] - for heading in order: - body = render_section_body(heading, canonical_lookup.get(heading, ""), settings).strip() - canonical_sections.append((heading, body)) - - headings_for_toc = [heading for heading, _body in canonical_sections] - if allow_additional: - headings_for_toc.extend(heading for heading, _body in extra_sections) - rendered_sections = [("Table of Contents", build_toc(headings_for_toc).strip()), *canonical_sections] - if allow_additional: - rendered_sections.extend(extra_sections) - - parts = [normalized_preamble] - for heading, body in rendered_sections: - parts.extend(["", f"## {heading}", "", body.strip()]) - document = "\n".join(parts).strip() + "\n" - return normalize_whitespace(document), [ - { - "action": "normalize-contributing-structure", - "file": str(contributing_path), - "reason": "Normalized CONTRIBUTING.md to the canonical template-backed section schema.", - } - ] - - -def format_report(report: Dict[str, Any]) -> str: - total_issues = ( - len(report["schema_violations"]) - + len(report["command_integrity_issues"]) - + len(report["content_quality_issues"]) - ) - if total_issues == 0 and not report["errors"]: - return "No findings." - - lines = [ - "# CONTRIBUTING.md Maintenance Report", - "", - f"- Target: `{report['run_context']['contributing_path']}`", - f"- Mode: `{report['run_context']['run_mode']}`", - f"- Config: `{report['schema_contract']['config_path']}`", - ] - - for key, title in ( - ("schema_violations", "Schema Violations"), - ("command_integrity_issues", "Command Integrity Issues"), - ("content_quality_issues", "Content Quality Issues"), - ("fixes_applied", "Fixes Applied"), - ("errors", "Errors"), - ): - items = report[key] - if not items: - continue - lines.extend(["", f"## {title}"]) - for item in items: - evidence = item.get("evidence") or item.get("reason") or item.get("message") - lines.append(f"- {item.get('issue_id', item.get('action', 'item'))}: {evidence}") - - return "\n".join(lines).strip() + "\n" - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - if not project_root.is_dir(): - raise ValueError(f"Project root does not exist or is not a directory: {project_root}") - - contributing_path = ( - Path(args.contributing_path).expanduser().resolve() - if args.contributing_path - else project_root / "CONTRIBUTING.md" - ) - config = load_config(project_root, args.config) - - errors: List[str] = [] - fixes_applied: List[Dict[str, str]] = [] - existing_text = read_text(contributing_path) if contributing_path.is_file() else "" - - if args.run_mode == "apply": - new_text, applied = apply_fixes(project_root, contributing_path, existing_text, config) - if not contributing_path.parent.exists(): - contributing_path.parent.mkdir(parents=True, exist_ok=True) - if normalize_whitespace(existing_text) != new_text: - write_text(contributing_path, new_text) - fixes_applied.extend(applied) - existing_text = new_text - - if existing_text: - schema_issues, command_issues, content_issues, _sections = validate_schema( - contributing_path, existing_text, config - ) - else: - schema_issues = [ - Issue( - issue_id="missing-contributing-file", - category="schema", - severity="high", - file=str(contributing_path), - evidence="CONTRIBUTING.md does not exist.", - recommended_fix="Create the canonical CONTRIBUTING.md file from the bundled template.", - auto_fixable=True, - ) - ] - command_issues = [] - content_issues = [] - - report = { - "run_context": { - "project_root": str(project_root), - "contributing_path": str(contributing_path), - "run_mode": args.run_mode, - "generated_at": datetime.now(timezone.utc).isoformat(), - }, - "schema_contract": { - "config_path": config.get("configPath"), - "default_config_path": config.get("defaultConfigPath"), - "required_table_of_contents": True, - "required_sections": required_sections(config_settings(config)), - "section_order": canonical_order(config_settings(config)), - "required_subsections": required_subsections(config_settings(config)), - }, - "schema_violations": [issue.to_dict() for issue in schema_issues], - "command_integrity_issues": [issue.to_dict() for issue in command_issues], - "content_quality_issues": [issue.to_dict() for issue in content_issues], - "fixes_applied": fixes_applied, - "post_fix_status": { - "remaining_issue_count": len(schema_issues) + len(command_issues) + len(content_issues), - "is_clean": not schema_issues and not command_issues and not content_issues and not errors, - }, - "errors": errors, - } - markdown = format_report(report) - return report, markdown - - -def main() -> int: - args = parse_args() - try: - report, markdown = run_maintenance(args) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - if args.json_out: - Path(args.json_out).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - if args.md_out: - Path(args.md_out).write_text(markdown, encoding="utf-8") - if args.print_json: - print(json.dumps(report, indent=2)) - if args.print_md: - print(markdown, end="") - - has_issues = ( - bool(report["schema_violations"]) - or bool(report["command_integrity_issues"]) - or bool(report["content_quality_issues"]) - or bool(report["errors"]) - ) - if args.fail_on_issues and has_issues: - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/repository-skills/skills/maintain-project-readme/SKILL.md b/plugins/repository-skills/skills/maintain-project-readme/SKILL.md index ffecfd8fb..82167806c 100644 --- a/plugins/repository-skills/skills/maintain-project-readme/SKILL.md +++ b/plugins/repository-skills/skills/maintain-project-readme/SKILL.md @@ -1,96 +1,62 @@ --- name: maintain-project-readme -description: Maintain README.md files against a canonical base schema with deterministic check-only and bounded apply modes. Use when a repository needs baseline structure, normalization, or targeted fixes without weakening downstream customization. +description: Maintain README.md as the product-facing member of the canonical four-document repository suite. --- # Maintain Project README -Maintain `README.md` files through one deterministic base-template workflow. +## Purpose -This skill is the primary layer for README maintenance. It defines the canonical shared README contract that downstream language-, framework-, stack-, or repository-specific customization can adapt through explicit extension, instead of ad hoc structure drift. +Keep `README.md` product-focused while the repository's README, CONTRIBUTING, +AGENTS, and ROADMAP documents are checked or applied as one deterministic unit. -## Inputs +## Commands -- Required: `--project-root ` -- Required: `--run-mode ` -- Optional: `--readme-path ` -- Optional: `--config ` +There are exactly two documentation commands: -## Workflow +```text +just docs-check +just docs-apply +``` -1. Validate the project root and resolve the target `README.md`. -2. Load the canonical README schema from the built-in template config, then merge any explicit customization override. -3. In `check-only`, audit title and summary requirements, top-level section names and order, required subsection names, the required table of contents, and placeholder-style content. -4. In `apply`, keep edits bounded to the target `README.md` while normalizing the README into the configured canonical structure. -5. Preserve preamble material such as badges, callouts, screenshots, and extra intro prose before the first H2 while normalizing the structural contract around it. -6. When bootstrapping a missing `README.md`, ask the user for text for `Overview > Status`, `Overview > What This Project Is`, and `Overview > Motivation` before writing those subsections. -7. Use the bundled README template when bootstrapping a missing `README.md` or when a downstream workflow needs a canonical starter document; if the user has not provided text for any Overview subsection, leave that subsection body exactly `TBD`. -8. Re-run the same audit to confirm post-fix status. -9. For skills, plugin, or hybrid repositories, keep the same hard-enforced schema while grounding install, discovery, packaging, and maintainer wording in the real repo surface instead of inventing ordinary-app sections that are not actually shipped. +Both commands always process all four canonical documents in this order: +README, CONTRIBUTING, AGENTS, ROADMAP. Never expose or recommend a per-file +documentation command or direct `.fsx` invocation. -## Writing Expectations +## Managed Contract -- `README.md` is product-focused: write it for end users, evaluators, integrators, and their agents who need to understand what the project is, whether it fits, how to try it, and where the shipped surface lives. -- Contributor, maintainer, release, validation, branch, review, and local development procedures belong in `CONTRIBUTING.md` or a linked maintainer document. In `README.md`, keep only the shortest useful pointer to that contributor path. -- Keep the whole README near 250 lines or less by default. Treat 300 lines as a soft ceiling that should trigger consolidation unless the user explicitly wants a long-form README. -- Keep most generated or agent-edited top-level sections near 40 lines or less. Split or hoist content only when it clarifies ownership; otherwise trim repetition and link to the canonical owner. -- The user-authored `Overview` subsections may be longer when the user supplies that text. Do not shorten `Overview > Status`, `Overview > What This Project Is`, or `Overview > Motivation` unless the user explicitly asks. -- `Overview > Status`, `Overview > What This Project Is`, and `Overview > Motivation` must be written by the user in the user's own words, never by the agent. -- If one of those Overview subsections already contains text, leave that text intact and untouched unless the user explicitly provides replacement text for that exact subsection. -- If one of those Overview subsections is empty or missing, set the subsection body to exactly `TBD`; for new README files, ask the user for text to place there before falling back to `TBD`. -- `Quick Start` should stay human-focused, short, concise, and end-user friendly, or explicitly say the project is still too early for a real quick start and direct curious readers to `Development`. -- `Usage` should stay human-focused, concise, and informative. Prefer fenced code blocks with info strings when examples help. -- `Development` should stay short and reader-oriented. Prefer a direct link to `CONTRIBUTING.md` for setup, workflow, validation, review, and maintainer commands instead of duplicating those procedures in the README. -- `Repo Structure` should be a small directory tree or outline diagram, not a prose section. -- Keep README, CONTRIBUTING, ROADMAP, and AGENTS responsibilities distinct. Product summary and end-user fit belong here; contribution workflow belongs in `CONTRIBUTING.md`; backlog and small-ticket planning belong in `ROADMAP.md` by default; agent-facing maintainer rules belong in `AGENTS.md`. +- `assets/document.contract.json` is the fixed structural contract. +- `assets/README.template.md` is the bootstrap and missing-content asset. +- The contract is versioned with the skill and is not project-customizable. +- Existing prose and allowed additional sections are preserved; canonical + headings, aliases, ordering, and fix policy cannot be overridden. -## Codex Subagent Fit +## README Ownership -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy README discovery before the main workflow edits or reports: checking source docs, inventorying commands, inspecting sibling package metadata, or comparing README claims against one upstream source per worker. +README owns product identity, current status, quick start, usage, repository +shape, release-note discovery, and license discovery. Contributor workflow, +agent policy, release procedure, and roadmap tickets belong to their canonical +owners and should be linked rather than duplicated. -Keep `apply` edits in the main thread because this skill has one target file and a hard-enforced schema. Ask subagents to return concise evidence and file references, not replacement README prose. +Preserve existing user-authored Overview prose. Missing Overview content uses +the exact managed `TBD` scaffold and is reported without inventing claims. -## Canonical Base Contract +## Deterministic Workflow -The authoritative default shared README structure lives in: - -- `config/readme-customization.template.yaml` -- `assets/README.template.md` - -Treat those two files as the source of truth for the canonical base schema and the canonical bootstrap document. Downstream plugins may extend that structure through preamble and appendices, but this base skill treats the required table of contents plus the configured section block as hard-enforced. - -## Output Contract - -- Return Markdown plus JSON with: - - `run_context` - - `customization_state` - - `schema_contract` - - `schema_violations` - - `content_quality_issues` - - `fixes_applied` - - `post_fix_status` - - `errors` -- If there are no issues and no errors, output exactly `No findings.` +1. Run `just docs-check` for a no-write four-document audit. +2. Run `just docs-apply` when structural normalization is requested. +3. The coordinator plans all four outputs before writing any file. +4. Apply uses atomic replacements and rolls back completed writes on failure. +5. A second apply must be byte-identical and produce no change. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent commands, setup steps, or product claims that are not grounded in the repo. -- Never edit files other than the target `README.md`. -- Never move contributor or maintainer procedures into `README.md` when `CONTRIBUTING.md` or a maintainer doc is the correct owner. -- Keep the README schema hard-enforced against the configured contract instead of inferring structure from repo profile heuristics. -- Do not relax the configured schema just because the repository is a plugin, skills, or hybrid repo. Use explicit extension via preamble or appendices when the repo genuinely needs an additional structure or section. +- Never edit or check README in isolation from the full document suite. +- Never invent product claims, commands, guarantees, or support promises. +- Never add project-local schema or fix-policy customization. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `config/readme-customization.template.yaml` +- `assets/document.contract.json` - `assets/README.template.md` -- `references/section-schema.md` -- `references/readme-customization.md` -- `references/readme-config-schema.md` -- `references/output-contract.md` -- `references/fix-policies.md` -- `references/style-rules.md` -- `references/verification-checklist.md` -- `references/project-readme-maintenance-automation-prompts.md` diff --git a/plugins/repository-skills/skills/maintain-project-readme/assets/document.contract.json b/plugins/repository-skills/skills/maintain-project-readme/assets/document.contract.json new file mode 100644 index 000000000..f7579eb92 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-readme/assets/document.contract.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "document": "readme", + "targetFile": "README.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Overview", "Quick Start", "Usage", "Development", "Repo Structure", "Release Notes", "License"], + "sectionOrder": ["Overview", "Quick Start", "Usage", "Development", "Repo Structure", "Release Notes", "License"], + "requiredSubsections": { + "Overview": ["Status", "What This Project Is", "Motivation"] + }, + "sectionAliases": { + "Quick Start": ["Getting Started", "Installation"], + "Usage": ["Examples"] + }, + "subsectionAliases": {} +} diff --git a/plugins/repository-skills/skills/maintain-project-readme/config/readme-customization.template.yaml b/plugins/repository-skills/skills/maintain-project-readme/config/readme-customization.template.yaml deleted file mode 100644 index c82c959e6..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/config/readme-customization.template.yaml +++ /dev/null @@ -1,71 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - requiredSections: - - Overview - - Quick Start - - Usage - - Development - - Repo Structure - - Release Notes - - License - sectionOrder: - - Overview - - Quick Start - - Usage - - Development - - Repo Structure - - Release Notes - - License - requiredSubsections: - Overview: - - Status - - What This Project Is - - Motivation - sectionAliases: - Quick Start: - - Getting Started - - Installation - Usage: - - Examples - sectionTemplates: - Overview: | - ### Status - - TBD - - ### What This Project Is - - TBD - - ### Motivation - - TBD - Quick Start: | - Give a human-friendly quick start for trying or using the project. If the project is still too early for a real quick start, say that plainly and direct curious readers to the Development section for contributor documentation. - Usage: | - Keep this section concise and human-focused. Prefer fenced code blocks with language info strings when examples help explain normal usage. - Development: | - For setup, local workflow, validation, and contribution expectations, see [CONTRIBUTING.md](./CONTRIBUTING.md). - Repo Structure: | - ```text - . - ├── path/ - └── path/ - ``` - - Replace this outline with a short directory tree for the important repository surfaces. - Release Notes: | - Summarize how releases, version notes, or notable shipped changes are tracked for this project. - License: | - See [LICENSE](./LICENSE). - subsectionTemplates: - Overview/Status: | - TBD - Overview/What This Project Is: | - TBD - Overview/Motivation: | - TBD diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/fix-policies.md b/plugins/repository-skills/skills/maintain-project-readme/references/fix-policies.md deleted file mode 100644 index ffcc94ba1..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/fix-policies.md +++ /dev/null @@ -1,26 +0,0 @@ -# Fix Policies - -## Allowed Automatic Fixes - -- add missing canonical top-level sections from the configured schema -- add missing required subsections inside existing canonical sections -- normalize top-level section ordering into the configured canonical order -- migrate configured alias headings into canonical heading names -- add or refresh the required H2-only table of contents -- replace a missing title/summary block with grounded repo-neutral wording -- fill empty required sections or subsections with readable neutral scaffolding - -## Disallowed Automatic Fixes - -- invent quick-start, setup, workflow, validation, deploy, or release commands -- invent audience claims, performance claims, guarantees, or support promises -- rewrite healthy prose just to make it sound more generated -- edit files other than the target `README.md` - -## Review Bias - -- prefer hard structural normalization over soft structural hints -- prefer preserving good existing prose within a section while normalizing the surrounding schema -- prefer alias migration over deleting useful content -- preserve preamble material before the first H2 when it remains coherent -- report placeholder-style content instead of pretending the repo provides facts that are not visible diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/output-contract.md b/plugins/repository-skills/skills/maintain-project-readme/references/output-contract.md deleted file mode 100644 index fb3c1ed9e..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/output-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# Output Contract - -## Markdown Sections - -1. Run Context -2. Customization State -3. Schema Contract -4. Schema Violations -5. Content Quality Issues -6. Fixes Applied -7. Post-Fix Status -8. Errors - -## JSON Top-Level Keys - -- `run_context` -- `customization_state` -- `schema_contract` -- `schema_violations` -- `content_quality_issues` -- `fixes_applied` -- `post_fix_status` -- `errors` - -## Exit Policy - -- Print exactly `No findings.` when there are no issues and no errors. -- Exit `0` for successful runs unless `--fail-on-issues` is set and unresolved issues remain. -- Exit `1` for fatal runtime errors or incompatible repo-type routing failures. diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md b/plugins/repository-skills/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md deleted file mode 100644 index f5acc2dbf..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md +++ /dev/null @@ -1,86 +0,0 @@ -# Project README Maintenance Automation Prompt Templates - -## Suitability - -- Codex App: `Strong` -- Codex CLI: `Strong` - -## Codex App Automation Prompt Template - -```markdown -Use $maintain-project-readme. - -Scope: -- Project root: -- README path override: -- README config override: - -Execution policy: -- Load the canonical README config first. -- Run `check-only` first and summarize customization state, schema contract, schema violations, and content-quality issues. -- If is true, run bounded README fixes and re-check. -- Preserve existing preamble content such as badges, callouts, screenshots, and intro prose before the first H2. -- Treat the configured README structure as hard-enforced. -- Treat `Status`, `What This Project Is`, and `Motivation` as user-authored Overview subsections that should never be written or replaced with invented claims. -- For an existing README, leave text in those Overview subsections intact; for empty or missing Overview subsection bodies, use exactly `TBD`. -- For a new README, ask the user for text for those Overview subsections before falling back to `TBD`. -- Keep `Quick Start` and `Usage` short, succinct, human-focused and end-user friendly; prefer fenced code blocks with info strings in `Usage` when examples help. -- Never invent commands, setup steps, or unsupported product claims. -- Never edit files other than the target `README.md`. -- Confirm with the user before a commit or push. - -Output contract: -- Return Markdown summary and JSON-ready fields for: - run_context, customization_state, schema_contract, schema_violations, - content_quality_issues, fixes_applied, post_fix_status, errors. -- Write reports to: - - - - - -No-findings handling: -- If there are no issues and no errors, output exactly `No findings.`. -``` - -## Codex CLI Automation Prompt Template - -### Variant A: Audit-only - -```markdown -Use $maintain-project-readme. - -Audit the project README under . -If needed, use README override path . -If needed, use README config override . -Load the canonical README config first, then run `check-only`. -Report customization state, schema contract, schema violations, and content-quality issues. -Write outputs to and . -If there are no issues and no errors, output exactly `No findings.`. -``` - -### Variant B: Audit + bounded fixes - -```markdown -Use $maintain-project-readme. - -Audit the project README under . -If needed, use README override path . -If needed, use README config override . -Load the canonical README config first, then run `check-only`, then bounded README fixes, then re-check. -Preserve badges, callouts, screenshots, and extra intro prose before the first H2. -Treat the configured structure as hard-enforced. -Treat `Status`, `What This Project Is`, and `Motivation` as user-authored Overview subsections that should never be written or replaced with invented claims. -For existing READMEs, leave text in those Overview subsections intact; for empty or missing Overview subsection bodies, use exactly `TBD`. -For new READMEs, ask the user for text for those Overview subsections before falling back to `TBD`. -Keep `Quick Start` and `Usage` human-focused and end-user friendly; prefer fenced code blocks with info strings in `Usage` when examples help. -Do not invent commands or edit files other than the target `README.md`. -Write outputs to and . -``` - -## Placeholders - -- `` -- `` -- `` -- `` -- `` -- `` diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/readme-config-schema.md b/plugins/repository-skills/skills/maintain-project-readme/references/readme-config-schema.md deleted file mode 100644 index 4a9226c3d..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/readme-config-schema.md +++ /dev/null @@ -1,32 +0,0 @@ -# README Configuration Schema - -Persistent README customization for `maintain-project-readme` is defined in: - -- Template defaults: `config/readme-customization.template.yaml` -- User or downstream overrides: explicit `--config ` or project-local `config/readme-customization.yaml` - -## Top-level fields - -- `schemaVersion`: integer schema version (`1`) -- `isCustomized`: `true` when the loaded config is an override rather than only the built-in template -- `profile`: short profile label such as `base`, `python-library`, or `typescript-service` -- `settings`: README schema behavior controls - -## `settings` fields - -- `preservePreamble`: boolean -- `allowAdditionalSections`: boolean -- `requiredSections`: ordered list of exact canonical H2 headings -- `sectionOrder`: ordered list of exact canonical H2 headings used for normalization -- `requiredSubsections`: map of H2 heading to ordered list of exact canonical H3 headings -- `sectionAliases`: map of canonical H2 heading to alias heading list used for migration -- `sectionTemplates`: map of H2 heading to neutral scaffolding text -- `subsectionTemplates`: map of `Parent/Child` to neutral scaffolding text - -## Runtime Behavior - -- The merged config is authoritative for both `check-only` and `apply`. -- `requiredSections` and `sectionOrder` should describe the same canonical block. -- Alias headings are migration hints only and must not remain in canonical output after apply. -- The base contract treats `Table of Contents` as required unconditionally. -- Unknown keys should be tolerated but ignored unless a downstream plugin explicitly documents them. diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/readme-customization.md b/plugins/repository-skills/skills/maintain-project-readme/references/readme-customization.md deleted file mode 100644 index 34f1f0bd3..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/readme-customization.md +++ /dev/null @@ -1,44 +0,0 @@ -# README Customization Guide - -## Why Customization Exists - -`maintain-project-readme` is the general template layer for ordinary project READMEs. The base schema is intentionally strict, but downstream plugins can adapt it through explicit config instead of forking the workflow into unrelated variants. - -## Canonical Base Defaults - -The built-in template config in `config/readme-customization.template.yaml` defines: - -- title plus one-line summary -- always-required H2-only table of contents -- canonical top-level sections -- required `Overview` subsections -- a short `Development` handoff to contributor documentation, usually `CONTRIBUTING.md` - -## Supported Customization Knobs - -- `profile` - - Human-readable label for the active schema profile. -- `settings.requiredSections` - - Exact canonical top-level sections that must exist. -- `settings.sectionOrder` - - Exact canonical top-level order used for normalization. -- `settings.requiredSubsections` - - Exact required `###` subsections keyed by parent H2 section. -- `settings.sectionAliases` - - Migration hints from non-canonical headings to canonical output headings. -- `settings.sectionTemplates` - - Neutral scaffolding text for missing required sections. -- `settings.subsectionTemplates` - - Neutral scaffolding text for missing required subsections. -- `settings.allowAdditionalSections` - - Whether repo-specific extra sections are preserved after the canonical block. -- `settings.preservePreamble` - - Whether content before the first H2 is preserved during apply mode. - -## Customization Policy - -- Downstream plugins may add sections, subsections, alias mappings, and scaffolding. -- Downstream plugins may reorder canonical sections. -- Downstream plugins should preserve the table of contents as part of the shared base structure. -- The configured structure remains authoritative once loaded. -- Apply mode should normalize into the configured schema, not negotiate with existing README drift. diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/section-schema.md b/plugins/repository-skills/skills/maintain-project-readme/references/section-schema.md deleted file mode 100644 index 362a20f4e..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/section-schema.md +++ /dev/null @@ -1,29 +0,0 @@ -# Section Schema - -## Canonical Base README Structure - -The canonical base README structure is defined in `config/readme-customization.template.yaml`. - -## Hard-Enforced Rules - -- Top-level canonical sections use exact `##` heading names from the configured schema. -- Required subsections use exact `###` heading names from the configured schema. -- Canonical sections appear in canonical order. -- `Overview` owns the canonical `Status`, `What This Project Is`, and `Motivation` subsections. -- `Development` is a short handoff to contributor documentation, usually `CONTRIBUTING.md`; setup, workflow, validation, release, branch, and review procedures do not belong in the base README contract. -- `Table of Contents` is always required in the base workflow. -- Additional repo-specific sections may exist, but they follow the canonical block unless a customization override defines a different order. -- `Table of Contents` is generated from H2 headings only and should use the canonical heading names that appear in the README. -- The summary line directly beneath the title is part of the schema contract, not optional polish. - -## Alias Policy - -- Alias headings may be used as migration hints during apply mode. -- Alias headings are not canonical output. -- If a README uses an alias such as `Getting Started` where the canonical schema expects `Quick Start`, the audit should report the non-canonical heading and apply mode should migrate it to the configured canonical heading name. - -## Downstream Customization - -- Downstream plugins may add, remove, or reorder sections through the customization config. -- Downstream plugins may add required subsections and alias mappings. -- Even when customized, the configured schema remains hard-enforced for both `check-only` and `apply`. diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/style-rules.md b/plugins/repository-skills/skills/maintain-project-readme/references/style-rules.md deleted file mode 100644 index c55787bc0..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/style-rules.md +++ /dev/null @@ -1,16 +0,0 @@ -# Style Rules - -- Keep README prose direct, practical, and grounded in the repo. -- Prefer short explanatory paragraphs over marketing language. -- Keep the whole README near 250 lines by default, with 300 lines as a soft ceiling for consolidation. -- Keep most generated or agent-edited top-level sections near 40 lines or less. -- Treat the title plus one-line summary as a stable, repeatable intro block. -- Use exact configured heading names instead of near-synonyms in final output. -- Treat `Status`, `What This Project Is`, and `Motivation` as user-authored sections rather than generated claims. -- Leave existing text in those Overview subsections intact unless the user explicitly supplies replacement text. -- Use exactly `TBD` for any empty or missing Overview subsection body, and ask the user for those subsection texts before bootstrapping a new README. -- Keep `Quick Start` and `Usage` human-focused and end-user friendly. -- Prefer fenced code blocks with info strings in `Usage` when concrete examples help. -- Keep README content distinct from `CONTRIBUTING.md`, `ROADMAP.md`, and `AGENTS.md`; small-ticket planning belongs in `ROADMAP.md` by default. -- Keep generated scaffolding readable enough for a maintainer to refine quickly. -- Preserve useful repo-specific prose when it already fits the configured section contract. diff --git a/plugins/repository-skills/skills/maintain-project-readme/references/verification-checklist.md b/plugins/repository-skills/skills/maintain-project-readme/references/verification-checklist.md deleted file mode 100644 index 26920b775..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/references/verification-checklist.md +++ /dev/null @@ -1,13 +0,0 @@ -# Verification Checklist - -- The README has a title and one-line summary. -- The README has a table of contents. -- The configured canonical top-level sections all exist. -- The configured required subsections all exist beneath the correct parent section, including `Overview > Status`, `Overview > What This Project Is`, and `Overview > Motivation` in the base schema. -- The base `Development` section stays a short handoff to contributor documentation instead of duplicating setup, workflow, validation, branch, review, release, or maintainer procedure. -- Canonical sections appear in canonical order. -- Alias headings are migrated or reported as non-canonical. -- The table of contents lists the actual H2 headings in order. -- Placeholder-style content is reported instead of silently accepted. -- `apply` mode changes only the target `README.md`. -- Clean runs emit exactly `No findings.` diff --git a/plugins/repository-skills/skills/maintain-project-readme/scripts/maintain_project_readme.py b/plugins/repository-skills/skills/maintain-project-readme/scripts/maintain_project_readme.py deleted file mode 100644 index fef06ca3d..000000000 --- a/plugins/repository-skills/skills/maintain-project-readme/scripts/maintain_project_readme.py +++ /dev/null @@ -1,885 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and apply bounded README maintenance from a hard-enforced schema.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] -CONTRIBUTOR_PROCEDURE_HEADINGS = { - "Setup", - "Workflow", - "Validation", - "Local Setup", - "Development Workflow", - "Release Workflow", - "Review Workflow", - "Maintainer Workflow", -} -USER_AUTHORED_OVERVIEW_SUBSECTIONS = { - "Status", - "What This Project Is", - "Motivation", -} - - -@dataclass -class Issue: - issue_id: str - category: str - severity: str - file: str - evidence: str - recommended_fix: str - auto_fixable: bool - fixed: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_id": self.issue_id, - "category": self.category, - "severity": self.severity, - "file": self.file, - "evidence": self.evidence, - "recommended_fix": self.recommended_fix, - "auto_fixable": self.auto_fixable, - "fixed": self.fixed, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded README maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--readme-path", help="Optional README path override") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional README config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when unresolved issues remain") - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def slugify_heading(heading: str) -> str: - slug = heading.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"\s+", "-", slug) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - heading = match.group(1).strip() - body = text[start:end].strip("\n") - sections.append((heading, body)) - return preamble, sections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def parse_title_and_summary(preamble: str) -> Tuple[Optional[str], Optional[str], List[str]]: - lines = [line.rstrip() for line in preamble.splitlines()] - title: Optional[str] = None - summary: Optional[str] = None - extras: List[str] = [] - - if not lines: - return None, None, extras - - title_index: Optional[int] = None - for idx, line in enumerate(lines): - if line.startswith("# "): - title = line[2:].strip() - title_index = idx - break - - if title_index is None: - return None, None, lines - - summary_index: Optional[int] = None - for idx in range(title_index + 1, len(lines)): - if lines[idx].strip(): - summary = lines[idx].strip() - summary_index = idx - break - - for idx, line in enumerate(lines): - if idx == title_index or idx == summary_index: - continue - extras.append(line) - - return title, summary, extras - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, repo_name: str, preserve_preamble: bool) -> str: - title, summary, extras = parse_title_and_summary(preamble) - normalized_title = title or repo_name - normalized_summary = summary or f"Project documentation for {repo_name}." - - lines = [f"# {normalized_title}", "", normalized_summary] - if preserve_preamble: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - lines.extend(["", *extra_lines]) - return "\n".join(lines).strip() - - -def render_template_bootstrap(project_root: Path) -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "README.template.md" - template = read_text(template_path) - rendered = template.replace("{{PROJECT_NAME}}", project_root.name) - rendered = rendered.replace("{{ONE_LINE_SUMMARY}}", f"Project documentation for {project_root.name}.") - return normalize_whitespace(rendered) - - -def is_skills_or_plugin_repo(project_root: Path) -> bool: - if (project_root / ".codex-plugin" / "plugin.json").is_file(): - return True - skills_dir = project_root / "skills" - if skills_dir.is_dir(): - for skill_file in skills_dir.glob("*/SKILL.md"): - if skill_file.is_file(): - return True - return False - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "readme-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "readme-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - sections = settings.get("requiredSections", []) - return [str(item) for item in sections] if isinstance(sections, list) else [] - - -def canonical_order(settings: Dict[str, Any]) -> List[str]: - order = settings.get("sectionOrder", []) - return [str(item) for item in order] if isinstance(order, list) else [] - - -def required_subsections(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("requiredSubsections", {}) - if not isinstance(raw, dict): - return {} - normalized: Dict[str, List[str]] = {} - for key, value in raw.items(): - if isinstance(value, list): - normalized[str(key)] = [str(item) for item in value] - return normalized - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - normalized: Dict[str, List[str]] = {} - for key, value in raw.items(): - if isinstance(value, list): - normalized[str(key)] = [str(item) for item in value] - return normalized - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("subsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def collect_subsection_headings(body: str) -> List[str]: - return [heading.strip() for heading in H3_RE.findall(body)] - - -def build_toc(headings: Sequence[str]) -> str: - return "\n".join(f"- [{heading}](#{slugify_heading(heading)})" for heading in headings) - - -def toc_entries(body: str) -> List[str]: - entries: List[str] = [] - for line in body.splitlines(): - match = re.match(r"^\s*-\s+\[(.+?)\]\(#(.+?)\)\s*$", line.strip()) - if match: - entries.append(match.group(1).strip()) - return entries - - -def contains_placeholder_content(heading: str, body: str) -> bool: - if heading != "Overview": - return any(pattern.search(body) for pattern in PLACEHOLDER_PATTERNS) - - preamble, subsections = split_subsections(body) - bodies_to_check: List[str] = [preamble] - for subsection, subsection_body in subsections: - if subsection in USER_AUTHORED_OVERVIEW_SUBSECTIONS and subsection_body.strip() == "TBD": - continue - bodies_to_check.append(subsection_body) - return any(pattern.search("\n".join(bodies_to_check)) for pattern in PLACEHOLDER_PATTERNS) - - -def alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - aliases = section_aliases(settings) - reverse: Dict[str, str] = {} - for canonical, names in aliases.items(): - for alias in names: - reverse[alias] = canonical - return reverse - - -def validate_schema( - readme_path: Path, - readme_text: str, - config: Dict[str, Any], -) -> Tuple[List[Issue], List[Issue], List[Tuple[str, str]]]: - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - subsection_map = required_subsections(settings) - alias_map = alias_lookup(settings) - - schema_issues: List[Issue] = [] - content_issues: List[Issue] = [] - preamble, sections = split_sections(readme_text) - lookup = section_map(sections) - title, summary, _extras = parse_title_and_summary(preamble) - - if not title: - schema_issues.append( - Issue( - issue_id="missing-title", - category="schema", - severity="high", - file=str(readme_path), - evidence="README is missing a top-level '# ' heading.", - recommended_fix="Add a top-level title before the canonical section block.", - auto_fixable=True, - ) - ) - if not summary: - schema_issues.append( - Issue( - issue_id="missing-summary", - category="schema", - severity="high", - file=str(readme_path), - evidence="README is missing a one-line summary directly beneath the title.", - recommended_fix="Add a concise one-line summary directly beneath the title.", - auto_fixable=True, - ) - ) - - if "Table of Contents" not in lookup: - schema_issues.append( - Issue( - issue_id="missing-table-of-contents", - category="schema", - severity="medium", - file=str(readme_path), - evidence="README is missing the required '## Table of Contents' section.", - recommended_fix="Add an H2-only table of contents that mirrors the canonical top-level headings.", - auto_fixable=True, - ) - ) - current_positions: Dict[str, int] = {heading: idx for idx, (heading, _body) in enumerate(sections)} - for heading in required: - if heading not in lookup: - alias_found = next((alias for alias, canonical in alias_map.items() if canonical == heading and alias in lookup), None) - if alias_found: - schema_issues.append( - Issue( - issue_id=f"non-canonical-heading-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(readme_path), - evidence=f"README uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - recommended_fix=f"Rename '## {alias_found}' to '## {heading}'.", - auto_fixable=True, - ) - ) - else: - schema_issues.append( - Issue( - issue_id=f"missing-section-{slugify_heading(heading)}", - category="schema", - severity="high", - file=str(readme_path), - evidence=f"README is missing required section '## {heading}'.", - recommended_fix=f"Add the required '## {heading}' section.", - auto_fixable=True, - ) - ) - - order_positions: List[int] = [] - for heading in order: - if heading in current_positions: - order_positions.append(current_positions[heading]) - else: - alias_found = next((alias for alias, canonical in alias_map.items() if canonical == heading and alias in current_positions), None) - if alias_found: - order_positions.append(current_positions[alias_found]) - if order_positions and order_positions != sorted(order_positions): - schema_issues.append( - Issue( - issue_id="canonical-section-order", - category="schema", - severity="medium", - file=str(readme_path), - evidence="Canonical README sections are not in the configured order.", - recommended_fix="Normalize the top-level sections into canonical order.", - auto_fixable=True, - ) - ) - - for parent, children in subsection_map.items(): - body = lookup.get(parent, "") - if not body: - continue - found_children = collect_subsection_headings(body) - for child in children: - if child not in found_children: - schema_issues.append( - Issue( - issue_id=f"missing-subsection-{slugify_heading(parent)}-{slugify_heading(child)}", - category="schema", - severity="high", - file=str(readme_path), - evidence=f"Section '## {parent}' is missing required subsection '### {child}'.", - recommended_fix=f"Add the required subsection '### {child}' under '## {parent}'.", - auto_fixable=True, - ) - ) - - if parent == "Overview": - preamble, subsections = split_subsections(body) - subsection_lookup = {name: subsection_body for name, subsection_body in subsections} - status_body = subsection_lookup.get("Status", "").strip() - if status_body: - status_lines = [line for line in status_body.splitlines() if line.strip()] - if len(status_lines) > 2 or len(status_body) > 220: - content_issues.append( - Issue( - issue_id="status-section-too-long", - category="content-quality", - severity="low", - file=str(readme_path), - evidence="Section 'Overview > Status' should stay very short and plain.", - recommended_fix="Reduce the Status subsection to a brief statement about maturity, availability, or inactivity.", - auto_fixable=False, - ) - ) - - if "Table of Contents" in lookup: - expected_toc = [heading for heading, _body in sections if heading != "Table of Contents"] - actual_toc = toc_entries(lookup["Table of Contents"]) - if actual_toc != expected_toc: - schema_issues.append( - Issue( - issue_id="stale-table-of-contents", - category="schema", - severity="low", - file=str(readme_path), - evidence="Table of contents entries do not match the canonical top-level section headings in order.", - recommended_fix="Regenerate the H2-only table of contents from the canonical section list.", - auto_fixable=True, - ) - ) - - for heading in required: - required_body = lookup.get(heading) - if not required_body: - continue - if contains_placeholder_content(heading, required_body): - content_issues.append( - Issue( - issue_id=f"placeholder-content-{slugify_heading(heading)}", - category="content-quality", - severity="medium", - file=str(readme_path), - evidence=f"Section '## {heading}' contains placeholder-style content.", - recommended_fix="Replace placeholder content with repo-grounded wording.", - auto_fixable=False, - ) - ) - if not required_body.strip(): - schema_issues.append( - Issue( - issue_id=f"empty-section-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(readme_path), - evidence=f"Section '## {heading}' is present but empty.", - recommended_fix=f"Add grounded content to '## {heading}'.", - auto_fixable=True, - ) - ) - - if heading == "Repo Structure" and "```text" not in required_body: - content_issues.append( - Issue( - issue_id="repo-structure-missing-tree-outline", - category="content-quality", - severity="medium", - file=str(readme_path), - evidence="Section '## Repo Structure' should contain a short directory tree or outline diagram.", - recommended_fix="Replace the Repo Structure prose with a short fenced `text` directory tree or outline.", - auto_fixable=False, - ) - ) - if heading == "Development" and not required_subsections(settings).get("Development"): - procedure_headings = [ - subsection for subsection in collect_subsection_headings(required_body) if subsection in CONTRIBUTOR_PROCEDURE_HEADINGS - ] - if procedure_headings: - content_issues.append( - Issue( - issue_id="readme-development-contains-contributor-procedure", - category="content-quality", - severity="medium", - file=str(readme_path), - evidence=( - "Section '## Development' contains contributor-procedure subsections: " - + ", ".join(f"'### {heading}'" for heading in procedure_headings) - + "." - ), - recommended_fix=( - "Move setup, workflow, validation, release, branch, and review procedures to " - "`CONTRIBUTING.md` or a maintainer document, and keep README.md to a short pointer." - ), - auto_fixable=False, - ) - ) - - return schema_issues, content_issues, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - heading = match.group(1).strip() - subsection_body = body[start:end].strip("\n") - subsections.append((heading, subsection_body)) - return preamble, subsections - - -def render_section_body( - heading: str, - existing_body: str, - settings: Dict[str, Any], -) -> str: - required_children = required_subsections(settings).get(heading, []) - section_template_map = section_templates(settings) - subsection_template_map = subsection_templates(settings) - - if not required_children: - return existing_body.strip() or section_template_map.get(heading, "") - - preamble, subsections = split_subsections(existing_body) - subsection_lookup = {name: body for name, body in subsections} - ordered_lines: List[str] = [] - if preamble.strip(): - ordered_lines.extend([preamble.strip(), ""]) - - for idx, child in enumerate(required_children): - child_body = subsection_lookup.get(child, "").strip() - if not child_body: - child_body = subsection_template_map.get(f"{heading}/{child}", "") - ordered_lines.extend([f"### {child}", "", child_body.strip()]) - if idx < len(required_children) - 1: - ordered_lines.append("") - - used_children = set(required_children) - extras = [(name, body) for name, body in subsections if name not in used_children] - if extras: - ordered_lines.append("") - for idx, (name, body) in enumerate(extras): - ordered_lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extras) - 1: - ordered_lines.append("") - - rendered = "\n".join(line for line in ordered_lines if line is not None).strip() - return rendered or section_template_map.get(heading, "") - - -def apply_fixes(project_root: Path, readme_path: Path, readme_text: str, config: Dict[str, Any]) -> Tuple[str, List[Dict[str, str]]]: - if not readme_text.strip(): - bootstrap = render_template_bootstrap(project_root) - write_text(readme_path, bootstrap) - return ( - bootstrap, - [ - { - "action": "create-readme-from-template", - "file": str(readme_path), - "reason": "Created a missing README.md from the bundled canonical README template.", - } - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - alias_map = alias_lookup(settings) - preserve_preamble = bool(settings.get("preservePreamble", True)) - allow_additional = bool(settings.get("allowAdditionalSections", True)) - - preamble, sections = split_sections(readme_text) - repo_name = project_root.name - normalized_preamble = normalize_preamble(preamble, repo_name, preserve_preamble) - existing_lookup = section_map(sections) - - canonical_lookup: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - used_aliases: set[str] = set() - - for heading, body in sections: - if heading == "Table of Contents": - continue - if heading in order or heading in required: - canonical_lookup[heading] = body - continue - if heading in alias_map: - canonical_lookup[alias_map[heading]] = body - used_aliases.add(heading) - continue - if allow_additional: - extra_sections.append((heading, body)) - - canonical_sections: List[Tuple[str, str]] = [] - for heading in order: - existing_body = canonical_lookup.get(heading, existing_lookup.get(heading, "")) - body = render_section_body(heading, existing_body, settings).strip() - canonical_sections.append((heading, body)) - - top_level_for_toc = [heading for heading, _body in canonical_sections] - if allow_additional: - top_level_for_toc.extend(heading for heading, _body in extra_sections) - rendered_lines = [normalized_preamble.strip()] - rendered_lines.extend(["", "## Table of Contents", "", build_toc(top_level_for_toc)]) - - for heading, body in canonical_sections: - rendered_lines.extend(["", f"## {heading}", "", body.strip()]) - - if allow_additional: - for heading, body in extra_sections: - rendered_lines.extend(["", f"## {heading}", "", body.strip()]) - - updated = normalize_whitespace("\n".join(rendered_lines)) - actions: List[Dict[str, str]] = [] - if updated != normalize_whitespace(readme_text): - write_text(readme_path, updated) - actions.append( - { - "action": "normalize-readme-schema", - "file": str(readme_path), - "reason": "Normalized the README into the configured canonical structure and preserved allowed preamble content.", - } - ) - if used_aliases: - actions.append( - { - "action": "migrate-alias-headings", - "file": str(readme_path), - "reason": f"Migrated alias headings into canonical heading names: {', '.join(sorted(used_aliases))}.", - } - ) - return updated, actions - - -def markdown_report(report: Dict[str, Any]) -> str: - lines = [ - "# Maintain Project README Report", - "", - "## Run Context", - "", - f"- Project root: `{report['run_context']['project_root']}`", - f"- README path: `{report['run_context']['readme_path']}`", - f"- Run mode: `{report['run_context']['run_mode']}`", - f"- Timestamp: `{report['run_context']['timestamp_utc']}`", - "", - "## Customization State", - "", - f"- Config path: `{report['customization_state'].get('config_path', 'none')}`", - f"- Default config path: `{report['customization_state'].get('default_config_path', 'none')}`", - f"- Profile: `{report['customization_state'].get('profile', 'base')}`", - f"- Customized: `{report['customization_state'].get('is_customized', False)}`", - "", - "## Schema Contract", - "", - f"- Required sections: `{', '.join(report['schema_contract'].get('required_sections', []))}`", - f"- Canonical order: `{', '.join(report['schema_contract'].get('section_order', []))}`", - "", - "## Schema Violations", - "", - ] - if report["schema_violations"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}`: {issue['evidence']}" - for issue in report["schema_violations"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Content Quality Issues", ""]) - if report["content_quality_issues"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}`: {issue['evidence']}" - for issue in report["content_quality_issues"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Fixes Applied", ""]) - if report["fixes_applied"]: - lines.extend(f"- `{action['action']}`: {action['reason']}" for action in report["fixes_applied"]) - else: - lines.append("- None.") - - lines.extend(["", "## Post-Fix Status", ""]) - if report["post_fix_status"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}`: {issue['evidence']}" - for issue in report["post_fix_status"] - ) - else: - lines.append("- Clean.") - - lines.extend(["", "## Errors", ""]) - if report["errors"]: - lines.extend(f"- {error}" for error in report["errors"]) - else: - lines.append("- None.") - - return "\n".join(lines).rstrip() + "\n" - - -def unresolved_issues(report: Dict[str, Any]) -> List[Dict[str, Any]]: - items: List[Dict[str, Any]] = [] - for key in ["schema_violations", "content_quality_issues", "post_fix_status"]: - items.extend(report[key]) - return items - - -def schema_contract(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config_settings(config) - return { - "required_sections": required_sections(settings), - "section_order": canonical_order(settings), - "required_subsections": required_subsections(settings), - } - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - readme_path = Path(args.readme_path).expanduser().resolve() if args.readme_path else project_root / "README.md" - - report: Dict[str, Any] = { - "run_context": { - "project_root": str(project_root), - "readme_path": str(readme_path), - "run_mode": args.run_mode, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - }, - "customization_state": {}, - "schema_contract": {}, - "schema_violations": [], - "content_quality_issues": [], - "fixes_applied": [], - "post_fix_status": [], - "errors": [], - } - - if not project_root.is_dir(): - report["errors"].append(f"Project root does not exist or is not a directory: {project_root}") - return report, markdown_report(report) - config = load_config(project_root, args.config) - report["customization_state"] = { - "config_path": config.get("configPath", "none"), - "default_config_path": config.get("defaultConfigPath", "none"), - "profile": config.get("profile", "base"), - "is_customized": bool(config.get("isCustomized", False)), - } - report["schema_contract"] = schema_contract(config) - - if readme_path.is_file(): - readme_text = read_text(readme_path) - schema_issues, content_issues, _sections = validate_schema(readme_path, readme_text, config) - report["schema_violations"] = [issue.to_dict() for issue in schema_issues] - report["content_quality_issues"] = [issue.to_dict() for issue in content_issues] - elif args.run_mode == "apply": - readme_text = "" - else: - readme_text = "" - report["schema_violations"] = [ - Issue( - issue_id="missing-readme-file", - category="schema", - severity="high", - file=str(readme_path), - evidence="README.md does not exist.", - recommended_fix="Create the canonical README.md file from the bundled template.", - auto_fixable=True, - ).to_dict() - ] - - if args.run_mode == "apply" and not report["errors"]: - _updated_text, actions = apply_fixes(project_root, readme_path, readme_text, config) - report["fixes_applied"] = actions - refreshed_text = read_text(readme_path) - post_schema, post_content, _ = validate_schema(readme_path, refreshed_text, config) - report["post_fix_status"] = [issue.to_dict() for issue in [*post_schema, *post_content]] - - md = markdown_report(report) - return report, md - - -def main() -> int: - args = parse_args() - report, md = run_maintenance(args) - payload = json.dumps(report, indent=2, sort_keys=True) + "\n" - - if args.json_out: - write_text(Path(args.json_out), payload) - if args.md_out: - write_text(Path(args.md_out), md) - - if args.print_json: - sys.stdout.write(payload) - elif args.print_md: - sys.stdout.write(md) - else: - if not unresolved_issues(report) and not report["errors"]: - sys.stdout.write("No findings.\n") - else: - sys.stdout.write(md) - - if report["errors"]: - return 1 - if args.fail_on_issues and unresolved_issues(report): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/repository-skills/skills/maintain-project-repo/SKILL.md b/plugins/repository-skills/skills/maintain-project-repo/SKILL.md index a10c0a9cf..3eb962fe7 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/SKILL.md +++ b/plugins/repository-skills/skills/maintain-project-repo/SKILL.md @@ -1,204 +1,140 @@ --- name: maintain-project-repo -description: Install or refresh repository tooling and canonical project docs. Use for repository bootstrap or maintenance, coordinated README/CONTRIBUTING/AGENTS/ROADMAP updates, releases, tags, publication, or branch accounting. +description: Install or refresh deterministic FSX repository maintenance, maintain all four canonical project documents, validate and synchronize repository assets, and operate protected-main releases. license: Apache-2.0 metadata: - semver: 0.3.0 + semver: 1.0.0 --- # Maintain Project Repo ## Purpose -Install or refresh the reusable `maintain-project-repo` toolkit and canonical -project documentation inside a general repository or a canonical Swift product -workspace. Validation, shared-sync work, release steps, README, contributor -guidance, agent guidance, and roadmap planning stay aligned through one -repository lifecycle. Callers select `generic` or `xcode-workspace` explicitly; -this workflow does not classify repository shape. - -## When To Use - -- Use this skill when a Swift or Xcode repo needs one local entrypoint for validation, shared sync work, and releases. -- Use this skill when a repo has GitHub Actions or local shell helpers that should become thin wrappers around repo-owned scripts. -- Use this skill for a coordinated README.md, CONTRIBUTING.md, AGENTS.md, and ROADMAP.md maintenance pass. -- Use this skill when a repo needs a protected-main standard release flow and a submodule-aware release flow. -- Use this skill when the user asks to release or publish a version. -- Use this skill when the user asks to bump and tag a release, create the GitHub release, prepare or merge a protected-main release, or finish release cleanup and branch accounting. -- Use this skill when the user wants a local-first alternative to putting maintainer logic under `.github/scripts/`. -- Do not use this skill to make ordinary questions, investigations, local edits, or documentation maintenance take a full PR, CI, release, tag, and cleanup path. Repository installation, refresh, and documentation maintenance remain local operations unless the user separately requests delivery or release work. -- Do not run or recommend the release choreography unless the user is actually asking to release, publish, merge, tag, open a release PR, or prepare the repo for that protected-main release workflow. -- Do not use this skill for app bootstrap, Swift package bootstrap, or AGENTS-only guidance sync by themselves. -- Recommend `bootstrap-xcode-workspace --operation create --component-kind library` when the repo does not exist yet and package scaffold creation is still the primary task. -- Recommend `bootstrap-xcode-workspace` when the repo does not exist yet and native Apple product bootstrap is still the primary task. -- Recommend `bootstrap-xcode-workspace --operation adopt|align` for every - existing Swift repository before installing the explicit `xcode-workspace` - profile. - -## Single-Path Workflow - -1. Collect the required inputs: - - `repo_root` - - optional `operation` - - optional `skip_github_workflow` - - optional `dry_run` -2. Use the profile explicitly supplied by the owning workflow: - - use `xcode-workspace` for every Swift repository after canonical creation - or adoption: one `.xcworkspace`, one generated root project, and required - `Apps/`, `Packages/`, and `Services/` roots - - use `generic` only for non-Swift repositories or an explicitly general - maintainer surface - - never inspect markers to decide whether the whole repository is Xcode, - SwiftPM, plain, or mixed - - stop if the requested path is not a repository root - - use lowercase `scripts/repo-maintenance/` for every profile -3. Explain the architecture boundary before mutating anything: - - this is a durable building-block change because it creates one repo-owned maintainer surface that bootstrap, sync, validation, CI, and release flows can all share - - it removes the pain of CI-only helper scripts and scattered release glue - - the simpler extension path considered first was leaving helper scripts under `.github/scripts/` and adding more workflow-specific wrappers, but that would keep local and CI behavior drifting apart - - preserve machine-level Git defaults such as Gale's fetch pruning, - fast-forward-only pulls, and tracking-branch rebases; the installer does - not write `git config --local` because generated repositories must remain - portable across contributor machines -4. Run `scripts/run_workflow.py` to normalize the inputs and choose the installer path. -5. Apply the managed `maintain-project-repo` files: - - install or refresh the managed repo-maintenance files under the selected profile's toolkit root - - install or refresh the selected profile's `config/profile.env` - - install or refresh the thin workflow wrapper at `.github/workflows/validate-repo-maintenance.yml` unless disabled - - for `xcode-workspace`, migrate an existing legacy - `Scripts/repo-maintenance/` toolkit root to lowercase - `scripts/repo-maintenance/`; stop if both roots exist separately - - preserve repo-specific scripts or files that are not part of the managed file set -6. Maintain canonical project documentation as part of the same operation: - - `install` and `refresh` run the README, CONTRIBUTING, AGENTS, and ROADMAP owner workflows serially in `apply` mode - - `report-only` and `--dry-run` run the same document workflows in `check-only` mode and never write documentation - - create missing canonical documents from the owner workflow templates - - preserve the responsibility split between product docs, contributor workflow, agent guidance, and roadmap planning - - report cross-document responsibility drift without silently moving content between files - - never offer a skip-docs path: every repository install or refresh owns the corresponding documentation pass -7. Verify the installed `maintain-project-repo` files and documentation result: - - `scripts/repo-maintenance/*.sh` for every profile - - `.github/workflows/validate-repo-maintenance.yml` when workflow installation is enabled - - branch protection, when enabled, requires the GitHub Actions check context `validate`; do not require the display-style string `Validate Repo Maintenance / validate` -8. Hand off GitHub repository settings work: - - use `maintain-github-repository` for repository features, merge methods, - Dependabot, secret scanning, push protection, vulnerability reporting, - sign-off policy, branch protection, and rulesets - - keep settings alignment separate from release choreography -9. Hand off follow-on work cleanly: - - use the selected profile's `validate-all.sh` for local validation - - use the selected profile's `sync-shared.sh` for repo-local shared sync tasks - - use the selected profile's `release.sh --mode standard --operation prepare` from a feature branch or worktree when protected `main` owns the final release line - - for remote CI, review bots, deployment, or GitHub indexing, consume the emitted continuation packet and first reuse the live matching host-native continuation while the gate remains pending and healthy; do not delete/recreate it for an unchanged snapshot. Codex uses a same-thread heartbeat and Hermes uses an updated continuable `cronjob` with `deliver="origin"` and `attach_to_session=true`; pause/delete only when the gate resolves, fails, is cancelled, or changes identity - - on wakeup, run `--operation inspect` first; run `--operation advance` only if the branch, commit, PR, and tag identities still match the continuation packet. Treat every pending status context as a wait state, not permission to merge; failed checks, requested changes, and unresolved comments remain blocking - - use `scripts/repo-maintenance/release.sh --mode submodule` only when the repo is checked out as a submodule and the parent pointer update remains a separate follow-up - - treat SemVer tags with prerelease suffixes such as `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`, or preview-style suffixes as GitHub prereleases; the release script passes `--prerelease` for those tags and rejects existing release objects whose prerelease metadata does not match the tag - - before claiming a release, publish, merge, or cleanup step is done, enumerate every local branch still not contained by the local base branch and account for each one as already preserved elsewhere, intentionally still in progress, newly archived, newly merged, or safe to delete - - verify commit reachability in the exact local repository and remote before saying work is on `main`, merged, recovered, preserved, or safe to clean up - - do not delete local branches, remote branches, worktrees, archive refs, or temporary rescue refs until branch accounting is complete and any non-base history is merged or preserved on an explicit archive ref - -## Inputs - -- `repo_root`: optional absolute or relative path to the repository root; defaults to `.` -- `operation`: `install`, `refresh`, or `report-only` -- `profile`: `generic` or `xcode-workspace` -- `skip_github_workflow`: optional flag to skip `.github/workflows/validate-repo-maintenance.yml` -- `dry_run`: optional flag to report the managed actions without writing files -- Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` - - `repo_root=.` when omitted - - `operation=install` - - `profile=generic` - - GitHub workflow installation is enabled unless explicitly skipped - - documentation mode is derived from the repository operation and cannot be skipped: `apply` for install/refresh and `check-only` for report-only/dry-run - -## Outputs - -- `status` - - `success`: `maintain-project-repo` is installed, refreshed, or reported successfully - - `blocked`: the requested repo root or installer preconditions are invalid - - `failed`: the installer started but did not complete successfully -- `path_type` - - `primary`: the managed installer path completed - - `fallback`: a non-mutating report-only result was returned -- `output` - - resolved repo root - - normalized inputs - - selected profile - - managed file list - - planned or applied actions - - integrated documentation report with document order, owner reports, responsibility issues, fixes, post-fix status, and errors - - one concise next step - -## Guards and Stop Conditions - -- Stop with `blocked` if the repo root does not exist. -- Stop with `blocked` if the repo root is not a directory. -- Stop with `blocked` if the managed target paths are blocked by non-regular files that cannot be updated safely. -- Stop with `blocked` if the requested operation is unsupported. -- Return `failed` when any selected document owner workflow errors after the repository installer starts; report completed actions explicitly so a partial write is never presented as atomic success. - -## Fallbacks and Handoffs - -- `report-only` is the non-mutating fallback path. -- Documentation is a required repository lifecycle surface. Do not add a compatibility switch that refreshes tooling while leaving README.md, CONTRIBUTING.md, AGENTS.md, or ROADMAP.md outside the operation. -- The installer preserves repo-specific extra files under the selected profile's repo-maintenance root, `.github/workflows/`, and adjacent surfaces when they are not part of the managed file set. -- The installer keeps the selected `maintain-project-repo` profile explicit via the selected profile's `config/profile.env`. -- The installer does not write repository-local Git defaults. Its release script - uses explicit `git pull --ff-only` where protected-main safety must not depend - on a caller's global configuration. -- Apple profiles install checked-in `.swiftformat` and `.swiftlint.yml` samples so SwiftFormat owns formatting shape while SwiftLint stays focused on complementary safety and clarity checks. -- The generated workflow's branch-protection check context is `validate`; GitHub exposes the job check run by that context, not by the workflow title plus job name. -- The generated GitHub Actions wrapper uses Node 24-compatible Actions versions, with `actions/checkout@v6.0.2` as the current validated floor. Newer stable official action versions are allowed and often preferred after checking release notes and running the relevant validation. Apple profiles report the runner-selected Xcode with shell commands instead of using the Node 20-based `maxim-lobanov/setup-xcode@v1` action. -- Standard release mode has bounded `prepare`, `inspect`, and `advance` operations. It never watches or polls remote state: it reuses a live matching host-native continuation while its gate is pending and healthy, creates/updates one only after it fires or becomes stale, resumes with `inspect`, and advances only after identity checks still match the packet. Every scheduled interval is at least five minutes. -- GitHub release creation preserves prerelease metadata for SemVer prerelease tags and fails clearly when an existing GitHub release object disagrees with the tag. -- GitHub release creation prefers checked-in `docs/releases/vX.Y.Z.md` notes, then `docs/releases/X.Y.Z.md`; it logs and falls back to GitHub-generated notes only when neither file exists. -- Treat branch accounting as a hard completion gate for release and cleanup work, not as follow-up tidying. If `git branch --no-merged ` reports local branches after a merge, account for each branch explicitly before deleting anything or reporting the workflow complete. -- Recommend `bootstrap-xcode-workspace --operation create --component-kind library` or `bootstrap-xcode-workspace` when the repo still needs to be created. -- Recommend `bootstrap-xcode-workspace --operation align` when product guidance alignment is still the missing baseline after `maintain-project-repo` is present. - -## Codex Subagent Fit - -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy repo-maintenance discovery before the main workflow installs, refreshes, or reports: inspecting existing validation scripts, checking CI wrapper shape, reading release docs, or inventorying repo-specific commands in separate directories. - -Keep managed file installation, refresh, and release guidance in the main thread unless the user explicitly requests parallel implementation with disjoint write scopes. Subagents should return concise findings and file references so the main thread can make one coherent decision about the managed toolkit. - -## Codex Hooks Fit - -This skill may document Codex Hooks as an adjacent Codex runtime surface, but it should not install or manage Codex Hooks as part of the current `maintain-project-repo` file set. Keep Codex Hooks distinct from git pre-commit hooks, `scripts/repo-maintenance/hooks/`, validation scripts, and GitHub Actions wrappers. - -When a repo needs Codex Hooks guidance, record that hooks are enabled by default, may be disabled with `features.hooks = false`, may live in `hooks.json` or inline `[hooks]` config, and should name the lifecycle event, matcher, stable script path, and expected effect. Recommend a future dedicated `maintain-project-hooks` workflow when the user wants deterministic hook auditing or scaffolding. - -## Customization - -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- The current customization surface is one policy-only default for release mode preference. Installation shape, profile selection, standard-mode branch release behavior, and managed file selection are explicit workflow behavior, not durable runtime customization. +Install one repo-owned F# script runtime behind a small `just` interface. The +runtime owns repository validation, shared-asset synchronization, canonical +documentation, and bounded protected-main release operations. + +## Required Interface + +Run repository work through `just`. Documentation has exactly two public +commands and both always process README.md, CONTRIBUTING.md, AGENTS.md, and +ROADMAP.md as one transaction: + +```text +just docs-check +just docs-apply +``` + +The remaining managed commands are: + +```text +just repo-validate +just repo-sync +just repo-release-prepare +just repo-release-inspect +just repo-release-advance +``` + +Never add per-document recipes, direct operator-facing script commands, Python +or shell implementations, compatibility wrappers, or alternate documentation +modes. + +## Installation Workflow + +1. Confirm the target is the repository root and select `generic` or + `xcode-workspace` explicitly. +2. Use `scripts/maintain-project-repo.fsx` from this skill for `install`, + `refresh`, or `report-only`. +3. Install the fixed manifest under `scripts/repo-maintenance/`, the managed + Just import, and the GitHub validation workflow. +4. Install or refresh the four documentation contracts and templates. +5. Run the full documentation transaction: apply for install/refresh and check + for report-only. +6. Run the target repository's `just repo-validate` after mutation. + +The installer preserves repo-owned files outside the managed manifest. It does +not infer profiles, accept project-local schemas, or expose skip-docs behavior. + +## Documentation Contract + +The four document-owner skills provide fixed JSON contracts and Markdown +templates. `maintain-project-docs.fsx` loads all four in a fixed order, audits +responsibility boundaries, plans every change before writing, applies writes +atomically, and verifies the result. Apply is idempotent. + +Customization is intentionally narrow: repositories supply their substantive +project content inside the canonical sections. They cannot customize document +names, required headings, aliases, ordering, status vocabulary, normalization, +or fix policy. + +## Managed Layout + +```text +scripts/repo-maintenance/ + maintain-project-docs.fsx + repo-maintenance.fsx + repo-maintenance.just + managed-assets.json + docs/ + validations/ + syncing/ + version-bump.fsx # optional repo-owned release hook +.github/workflows/ + validate-repo-maintenance.yml +``` + +Ordered validation and synchronization hooks are `.fsx` files. The runtime +discovers them lexically and invokes them through `dotnet fsi`. Hook filenames +and arguments are the extension boundary; there is no persistent policy file. + +## Validation and Synchronization + +- `just repo-validate` verifies the managed manifest and Just import, then runs + every root-owned validation hook. +- `just repo-sync` runs every root-owned synchronization hook and then validates. +- CI calls `just repo-validate`; it does not duplicate repository policy. +- End-to-end tests live only at the target repository root. Do not install or + retain nested test suites inside skills or managed directories. + +## Release Workflow + +Use the standard protected-main path only when the user asks to release: + +1. `repo-release-prepare` validates, performs the repo-owned version bump, + checks release notes, commits the release branch, pushes it, and creates or + updates its PR. +2. `repo-release-inspect` checks the saved branch, commit, PR, checks, reviews, + comments, base branch, and tag identities without polling. +3. `repo-release-advance` repeats identity checks, merges only when every gate + passes, updates the owning main worktree, tags, pushes, creates the GitHub + release, and performs branch accounting. + +Prerelease SemVer tags create GitHub prereleases. Checked-in notes under +`docs/releases/` are preferred. Never delete branches, worktrees, refs, or +release state until every unmerged branch is explicitly accounted for. + +## Guards + +- Treat repository-skills 10.0.2 as the migration baseline; do not copy behavior + from an earlier installed version. +- Stop when a managed target is not a regular file or when both legacy and + canonical toolkit roots exist. +- Stop on unsupported profiles, operations, hook extensions, release states, + or document contract violations. +- Do not write repository-local Git defaults. +- Do not add Python, shell, YAML customization, per-file documentation commands, + nested tests, or transitional duplicate paths. ## References -### Workflow References - - `references/document-boundaries.md` - `references/repo-maintenance-layout.md` - `references/release-modes.md` - `references/pre-commit-vs-ci.md` -- `references/trigger-eval.md` - -### Contract References - - `references/automation-prompts.md` - `references/project-docs-maintenance-automation-prompts.md` -- `references/customization-flow.md` - -### Support References - -- `assets/repo-maintenance/` -- `assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml` -### Script Inventory +## Script Inventory -- `scripts/run_workflow.py` -- `scripts/install_maintain_project_repo.py` -- `scripts/maintain_project_docs.py` -- `scripts/customization_config.py` +- `scripts/maintain-project-repo.fsx` +- `scripts/maintain-project-docs.fsx` diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml b/plugins/repository-skills/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml deleted file mode 100644 index e46c46492..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Validate Repo Maintenance - -# Branch protection should require the Actions check context `validate`. -# GitHub exposes the job check run by this job name, not by the workflow title. - -on: - pull_request: - push: - branches: - - main - -jobs: - validate: - name: validate - runs-on: macos-latest - steps: - # This is a validated floor, not a ceiling; update to newer stable official versions when validated. - - uses: actions/checkout@v6.0.2 - - name: Install Swift repo-maintenance tools - run: brew install swiftformat swiftlint - - name: Run repo-maintenance validation - run: bash scripts/repo-maintenance/validate-all.sh diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml b/plugins/repository-skills/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml new file mode 100644 index 000000000..5586cf296 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml @@ -0,0 +1,24 @@ +name: Validate Repo Maintenance + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: validate + runs-on: macos-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-dotnet@v6.0.0 + with: + global-json-file: global.json + - name: Install just + run: brew install just + - name: Validate repository + run: just repo-validate diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/managed-assets.json b/plugins/repository-skills/skills/maintain-project-repo/assets/managed-assets.json new file mode 100644 index 000000000..6d42ae3f3 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/managed-assets.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "files": [ + { "source": "shared/project-docs/ProjectDocs.fsx", "target": "scripts/repo-maintenance/lib/ProjectDocs.fsx" }, + { "source": "shared/project-docs/DocsCoordinator.fsx", "target": "scripts/repo-maintenance/lib/DocsCoordinator.fsx" }, + { "source": "skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx", "target": "scripts/repo-maintenance/maintain-project-docs.fsx" }, + { "source": "skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx", "target": "scripts/repo-maintenance/repo-maintenance.fsx" }, + { "source": "skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just", "target": "scripts/repo-maintenance/repo-maintenance.just" }, + { "source": "skills/maintain-project-readme/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/readme/document.contract.json" }, + { "source": "skills/maintain-project-readme/assets/README.template.md", "target": "scripts/repo-maintenance/docs/readme/README.template.md" }, + { "source": "skills/maintain-project-contributing/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/contributing/document.contract.json" }, + { "source": "skills/maintain-project-contributing/assets/CONTRIBUTING.template.md", "target": "scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md" }, + { "source": "skills/maintain-project-agents/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/agents/document.contract.json" }, + { "source": "skills/maintain-project-agents/assets/AGENTS.template.md", "target": "scripts/repo-maintenance/docs/agents/AGENTS.template.md" }, + { "source": "skills/maintain-project-roadmap/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/roadmap/document.contract.json" }, + { "source": "skills/maintain-project-roadmap/assets/ROADMAP.template.md", "target": "scripts/repo-maintenance/docs/roadmap/ROADMAP.template.md" }, + { "source": "skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml", "target": ".github/workflows/validate-repo-maintenance.yml" }, + { "source": "skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx", "target": "scripts/repo-maintenance/validations/40-xcode-workspace-layout.fsx", "profile": "xcode-workspace" }, + { "source": "skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx", "target": "scripts/repo-maintenance/workspace/validate-components.fsx", "profile": "xcode-workspace" } + ] +} diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml deleted file mode 100644 index 5e055903d..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Validate Repo Maintenance - -# Branch protection should require the Actions check context `validate`. -# GitHub exposes the job check run by this job name, not by the workflow title. - -on: - pull_request: - push: - branches: - - main - -jobs: - validate: - name: validate - runs-on: macos-26 - steps: - # This is a validated floor, not a ceiling; update to newer stable official versions when validated. - - uses: actions/checkout@v6.0.2 - - name: Report selected Xcode - run: xcode-select --print-path - - name: Report Swift toolchain - run: xcrun swift --version - - name: Install Swift repo-maintenance tools - run: brew install swiftformat swiftlint - - name: Run repo-maintenance validation - run: bash scripts/repo-maintenance/validate-all.sh diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample deleted file mode 100755 index 8fc8726c2..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env sh -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -config_file="$repo_root/.swiftformat" -staged_file_list="$(mktemp "${TMPDIR:-/tmp}/swiftformat-staged.XXXXXX")" -trap 'rm -f "$staged_file_list"' EXIT HUP INT TERM - -if ! command -v swiftformat >/dev/null 2>&1; then - echo "SwiftFormat pre-commit hook could not find the \`swiftformat\` CLI on PATH. Install SwiftFormat before committing, or bypass once with --no-verify if you are unblocking an emergency." >&2 - exit 1 -fi - -if [ ! -f "$config_file" ]; then - echo "SwiftFormat pre-commit hook expected a checked-in config at $config_file, but it was missing. Restore the managed .swiftformat file or refresh maintain-project-repo before committing." >&2 - exit 1 -fi - -cd "$repo_root" -git diff --cached --name-only --diff-filter=ACMR -- '*.swift' > "$staged_file_list" - -if [ ! -s "$staged_file_list" ]; then - exit 0 -fi - -echo "Running SwiftFormat on staged Swift sources..." -swiftformat --config "$config_file" --filelist "$staged_file_list" - -while IFS= read -r relative_path; do - [ -n "$relative_path" ] || continue - git add -- "$relative_path" -done < "$staged_file_list" - -echo "Verifying staged Swift sources with SwiftFormat lint..." -swiftformat --lint --config "$config_file" --filelist "$staged_file_list" diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx new file mode 100644 index 000000000..d4ba3ff3c --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx @@ -0,0 +1,17 @@ +open System +open System.IO +open System.Text.Json + +let maintenanceRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let exactlyOne pattern = Directory.GetDirectories(repoRoot, pattern, SearchOption.TopDirectoryOnly).Length = 1 +let requiredDirectories = [ "Apps"; "Packages"; "Services" ] + +if not (exactlyOne "*.xcworkspace") then failwith "xcode-workspace profile requires exactly one root .xcworkspace." +if not (exactlyOne "*.xcodeproj") then failwith "xcode-workspace profile requires exactly one root .xcodeproj." +if not (File.Exists(Path.Combine(repoRoot, "project.yml"))) then failwith "xcode-workspace profile requires root project.yml." +for directory in requiredDirectories do + if not (Directory.Exists(Path.Combine(repoRoot, directory))) then failwith $"xcode-workspace profile requires {directory}/." + +printfn "Validated canonical xcode-workspace layout." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh deleted file mode 100644 index 0b8b8cbbe..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -require_exactly_one_workspace() { - workspace_count=$(find "$REPO_ROOT" -maxdepth 1 -type d -name '*.xcworkspace' -print | wc -l | tr -d ' ') - [ "$workspace_count" -eq 1 ] || die "The xcode-workspace profile requires exactly one root .xcworkspace; found $workspace_count." -} - -require_component_roots() { - [ -d "$REPO_ROOT/Apps" ] || die "The xcode-workspace profile requires Apps/ at the repository root." - [ -d "$REPO_ROOT/Packages" ] || die "The xcode-workspace profile requires Packages/ at the repository root." - [ -d "$REPO_ROOT/Services" ] || die "The xcode-workspace profile requires Services/ at the repository root." - - [ -f "$REPO_ROOT/project.yml" ] || die "The xcode-workspace profile requires root project.yml." - project_count=$(find "$REPO_ROOT" -maxdepth 1 -type d -name '*.xcodeproj' -print | wc -l | tr -d ' ') - [ "$project_count" -eq 1 ] || die "The xcode-workspace profile requires exactly one generated root .xcodeproj; found $project_count." - - component_count=$(find "$REPO_ROOT/Apps" -type f \( -name 'target.yml' -o -name 'target.yaml' \) -print; find "$REPO_ROOT/Packages" "$REPO_ROOT/Services" -type f -name 'Package.swift' -print) - [ -n "$component_count" ] || die "The xcode-workspace profile requires at least one component under Apps/, Packages/, or Services/." -} - -require_exactly_one_workspace -require_component_roots -log "Validated xcode-workspace composition: one root workspace and project with Apps/, Packages/, and Services/ component roots." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx new file mode 100644 index 000000000..8dc0f703f --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx @@ -0,0 +1,26 @@ +open System +open System.Diagnostics +open System.IO + +let maintenanceRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let componentRoots = [ "Apps"; "Packages"; "Services" ] + +for container in componentRoots do + let path = Path.Combine(repoRoot, container) + if Directory.Exists(path) then + for component in Directory.GetDirectories(path) |> Array.sort do + let script = Path.Combine(component, "scripts", "repo-maintenance", "repo-maintenance.fsx") + if File.Exists(script) then + let info = ProcessStartInfo("dotnet") + info.WorkingDirectory <- component + info.UseShellExecute <- false + info.ArgumentList.Add("fsi") + info.ArgumentList.Add(script) + info.ArgumentList.Add("validate") + use child = Process.Start(info) + child.WaitForExit() + if child.ExitCode <> 0 then failwith $"Component validation failed: {component}" + +printfn "Validated xcode-workspace components." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh deleted file mode 100644 index 9fcc54dc8..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$REPO_MAINTENANCE_COMMON_DIR/common.sh" - -run_component_validation() { - component_root=$1 - component_kind=$2 - candidate="$component_root/scripts/repo-maintenance/validate-all.sh" - if [ -f "$candidate" ]; then - log "Validating $component_kind component at $component_root with ${candidate#"$component_root/"}." - sh "$candidate" - return 0 - fi - log "No component-owned repo-maintenance validation found for $component_kind at $component_root; skipping." -} - -find "$REPO_ROOT/Apps" -type f \( -name 'target.yml' -o -name 'target.yaml' \) -print | sort | while IFS= read -r spec; do - run_component_validation "$(dirname -- "$spec")" "app-target" -done - -find "$REPO_ROOT/Packages" -type f -name 'Package.swift' -print | sort | while IFS= read -r manifest; do - run_component_validation "$(dirname -- "$manifest")" "package" -done - -if [ -d "$REPO_ROOT/Services" ]; then - find "$REPO_ROOT/Services" -mindepth 1 -maxdepth 1 -type d -print | sort | while IFS= read -r service; do - run_component_validation "$service" "service" - done -fi diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/release.env b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/release.env deleted file mode 100644 index a726d51b1..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/release.env +++ /dev/null @@ -1,16 +0,0 @@ -# Repo-maintenance release defaults. -REPO_MAINTENANCE_DEFAULT_RELEASE_MODE=standard -REPO_MAINTENANCE_RELEASE_BRANCH=main -REPO_MAINTENANCE_RELEASE_OPERATION=prepare -# Require one check by default. A repository that intentionally has no remote -# checks may explicitly set this to 0; do not infer permission to advance. -REPO_MAINTENANCE_MIN_REQUIRED_CHECKS=1 - -# GitHub can accept branch, tag, PR, check, review, and release mutations before -# those surfaces are immediately readable. The release script performs one -# bounded re-read, emits a continuation packet when it is not ready, and exits. -# Agents first reuse a live matching host-native continuation while its gate is -# pending and healthy; do not delete/recreate it after an unchanged snapshot. -# Create/update only after it fires or becomes stale, no sooner than five -# minutes later, then run --operation inspect before any --operation advance. -# Never add a shell poll loop or a shorter agent recheck interval here. diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env deleted file mode 100644 index c85b14789..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env +++ /dev/null @@ -1,2 +0,0 @@ -# Repo-maintenance validation defaults. -REPO_MAINTENANCE_REQUIRE_AGENTS=true diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample deleted file mode 100755 index 5749ac2ae..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -exec "$repo_root/scripts/repo-maintenance/validate-all.sh" diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh deleted file mode 100755 index 5d740c46f..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env sh -set -eu - -COMMON_DIR="${REPO_MAINTENANCE_COMMON_DIR:-}" - -if [ -z "$COMMON_DIR" ]; then - COMMON_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -fi - -REPO_MAINTENANCE_ROOT=$(CDPATH= cd -- "$COMMON_DIR/.." && pwd) -REPO_ROOT=$(CDPATH= cd -- "$REPO_MAINTENANCE_ROOT/../.." && pwd) -REPO_MAINTENANCE_PROFILE="generic" -REPO_MAINTENANCE_PROFILE_DESCRIPTION="Generic repo-maintenance baseline with no Swift or Xcode specialization." - -log() { - printf '%s\n' "$*" -} - -warn() { - printf 'WARN: %s\n' "$*" >&2 -} - -die() { - printf 'ERROR: %s\n' "$*" >&2 - exit 1 -} - -load_env_file() { - env_file="$1" - [ -f "$env_file" ] || return 0 - set -a - # shellcheck disable=SC1090 - . "$env_file" - set +a -} - -load_profile_env() { - load_env_file "$REPO_MAINTENANCE_ROOT/config/profile.env" -} - -positive_integer_or_default() { - value="$1" - default_value="$2" - - case "$value" in - ''|*[!0-9]*) - printf '%s\n' "$default_value" - ;; - 0) - printf '%s\n' "$default_value" - ;; - *) - printf '%s\n' "$value" - ;; - esac -} - -is_semver_prerelease_tag() { - tag_name="$1" - case "$tag_name" in - v[0-9]*.[0-9]*.[0-9]*-*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -expected_github_prerelease_value() { - tag_name="$1" - if is_semver_prerelease_tag "$tag_name"; then - printf '%s\n' "true" - else - printf '%s\n' "false" - fi -} - -github_release_create_prerelease_flag() { - tag_name="$1" - if is_semver_prerelease_tag "$tag_name"; then - printf '%s\n' "--prerelease" - fi -} - -verify_github_release_prerelease_metadata() { - tag_name="$1" - expected_value="$(expected_github_prerelease_value "$tag_name")" - - actual_value="$(gh release view "$tag_name" --json isPrerelease --jq .isPrerelease 2>/dev/null || true)" - case "$actual_value" in - true|false) - ;; - *) - die "GitHub release $tag_name exists, but its prerelease metadata was not readable. Confirm gh can read release JSON metadata before rerunning release.sh." - ;; - esac - - [ "$actual_value" = "$expected_value" ] || die "GitHub release $tag_name prerelease metadata mismatch: tag implies isPrerelease=$expected_value but GitHub reports isPrerelease=$actual_value. Update the release metadata or delete and recreate the release before rerunning release.sh." -} - -remote_branch_is_visible() { - branch_name="$1" - git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "$branch_name" >/dev/null 2>&1 -} - -remote_tag_is_visible() { - tag_name="$1" - git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1 -} - -github_release_is_visible() { - tag_name="$1" - gh release view "$tag_name" >/dev/null 2>&1 -} - -checked_in_release_notes_file() { - tag_name="$1" - version_name="${tag_name#v}" - - for candidate in \ - "$REPO_ROOT/docs/releases/$tag_name.md" \ - "$REPO_ROOT/docs/releases/$version_name.md"; do - if [ -f "$candidate" ]; then - printf '%s\n' "$candidate" - return 0 - fi - done - - return 1 -} - -create_github_release_from_notes_or_generated() { - tag_name="$1" - prerelease_flag="${2:-}" - - if notes_file="$(checked_in_release_notes_file "$tag_name")"; then - log "Creating GitHub release $tag_name from checked-in notes: $notes_file." - # shellcheck disable=SC2086 - gh release create "$tag_name" --verify-tag --notes-file "$notes_file" $prerelease_flag - return 0 - fi - - log "No checked-in release notes found for $tag_name; using GitHub-generated release notes." - # shellcheck disable=SC2086 - gh release create "$tag_name" --verify-tag --generate-notes $prerelease_flag -} - -ensure_git_repo() { - git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "maintain-project-repo must run inside a git worktree rooted at $REPO_ROOT." -} - -run_dispatch_dir() { - dir="$1" - label="$2" - ran_any="false" - - for script in "$dir"/*.sh; do - [ -e "$script" ] || continue - ran_any="true" - log "Running $label step $(basename "$script")" - sh "$script" - done - - if [ "$ran_any" = "false" ]; then - log "No $label steps are currently defined under $dir." - fi -} diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx new file mode 100644 index 000000000..898b1668a --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx @@ -0,0 +1,23 @@ +#!/usr/bin/env -S dotnet fsi +#load "lib/ProjectDocs.fsx" +#load "lib/DocsCoordinator.fsx" + +open System.IO +open DocsCoordinator + +let root = Path.GetFullPath(__SOURCE_DIRECTORY__) +let asset name target folder template = { + Name = name + Target = target + Contract = Path.Combine(root, "docs", folder, "document.contract.json") + Template = Path.Combine(root, "docs", folder, template) +} + +let assets = [ + asset "readme" "README.md" "readme" "README.template.md" + asset "contributing" "CONTRIBUTING.md" "contributing" "CONTRIBUTING.template.md" + asset "agents" "AGENTS.md" "agents" "AGENTS.template.md" + asset "roadmap" "ROADMAP.md" "roadmap" "ROADMAP.template.md" +] + +fsi.CommandLineArgs |> Array.skip 1 |> execute assets |> exit diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release.sh deleted file mode 100755 index 3bfed8711..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release.sh +++ /dev/null @@ -1,499 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" -. "$SELF_DIR/lib/common.sh" - -load_profile_env -load_env_file "$SELF_DIR/config/release.env" - -mode="${REPO_MAINTENANCE_DEFAULT_RELEASE_MODE:-standard}" -release_tag="" -skip_validate="false" -skip_gh_release="false" -skip_version_bump="false" -base_branch="${REPO_MAINTENANCE_RELEASE_BRANCH:-main}" -review_comments_addressed="false" -skip_branch_cleanup="false" -dry_run="false" -operation="${REPO_MAINTENANCE_RELEASE_OPERATION:-prepare}" - -while [ "$#" -gt 0 ]; do - case "$1" in - --mode) - mode="${2:-}" - shift 2 - ;; - --version) - release_tag="${2:-}" - shift 2 - ;; - --skip-validate) - skip_validate="true" - shift - ;; - --skip-gh-release) - skip_gh_release="true" - shift - ;; - --skip-version-bump) - skip_version_bump="true" - shift - ;; - --base-branch) - base_branch="${2:-}" - shift 2 - ;; - --review-comments-addressed) - review_comments_addressed="true" - shift - ;; - --operation) - operation="${2:-}" - shift 2 - ;; - --skip-branch-cleanup) - skip_branch_cleanup="true" - shift - ;; - --dry-run) - dry_run="true" - shift - ;; - -h|--help) - cat <<'USAGE' -Usage: - release.sh --mode standard --version --operation prepare|inspect|advance [--base-branch main] [--skip-validate] [--skip-version-bump] [--skip-gh-release] [--review-comments-addressed] [--skip-branch-cleanup] [--dry-run] - release.sh --mode submodule --version [--skip-validate] [--skip-gh-release] [--dry-run] -USAGE - exit 0 - ;; - *) - die "Unknown release argument: $1" - ;; - esac -done - -[ -n "$release_tag" ] || die "Pass --version vX.Y.Z when running the release workflow." - -export REPO_MAINTENANCE_RELEASE_MODE="$mode" -export RELEASE_TAG="$release_tag" -export REPO_MAINTENANCE_SKIP_GH_RELEASE="$skip_gh_release" -export REPO_MAINTENANCE_DRY_RUN="$dry_run" -export REPO_MAINTENANCE_RELEASE_OPERATION="$operation" - -ensure_clean_worktree() { - status_output="$(git -C "$REPO_ROOT" status --porcelain)" - [ -z "$status_output" ] || die "Release workflow requires committed changes and a clean worktree before it can continue." -} - -ensure_gh_cli() { - command -v gh >/dev/null 2>&1 || die "Standard release mode requires the GitHub CLI gh so it can inspect and advance the pull request, merge, and publish the release." -} - -ensure_semver_tag() { - case "$RELEASE_TAG" in - v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) - ;; - *) - die "Release tag must use vX.Y.Z SemVer syntax." - ;; - esac -} - -ensure_operation() { - case "$REPO_MAINTENANCE_RELEASE_OPERATION" in - prepare|inspect|advance) - ;; - *) - die "Release operation must be prepare, inspect, or advance. Long-running remote checks must be resumed by a host-native scheduled continuation, never watched from this script." - ;; - esac -} - -current_branch() { - git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true -} - -ensure_branch_release_context() { - branch_name="$(current_branch)" - [ -n "$branch_name" ] || die "Standard release mode requires a named feature branch or worktree instead of detached HEAD." - [ "$branch_name" != "$base_branch" ] || die "Standard release mode must run from a release branch or worktree, not protected $base_branch." - printf '%s\n' "$branch_name" -} - -run_version_bump() { - release_version="${RELEASE_TAG#v}" - version_bump_script="$SELF_DIR/version-bump.sh" - head_subject="$(git -C "$REPO_ROOT" log -1 --format=%s 2>/dev/null || true)" - - if [ "$skip_version_bump" = "true" ]; then - log "Skipping repo version bump because --skip-version-bump was requested." - return 0 - fi - - if [ "$head_subject" = "release: bump versions for $RELEASE_TAG" ]; then - log "Version bump commit for $RELEASE_TAG is already at HEAD; continuing the release resume path." - return 0 - fi - - [ -x "$version_bump_script" ] || die "Standard release mode expected an executable repo-specific version bump hook at $version_bump_script. Add that hook so the repo's version surfaces move together, or rerun with --skip-version-bump when this release intentionally has no version-bearing files." - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would run $version_bump_script $release_version with RELEASE_TAG=$RELEASE_TAG." - return 0 - fi - - RELEASE_VERSION="$release_version" "$version_bump_script" "$release_version" - - if [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - die "Version bump hook completed without changing files. Update $version_bump_script to edit the repo's version surfaces, or rerun with --skip-version-bump if this release intentionally has no version bump." - fi - - git -C "$REPO_ROOT" add -A - git -C "$REPO_ROOT" commit -m "release: bump versions for $RELEASE_TAG" - log "Committed version bump for $RELEASE_TAG." -} - -create_release_tag() { - head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" - tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" - - if [ -n "$tag_sha" ]; then - tag_commit_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$RELEASE_TAG")" - [ "$tag_commit_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." - log "Tag $RELEASE_TAG already points at HEAD." - return 0 - fi - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would create annotated tag $RELEASE_TAG at HEAD." - return 0 - fi - - git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" - log "Created annotated tag $RELEASE_TAG." -} - -push_release_branch() { - branch_name="$1" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would push branch $branch_name to origin." - return 0 - fi - - git -C "$REPO_ROOT" push -u origin "$branch_name" - log "Pushed branch $branch_name." - remote_branch_is_visible "$branch_name" || return 1 -} - -push_release_tag() { - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would push tag $RELEASE_TAG to origin." - return 0 - fi - - git -C "$REPO_ROOT" push origin "$RELEASE_TAG" - log "Pushed tag $RELEASE_TAG." - remote_tag_is_visible "$RELEASE_TAG" || return 1 -} - -create_or_update_pr() { - branch_name="$1" - PR_NUMBER="" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would create or update a release PR from $branch_name into $base_branch." - PR_NUMBER="DRY-RUN" - return 0 - fi - - body_file="$(mktemp "${TMPDIR:-/tmp}/repo-maintenance-release-pr.XXXXXX")" - trap 'rm -f "$body_file"' EXIT INT TERM - - cat >"$body_file" </dev/null - log "Updated existing release PR #$pr_number at $pr_url." - else - gh pr create --base "$base_branch" --head "$branch_name" --title "release: prepare $RELEASE_TAG" --body-file "$body_file" >/dev/null - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - [ -n "$pr_number" ] || die "GitHub CLI did not return a release PR number after creating the pull request." - pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" - log "Created release PR #$pr_number at $pr_url." - PR_NUMBER="$pr_number" - return 0 - fi - - PR_NUMBER="$pr_number" -} - -inspect_pr_gate() { - pr_number="$1" - minimum_check_count="${REPO_MAINTENANCE_MIN_REQUIRED_CHECKS:-1}" - case "$minimum_check_count" in - ''|*[!0-9]*) die "REPO_MAINTENANCE_MIN_REQUIRED_CHECKS must be a non-negative integer; received $minimum_check_count." ;; - esac - if CHECK_STATE="$(gh pr checks "$pr_number" --json name,bucket --jq 'map(.name + ":" + .bucket) | join(",")' 2>/dev/null)"; then - check_readable="true" - elif [ -n "${CHECK_STATE:-}" ]; then - # gh pr checks exits 8 while pending even when it returned valid JSON output. - check_readable="true" - else - CHECK_STATE="unreadable" - check_readable="false" - fi - if CHECK_BUCKETS="$(gh pr checks "$pr_number" --json bucket --jq 'map(.bucket) | join(",")' 2>/dev/null)"; then - : - elif [ -z "${CHECK_BUCKETS:-}" ]; then - check_readable="false" - fi - if check_count="$(gh pr checks "$pr_number" --json bucket --jq 'length' 2>/dev/null)"; then - : - elif [ -z "${check_count:-}" ]; then - check_readable="false" - fi - REVIEW_DECISION="$(gh pr view "$pr_number" --json reviewDecision --jq '.reviewDecision // ""' 2>/dev/null || printf 'UNREADABLE')" - COMMENT_COUNT="$(gh pr view "$pr_number" --json comments,reviews --jq '([.comments[]?, (.reviews[]? | select(.state == "COMMENTED"))] | length)' 2>/dev/null || printf '1')" - if [ "$check_readable" != "true" ] || [ "$REVIEW_DECISION" = "UNREADABLE" ]; then - GATE_PHASE="awaiting-github-state" - elif [ "$check_count" -lt "$minimum_check_count" ]; then - GATE_PHASE="awaiting-github-state" - elif case ",$CHECK_BUCKETS," in *,fail,*|*,cancel,*) true ;; *) false ;; esac; then - GATE_PHASE="failed-checks" - elif case ",$CHECK_BUCKETS," in *,pending,*) true ;; *) false ;; esac; then - GATE_PHASE="awaiting-pr-checks" - elif [ "$REVIEW_DECISION" = "CHANGES_REQUESTED" ]; then - GATE_PHASE="changes-requested" - else - GATE_PHASE="ready-to-advance" - fi - log "PR #$pr_number snapshot: phase=$GATE_PHASE; checks=${CHECK_STATE:-none}; review=${REVIEW_DECISION:-none}; comments=$COMMENT_COUNT." -} - -emit_continuation_packet() { - pr_number="$1" - branch_name="$2" - phase="$3" - resume_operation="inspect" - case "$phase" in - not-started|awaiting-branch-visibility) - resume_operation="prepare" - ;; - esac - repo_name="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || printf 'unknown')" - head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" - printf '%s\n' "{\"schema\":\"repo-maintenance-continuation/v1\",\"operation\":\"standard-release\",\"repository\":\"$repo_name\",\"release_tag\":\"$RELEASE_TAG\",\"branch\":\"$branch_name\",\"head_commit\":\"$head_sha\",\"pr_number\":\"$pr_number\",\"phase\":\"$phase\",\"minimum_delay_minutes\":5,\"resume_command\":\"scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG --operation $resume_operation\",\"advance_command\":\"scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG --operation advance\"}" - log "Before scheduling, reuse a live matching host-native continuation while this gate is pending and healthy; do not delete/recreate it after an unchanged snapshot. Create or update one only after it fires or becomes stale, no sooner than five minutes. On wakeup run inspect first; run advance only if this branch, commit, PR, and tag still match." -} - -check_pr_comments() { - pr_number="$1" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would check PR #$pr_number for comments and requested changes." - return 0 - fi - - review_decision="$REVIEW_DECISION" - comment_count="$COMMENT_COUNT" - - if [ "$review_decision" = "CHANGES_REQUESTED" ]; then - gh pr view "$pr_number" --comments - die "PR #$pr_number has requested changes. Address valid concerns in code, or add out-of-scope concerns to ROADMAP.md, resolve the threads, push, and rerun release.sh." - fi - - if [ "$comment_count" != "0" ] && [ "$review_comments_addressed" != "true" ]; then - gh pr view "$pr_number" --comments - die "PR #$pr_number has review or discussion comments. Address and resolve valid concerns, add out-of-scope concerns to ROADMAP.md, then rerun release.sh with --review-comments-addressed once the comment pass is intentionally complete." - fi - - log "PR #$pr_number has no blocking review state." -} - -merge_pr() { - pr_number="$1" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would merge PR #$pr_number into $base_branch with a merge commit and delete the remote branch." - return 0 - fi - - gh pr merge "$pr_number" --merge --delete-branch - log "Merged PR #$pr_number into $base_branch." -} - -fast_forward_base_branch() { - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would fast-forward local $base_branch from origin/$base_branch." - return 0 - fi - - git -C "$REPO_ROOT" fetch origin "$base_branch" - if git -C "$REPO_ROOT" switch "$base_branch" 2>/dev/null || git -C "$REPO_ROOT" checkout "$base_branch" 2>/dev/null; then - git -C "$REPO_ROOT" pull --ff-only origin "$base_branch" - log "Fast-forwarded local $base_branch." - else - die "Could not check out local $base_branch, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout, then rerun release.sh so the release tag is created from the reviewed base branch." - fi -} - -create_github_release() { - if [ "$REPO_MAINTENANCE_SKIP_GH_RELEASE" = "true" ]; then - log "Skipping GitHub release creation because --skip-gh-release was requested." - return 0 - fi - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" - log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." - return 0 - fi - - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - verify_github_release_prerelease_metadata "$RELEASE_TAG" - log "GitHub release $RELEASE_TAG already exists." - return 0 - fi - - prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" - create_github_release_from_notes_or_generated "$RELEASE_TAG" "$prerelease_flag" - log "Created GitHub release $RELEASE_TAG." - if ! github_release_is_visible "$RELEASE_TAG"; then - warn "GitHub release $RELEASE_TAG is not readable in this immediate re-read. Schedule a continuation for at least five minutes rather than polling." - return 1 - fi - verify_github_release_prerelease_metadata "$RELEASE_TAG" -} - -cleanup_merged_branches() { - release_branch_name="$1" - - if [ "$skip_branch_cleanup" = "true" ]; then - log "Skipping local merged-branch cleanup because --skip-branch-cleanup was requested." - return 0 - fi - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would prune origin and delete local branches already merged into $base_branch, including $release_branch_name when safe." - return 0 - fi - - git -C "$REPO_ROOT" remote prune origin - for merged_branch in $(git -C "$REPO_ROOT" for-each-ref --format='%(refname:short)' --merged "$base_branch" refs/heads); do - case "$merged_branch" in - "$base_branch") - ;; - *) - git -C "$REPO_ROOT" branch -d "$merged_branch" >/dev/null 2>&1 || warn "Could not delete local merged branch $merged_branch; it may be checked out in another worktree." - ;; - esac - done - log "Cleaned up local branches already merged into $base_branch where safe." -} - -run_standard_release() { - ensure_git_repo - ensure_gh_cli - ensure_semver_tag - ensure_operation - - if [ "$REPO_MAINTENANCE_RELEASE_OPERATION" = "inspect" ]; then - branch_name="$(current_branch)" - if [ -z "$branch_name" ]; then - log "Release inspection state: not-started; no named branch is checked out." - return 0 - fi - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - if [ -z "$pr_number" ]; then - emit_continuation_packet "pending" "$branch_name" "not-started" - log "Release inspection state: not-started; no release PR exists for branch $branch_name." - return 0 - fi - inspect_pr_gate "$pr_number" - emit_continuation_packet "$pr_number" "$branch_name" "$GATE_PHASE" - return 0 - fi - - branch_name="$(ensure_branch_release_context)" - ensure_clean_worktree - - if [ "$REPO_MAINTENANCE_RELEASE_OPERATION" = "prepare" ] && [ "$skip_validate" != "true" ]; then - sh "$SELF_DIR/validate-all.sh" - fi - - if [ "$REPO_MAINTENANCE_RELEASE_OPERATION" = "prepare" ]; then - run_version_bump - ensure_clean_worktree - if ! push_release_branch "$branch_name"; then - emit_continuation_packet "pending" "$branch_name" "awaiting-branch-visibility" - return 0 - fi - create_or_update_pr "$branch_name" - pr_number="$PR_NUMBER" - inspect_pr_gate "$pr_number" - emit_continuation_packet "$pr_number" "$branch_name" "$GATE_PHASE" - log "Standard release preparation completed for $RELEASE_TAG." - return 0 - fi - - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - [ -n "$pr_number" ] || die "No release PR exists for branch $branch_name into $base_branch. Run --operation prepare first." - inspect_pr_gate "$pr_number" - case "$GATE_PHASE" in - awaiting-github-state|awaiting-pr-checks) - emit_continuation_packet "$pr_number" "$branch_name" "$GATE_PHASE" - return 0 - ;; - failed-checks|changes-requested) - die "Release PR #$pr_number is in $GATE_PHASE. Resolve the remote gate, push any correction, then use --operation inspect after a scheduled continuation." - ;; - esac - check_pr_comments "$pr_number" - merge_pr "$pr_number" - fast_forward_base_branch - create_release_tag - if ! push_release_tag; then - emit_continuation_packet "$pr_number" "$branch_name" "awaiting-tag-visibility" - return 0 - fi - if ! create_github_release; then - emit_continuation_packet "$pr_number" "$branch_name" "awaiting-github-release-visibility" - return 0 - fi - cleanup_merged_branches "$branch_name" - log "Standard release flow completed successfully for $RELEASE_TAG." -} - -if [ "$mode" = "standard" ]; then - run_standard_release - exit 0 -fi - -if [ "$skip_validate" != "true" ]; then - sh "$SELF_DIR/validate-all.sh" -fi - -log "Running repo-maintenance release flow in $REPO_MAINTENANCE_RELEASE_MODE mode for $RELEASE_TAG with the $REPO_MAINTENANCE_PROFILE profile." -run_dispatch_dir "$SELF_DIR/release" "release" - -if [ "$REPO_MAINTENANCE_RELEASE_MODE" = "submodule" ]; then - log "Submodule release finished. Update the parent repository's submodule pointer in a separate follow-up commit." -fi - -log "Repo-maintenance release flow completed successfully." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh deleted file mode 100755 index 1e6a12e45..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -ensure_git_repo - -case "${REPO_MAINTENANCE_RELEASE_MODE:-}" in - standard|submodule) - ;; - *) - die "Release mode must be standard or submodule." - ;; -esac - -case "${RELEASE_TAG:-}" in - v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) - ;; - *) - die "Release tag must use vX.Y.Z SemVer syntax." - ;; -esac - -branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true)" -[ -n "$branch_name" ] || die "Release workflow requires a named branch instead of detached HEAD." - -status_output="$(git -C "$REPO_ROOT" status --porcelain)" -[ -z "$status_output" ] || die "Release workflow requires a clean worktree before tagging." - -if [ "${REPO_MAINTENANCE_RELEASE_MODE:-}" = "submodule" ]; then - superproject_root="$(git -C "$REPO_ROOT" rev-parse --show-superproject-working-tree || true)" - [ -n "$superproject_root" ] || die "Submodule release mode requires this repository to be checked out as a git submodule." -fi diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh deleted file mode 100755 index 80e147ba0..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" -tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" - -if [ -n "$tag_sha" ]; then - [ "$tag_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." - log "Tag $RELEASE_TAG already points at HEAD." - exit 0 -fi - -if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - log "Would create annotated tag $RELEASE_TAG at HEAD." - exit 0 -fi - -git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" -log "Created annotated tag $RELEASE_TAG." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh deleted file mode 100755 index 148ba4769..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD)" - -if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - log "Would push branch $branch_name and tag $RELEASE_TAG to origin." - exit 0 -fi - -git -C "$REPO_ROOT" push -u origin "$branch_name" -remote_branch_is_visible "$branch_name" || die "Remote branch origin/$branch_name is not visible in this immediate re-read. Do not poll; schedule a host-native continuation for at least five minutes, then re-run the release step." -git -C "$REPO_ROOT" push origin "$RELEASE_TAG" -remote_tag_is_visible "$RELEASE_TAG" || die "Remote tag $RELEASE_TAG is not visible in this immediate re-read. Do not poll; schedule a host-native continuation for at least five minutes, then re-run the release step." -log "Pushed branch $branch_name and tag $RELEASE_TAG." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh deleted file mode 100755 index e78221fe8..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -if [ "${REPO_MAINTENANCE_SKIP_GH_RELEASE:-false}" = "true" ]; then - log "Skipping GitHub release creation because --skip-gh-release was requested." - exit 0 -fi - -if ! command -v gh >/dev/null 2>&1; then - warn "gh is unavailable, so the release tag was pushed without creating a GitHub release object." - exit 0 -fi - -if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" - log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." - exit 0 -fi - -if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - verify_github_release_prerelease_metadata "$RELEASE_TAG" - log "GitHub release $RELEASE_TAG already exists." - exit 0 -fi - -prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" -create_github_release_from_notes_or_generated "$RELEASE_TAG" "$prerelease_flag" -log "Created GitHub release $RELEASE_TAG." -github_release_is_visible "$RELEASE_TAG" || die "GitHub release $RELEASE_TAG is not readable in this immediate re-read. Do not poll; schedule a host-native continuation for at least five minutes, then re-run the release step." -verify_github_release_prerelease_metadata "$RELEASE_TAG" diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx new file mode 100644 index 000000000..b3052eb27 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx @@ -0,0 +1,281 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type CommandResult = { ExitCode: int; Stdout: string; Stderr: string } + +let maintenanceRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let fail message = raise (InvalidOperationException(message)) + +let runIn cwd executable arguments = + let startInfo = ProcessStartInfo(executable) + startInfo.WorkingDirectory <- cwd + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in arguments do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + { ExitCode = child.ExitCode; Stdout = stdout.Trim(); Stderr = stderr.Trim() } + +let requireSuccess description result = + if result.ExitCode <> 0 then + let detail = if String.IsNullOrWhiteSpace(result.Stderr) then result.Stdout else result.Stderr + fail $"{description} failed in {repoRoot}: {detail}" + result.Stdout + +let run executable arguments = runIn repoRoot executable arguments +let git arguments = run "git" arguments +let gh arguments = run "gh" arguments + +let ensureGitRepo () = + git [ "rev-parse"; "--show-toplevel" ] + |> requireSuccess "Git repository check" + |> Path.GetFullPath + |> fun actual -> if actual <> repoRoot then fail $"Repo-maintenance expected repository root {repoRoot}, but Git resolved {actual}." + +let runFsxDirectory name = + let directory = Path.Combine(maintenanceRoot, name) + if Directory.Exists(directory) then + Directory.GetFiles(directory, "*.fsx") + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + |> Array.iter (fun script -> + let result = runIn repoRoot "dotnet" [ "fsi"; script ] + requireSuccess $"Repo-maintenance {name} step {Path.GetFileName(script)}" result |> ignore + if not (String.IsNullOrWhiteSpace(result.Stdout)) then printfn "%s" result.Stdout) + +let validate () = + ensureGitRepo () + let required = [ + "repo-maintenance.fsx" + "maintain-project-docs.fsx" + "repo-maintenance.just" + "lib/ProjectDocs.fsx" + "lib/DocsCoordinator.fsx" + "config/profile.json" + ] + for relative in required do + let path = Path.Combine(maintenanceRoot, relative) + if not (File.Exists(path)) then fail $"Managed repo-maintenance file is missing: {path}" + let justfile = Path.Combine(repoRoot, "justfile") + if not (File.Exists(justfile)) then fail $"Repository justfile is missing: {justfile}" + let justText = File.ReadAllText(justfile) + if not (justText.Contains("scripts/repo-maintenance/repo-maintenance.just")) then + fail "Repository justfile does not import scripts/repo-maintenance/repo-maintenance.just." + runFsxDirectory "validations" + let profile = JsonDocument.Parse(File.ReadAllText(Path.Combine(maintenanceRoot, "config", "profile.json"))).RootElement.GetProperty("profile").GetString() + if profile = "xcode-workspace" then + let components = Path.Combine(maintenanceRoot, "workspace", "validate-components.fsx") + if not (File.Exists(components)) then fail $"xcode-workspace component validator is missing: {components}" + runIn repoRoot "dotnet" [ "fsi"; components ] |> requireSuccess "xcode-workspace component validation" |> ignore + runIn repoRoot "dotnet" [ "fsi"; Path.Combine(maintenanceRoot, "maintain-project-docs.fsx"); "--project-root"; repoRoot; "--run-mode"; "check-only"; "--format"; "markdown"; "--fail-on-issues" ] + |> requireSuccess "Canonical documentation validation" + |> ignore + printfn "Repo-maintenance validation passed." + +let sync () = + ensureGitRepo () + runFsxDirectory "syncing" + validate () + printfn "Repo-maintenance shared sync and validation passed." + +let cleanWorktree (cwd: string) = + let status = runIn cwd "git" [ "status"; "--porcelain" ] |> requireSuccess "Worktree status" + if not (String.IsNullOrWhiteSpace(status)) then fail $"Release requires a clean worktree: {cwd}" + +let currentBranch (cwd: string) = runIn cwd "git" [ "branch"; "--show-current" ] |> requireSuccess "Current branch" + +let normalizeTag (value: string) = + let tag = if value.StartsWith("v") then value else "v" + value + if not (System.Text.RegularExpressions.Regex.IsMatch(tag, "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")) then + fail $"Release version must use SemVer syntax: {value}" + tag + +let optionValue (name: string) (args: string list) = + args + |> List.tryFindIndex ((=) name) + |> Option.bind (fun index -> args |> List.tryItem (index + 1)) + +let hasFlag (name: string) (args: string list) = List.contains name args + +let ensureReleaseNotes (cwd: string) (tag: string) = + let candidates = [ Path.Combine(cwd, "docs", "releases", tag + ".md"); Path.Combine(cwd, "docs", "releases", tag.TrimStart('v') + ".md") ] + candidates |> List.tryFind File.Exists |> Option.defaultWith (fun () -> fail $"Checked-in release notes are required for {tag} under docs/releases/.") + +let branchVisible (branch: string) (expected: string) = + let output = git [ "ls-remote"; "origin"; $"refs/heads/{branch}" ] |> requireSuccess "Remote branch visibility" + not (String.IsNullOrWhiteSpace(output)) && output.Split([|' '; '\t'|], StringSplitOptions.RemoveEmptyEntries).[0] = expected + +let tagVisible (cwd: string) (tag: string) (expected: string) = + let output = runIn cwd "git" [ "ls-remote"; "origin"; $"refs/tags/{tag}^{{}}" ] |> requireSuccess "Remote tag visibility" + not (String.IsNullOrWhiteSpace(output)) && output.Split([|' '; '\t'|], StringSplitOptions.RemoveEmptyEntries).[0] = expected + +let prNumber (branch: string) = + let output = gh [ "pr"; "list"; "--state"; "all"; "--head"; branch; "--base"; "main"; "--limit"; "1"; "--json"; "number" ] |> requireSuccess "Release PR lookup" + use json = JsonDocument.Parse(output) + if json.RootElement.GetArrayLength() = 0 then None else Some(json.RootElement[0].GetProperty("number").GetInt32()) + +type Gate = { Number: int; Url: string; State: string; Head: string; Sha: string; Phase: string; Comments: int } + +let inspectGate number = + let pr = gh [ "pr"; "view"; string number; "--json"; "url,state,headRefName,headRefOid,reviewDecision,comments,reviews" ] |> requireSuccess "Release PR inspection" + use data = JsonDocument.Parse(pr) + let root = data.RootElement + let checksResult = gh [ "pr"; "checks"; string number; "--json"; "name,bucket" ] + let checks = + if String.IsNullOrWhiteSpace(checksResult.Stdout) then [] + else + use parsed = JsonDocument.Parse(checksResult.Stdout) + parsed.RootElement.EnumerateArray() + |> Seq.map (fun item -> item.GetProperty("name").GetString(), item.GetProperty("bucket").GetString()) + |> Seq.toList + let state = root.GetProperty("state").GetString() + let review = root.GetProperty("reviewDecision").GetString() + let comments = root.GetProperty("comments").GetArrayLength() + (root.GetProperty("reviews").EnumerateArray() |> Seq.filter (fun item -> item.GetProperty("state").GetString() = "COMMENTED") |> Seq.length) + let names = checks |> List.map fst |> Set.ofList + let buckets = checks |> List.map snd |> Set.ofList + let phase = + if state = "MERGED" then "merged" + elif state <> "OPEN" then "closed" + elif List.isEmpty checks || not (names.Contains("validate")) then "awaiting-required-checks" + elif buckets.Contains("fail") || buckets.Contains("cancel") then "failed-checks" + elif buckets.Contains("pending") then "awaiting-pr-checks" + elif review = "CHANGES_REQUESTED" then "changes-requested" + elif comments > 0 then "comments-require-review" + else "ready-to-advance" + { Number = number; Url = root.GetProperty("url").GetString(); State = state; Head = root.GetProperty("headRefName").GetString(); Sha = root.GetProperty("headRefOid").GetString(); Phase = phase; Comments = comments } + +let continuation tag gate = + let repository = gh [ "repo"; "view"; "--json"; "nameWithOwner"; "--jq"; ".nameWithOwner" ] |> requireSuccess "Repository identity" + let payload = {| schema = "repo-maintenance-continuation/v1"; operation = "standard-release"; repository = repository; releaseTag = tag; branch = gate.Head; headCommit = gate.Sha; prNumber = gate.Number; phase = gate.Phase; minimumDelayMinutes = 5; resumeCommand = $"just repo-release-inspect {tag}"; advanceCommand = $"just repo-release-advance {tag}" |} + printfn "%s" (JsonSerializer.Serialize(payload)) + +let findMainWorktree () = + let output = git [ "worktree"; "list"; "--porcelain" ] |> requireSuccess "Worktree inventory" + let mutable path: string option = None + let mutable found: string option = None + for line in output.Split('\n') do + if line.StartsWith("worktree ") then path <- Some(line.Substring(9)) + elif line = "branch refs/heads/main" then found <- path + found |> Option.defaultWith (fun () -> fail "No clean worktree owns local main.") + +let accountBranches (mainRoot: string) (supplied: string list) = + let allowed = Set.ofList [ "preserved"; "in-progress"; "archived"; "merged"; "safe-to-delete" ] + let parsed = + supplied + |> List.map (fun value -> + let parts = value.Split('=', 2) + if parts.Length <> 2 || not (allowed.Contains(parts[1])) then fail $"Invalid branch accounting: {value}" + parts[0], parts[1]) + |> Map.ofList + let branches = + runIn mainRoot "git" [ "branch"; "--no-merged"; "main"; "--format=%(refname:short)" ] + |> requireSuccess "Unmerged branch inventory" + |> fun output -> output.Split('\n', StringSplitOptions.RemoveEmptyEntries) |> Array.filter ((<>) "main") |> Array.toList + let missing = branches |> List.filter (fun branch -> not (parsed.ContainsKey(branch))) + if not (List.isEmpty missing) then + let rendered = String.concat ", " missing + fail $"Branch accounting is incomplete for: {rendered}" + branches |> List.map (fun branch -> branch, parsed[branch]) + +let releasePrepare (tag: string) (args: string list) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + if String.IsNullOrWhiteSpace(branch) || branch = "main" then fail "Release prepare must run from a named feature branch, not main." + ensureReleaseNotes repoRoot tag |> ignore + validate () + let versionScript = Path.Combine(maintenanceRoot, "version-bump.fsx") + if not (hasFlag "--skip-version-bump" args) then + if not (File.Exists(versionScript)) then fail $"Version bump script is required: {versionScript}" + let result = runIn repoRoot "dotnet" [ "fsi"; versionScript; tag.TrimStart('v') ] + requireSuccess "Version bump" result |> ignore + let status = git [ "status"; "--porcelain" ] |> requireSuccess "Version bump status" + if String.IsNullOrWhiteSpace(status) then fail "Version bump completed without changing files." + git [ "add"; "-A" ] |> requireSuccess "Stage version bump" |> ignore + git [ "commit"; "-m"; $"release: bump versions for {tag}" ] |> requireSuccess "Commit version bump" |> ignore + cleanWorktree repoRoot + git [ "push"; "-u"; "origin"; branch ] |> requireSuccess "Push release branch" |> ignore + let head = git [ "rev-parse"; "HEAD" ] |> requireSuccess "Release head" + if not (branchVisible branch head) then + let gate = { Number = 0; Url = ""; State = "OPEN"; Head = branch; Sha = head; Phase = "awaiting-branch-visibility"; Comments = 0 } + continuation tag gate + else + let number = + match prNumber branch with + | Some existing -> existing + | None -> + gh [ "pr"; "create"; "--base"; "main"; "--head"; branch; "--title"; $"release: prepare {tag}"; "--body"; $"Prepare {tag} through the canonical repository-maintenance workflow." ] |> requireSuccess "Create release PR" |> ignore + prNumber branch |> Option.defaultWith (fun () -> fail "GitHub did not return the created release PR.") + let gate = inspectGate number + printfn "PR #%d: %s (%s)" gate.Number gate.Phase gate.Url + continuation tag gate + +let releaseInspect (tag: string) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + let number = prNumber branch |> Option.defaultWith (fun () -> fail $"No release PR exists for {branch}.") + let gate = inspectGate number + printfn "PR #%d: %s (%s)" gate.Number gate.Phase gate.Url + continuation tag gate + +let releaseAdvance (tag: string) (args: string list) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + let number = prNumber branch |> Option.defaultWith (fun () -> fail $"No release PR exists for {branch}.") + let gate = inspectGate number + let head = git [ "rev-parse"; "HEAD" ] |> requireSuccess "Release head" + if gate.Head <> branch || gate.Sha <> head then fail "Release PR branch or commit identity changed; inspect before advancing." + if gate.Phase <> "ready-to-advance" && not (gate.Phase = "comments-require-review" && hasFlag "--review-comments-addressed" args) then + continuation tag gate + fail $"Release PR #{number} is not ready to advance: {gate.Phase}." + gh [ "pr"; "merge"; string number; "--merge"; "--delete-branch" ] |> requireSuccess "Merge release PR" |> ignore + let mainRoot = findMainWorktree () + cleanWorktree mainRoot + runIn mainRoot "git" [ "fetch"; "origin"; "main"; "--prune" ] |> requireSuccess "Fetch main" |> ignore + runIn mainRoot "git" [ "pull"; "--ff-only"; "origin"; "main" ] |> requireSuccess "Fast-forward main" |> ignore + let mainHead = runIn mainRoot "git" [ "rev-parse"; "HEAD" ] |> requireSuccess "Reviewed main head" + let accountingValues = + args |> List.mapi (fun index value -> index, value) |> List.choose (fun (index, value) -> if value = "--branch-accounting" then args |> List.tryItem(index + 1) else None) + let accounting = accountBranches mainRoot accountingValues + ensureReleaseNotes mainRoot tag |> ignore + let existingTag = runIn mainRoot "git" [ "rev-parse"; "-q"; "--verify"; $"refs/tags/{tag}" ] + if existingTag.ExitCode <> 0 then runIn mainRoot "git" [ "tag"; "-a"; tag; "-m"; $"Release {tag}" ] |> requireSuccess "Create release tag" |> ignore + runIn mainRoot "git" [ "push"; "origin"; tag ] |> requireSuccess "Push release tag" |> ignore + if not (tagVisible mainRoot tag mainHead) then fail $"Remote tag {tag} is not visible at reviewed main {mainHead}." + let releaseView = runIn mainRoot "gh" [ "release"; "view"; tag; "--json"; "tagName,isPrerelease,url" ] + if releaseView.ExitCode <> 0 then + let notes = ensureReleaseNotes mainRoot tag + let createArgs = [ "release"; "create"; tag; "--verify-tag"; "--title"; tag; "--notes-file"; notes ] @ (if tag.Contains("-") then [ "--prerelease" ] else []) + runIn mainRoot "gh" createArgs |> requireSuccess "Create GitHub release" |> ignore + printfn "Branch accounting:" + if List.isEmpty accounting then printfn "- No local branches remain outside main." + else for branchName, status in accounting do printfn "- %s: %s" branchName status + printfn "Release %s completed from %s." tag mainHead + +let release (operation: string) (args: string list) = + let tag = optionValue "--version" args |> Option.defaultWith (fun () -> fail "Pass --version vX.Y.Z.") |> normalizeTag + match operation with + | "prepare" -> releasePrepare tag args + | "inspect" -> releaseInspect tag + | "advance" -> releaseAdvance tag args + | _ -> fail $"Unsupported release operation: {operation}" + +let main argv = + match List.ofArray argv with + | [ "validate" ] -> validate (); 0 + | [ "sync" ] -> sync (); 0 + | "release" :: operation :: args -> release operation args; 0 + | _ -> fail "Usage: repo-maintenance.fsx validate|sync|release prepare|inspect|advance --version vX.Y.Z" + +try fsi.CommandLineArgs |> Array.skip 1 |> main |> exit +with error -> eprintfn "ERROR: %s" error.Message; exit 1 diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just new file mode 100644 index 000000000..03a71ab34 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just @@ -0,0 +1,20 @@ +docs-check: + dotnet fsi scripts/repo-maintenance/maintain-project-docs.fsx --project-root . --run-mode check-only --format markdown --fail-on-issues + +docs-apply: + dotnet fsi scripts/repo-maintenance/maintain-project-docs.fsx --project-root . --run-mode apply --format markdown --fail-on-issues + +repo-validate: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx validate + +repo-sync: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx sync + +repo-release-prepare version *args: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release prepare --version {{ quote(version) }} {{ args }} + +repo-release-inspect version: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release inspect --version {{ quote(version) }} + +repo-release-advance version *args: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release advance --version {{ quote(version) }} {{ args }} diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh deleted file mode 100755 index 5a00c94aa..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" -. "$SELF_DIR/lib/common.sh" - -load_profile_env -ensure_git_repo -log "Running repo-maintenance shared sync from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." -run_dispatch_dir "$SELF_DIR/syncing" "sync" -log "Repo-maintenance shared sync completed successfully." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md index 66ff612c3..e04abac9f 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md +++ b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md @@ -1,31 +1,5 @@ -# Repo-Maintenance Syncing Steps +# Repository Synchronization Hooks -Small helper surface for deterministic repo-maintenance sync hooks. - -## Overview - -This directory holds repo-specific shell hooks that the shared repo-maintenance sync entrypoint can discover and run. - -### Motivation - -It exists so a repository can keep local sync follow-up steps in one predictable place without forking the shared sync entrypoint itself. - -## Setup - -Add repo-specific executable `.sh` files here only when the repository needs deterministic shared-sync follow-up steps. - -## Usage - -The top-level `scripts/repo-maintenance/sync-shared.sh` entrypoint discovers and runs every `*.sh` file in this directory in lexical order. - -## Development - -Keep each hook small, deterministic, and specific to the owning repository's guidance or packaging sync needs. - -## Verification - -Run the owning repository's shared sync entrypoint and confirm the expected repo-specific hooks execute in lexical order. - -## License - -Covered by the parent repository license. +Place only root-owned `.fsx` synchronization hooks here. `just repo-sync` +discovers them in lexical order, runs every hook, and then validates the full +repository. Keep hooks deterministic and non-interactive. diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh deleted file mode 100755 index fdc434748..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" -. "$SELF_DIR/lib/common.sh" - -load_profile_env -load_env_file "$SELF_DIR/config/validation.env" -ensure_git_repo -log "Running repo-maintenance validation from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." -run_dispatch_dir "$SELF_DIR/validations" "validation" -if [ "$REPO_MAINTENANCE_PROFILE" = "xcode-workspace" ]; then - "$SELF_DIR/workspace/validate-components.sh" -fi -log "Repo-maintenance validation completed successfully." diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh deleted file mode 100755 index 7103b2036..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -for required in \ - "$REPO_MAINTENANCE_ROOT/validate-all.sh" \ - "$REPO_MAINTENANCE_ROOT/sync-shared.sh" \ - "$REPO_MAINTENANCE_ROOT/release.sh" \ - "$REPO_MAINTENANCE_ROOT/lib/common.sh" \ - "$REPO_MAINTENANCE_ROOT/config/profile.env" -do - [ -f "$required" ] || die "maintain-project-repo is missing the required file $required." -done diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh deleted file mode 100755 index 2f775a7d1..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -if [ "${REPO_MAINTENANCE_REQUIRE_AGENTS:-true}" != "true" ]; then - log "Skipping AGENTS.md validation because REPO_MAINTENANCE_REQUIRE_AGENTS is disabled." - exit 0 -fi - -agents_path="$REPO_ROOT/AGENTS.md" -[ -f "$agents_path" ] || die "Expected $agents_path to exist so maintain-project-repo has repo guidance to complement." -[ -s "$agents_path" ] || die "Expected $agents_path to be non-empty." - -for needle in \ - "scripts/repo-maintenance/validate-all.sh" \ - "scripts/repo-maintenance/sync-shared.sh" \ - "scripts/repo-maintenance/release.sh" -do - grep -F "$needle" "$agents_path" >/dev/null 2>&1 || die "Expected $agents_path to mention $needle so the maintainer validation, sync, and release entrypoints stay discoverable." -done diff --git a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh b/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh deleted file mode 100755 index e6815be15..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -workflow_path="$REPO_ROOT/.github/workflows/validate-repo-maintenance.yml" - -if [ ! -f "$workflow_path" ]; then - log "Skipping CI wrapper validation because $workflow_path is not present." - exit 0 -fi - -grep -Fq "scripts/repo-maintenance/validate-all.sh" "$workflow_path" || die "Expected $workflow_path to call scripts/repo-maintenance/validate-all.sh." diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/automation-prompts.md b/plugins/repository-skills/skills/maintain-project-repo/references/automation-prompts.md index 2b095be28..0139fd58d 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/references/automation-prompts.md +++ b/plugins/repository-skills/skills/maintain-project-repo/references/automation-prompts.md @@ -1,12 +1,13 @@ -# Repo Maintenance Toolkit Automation Prompts +# Repository Maintenance Prompts -- Install `maintain-project-repo` into ``, keep the GitHub workflow wrapper enabled, and create or normalize README.md, CONTRIBUTING.md, AGENTS.md, and ROADMAP.md in the same operation. -- Refresh `maintain-project-repo` in `` without deleting repo-specific custom scripts, then refresh the four canonical project documents through their owner workflows. -- Report what `maintain-project-repo` would install and which documentation findings remain in `` without mutating files. -- Explain when to use `scripts/repo-maintenance/validate-all.sh`, `scripts/repo-maintenance/sync-shared.sh`, and `scripts/repo-maintenance/release.sh`. -- Explain that standard release mode runs from a feature branch or worktree, opens a PR against protected `main`, takes bounded CI/review snapshots through `prepare`, `inspect`, and `advance`, gates on PR comments, merges, fast-forwards local `main`, creates and pushes the tag from that reviewed `main`, creates the GitHub release, accounts for every local branch not contained by `main`, and only then cleans up branches that are proven safe to delete. -- Explain that branch cleanup is gated by commit reachability: do not call work on `main`, merged, recovered, preserved, or safe to clean up until the exact local repository and remote prove it, and do not delete local branches, remote branches, worktrees, archive refs, or temporary rescue refs until any non-base history is merged or explicitly archived. -- Explain that standard release mode uses bounded `prepare`, `inspect`, and `advance` operations. For a remote gate, consume its continuation packet and reuse a live matching host-native wakeup while the gate remains pending and healthy; do not delete/recreate it after an unchanged snapshot. Create/update only after it fires or is stale, and pause/delete it when resolved, failed, cancelled, or identity-drifted. Every scheduled interval is at least five minutes. Run `inspect` first, and run `advance` only when packet identities still match. Codex uses heartbeat; Hermes uses a continuable `cronjob` with `deliver="origin"` and `attach_to_session=true`. -- Explain that every pending status context is a wait state, not a clean merge signal. Codex should wake, inspect checks, reviews, and comments, address valid findings, and merge only after every required check and the review/comment gate are clear. -- Explain that GitHub release creation uses checked-in `docs/releases/vX.Y.Z.md` notes first, then `docs/releases/X.Y.Z.md`; it logs a clear fallback to GitHub-generated notes only when neither file exists. -- Explain that protected branches should require the GitHub Actions check context `validate` for the managed repo-maintenance workflow. +- Install or refresh the fixed FSX repository-maintenance assets, then run the + complete documentation transaction. +- Report the managed-file and four-document result without mutation. +- Run `just repo-validate` for local or CI validation. +- Run `just repo-sync` for all deterministic shared-asset synchronization. +- Use the three `just repo-release-*` recipes for an explicit protected-main + release and never poll a remote gate. +- Require branch reachability and accounting evidence before cleanup. + +Never request direct script execution, Python, shell, per-file documentation +commands, or project-local schemas. diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/customization-flow.md b/plugins/repository-skills/skills/maintain-project-repo/references/customization-flow.md deleted file mode 100644 index 04e370fd6..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/references/customization-flow.md +++ /dev/null @@ -1,31 +0,0 @@ -# Repo Maintenance Toolkit Customization Contract - -## Purpose - -Record lightweight default preferences for `maintain-project-repo` without turning its managed file set into a wide runtime customization surface. - -## Knobs - -| Knob | Default | Status | Effect | -| --- | --- | --- | --- | -| `defaultReleaseMode` | `standard` | `policy-only` | Sets the default planning posture when the user asks for a release flow without saying whether the repo is standalone or a submodule. Standard mode assumes releases run from a branch or worktree into protected `main`. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/install_maintain_project_repo.py` and `scripts/run_workflow.py` do not currently read these customization knobs. -- The managed file set, GitHub workflow wrapper, and release script surfaces are fixed workflow behavior rather than durable runtime customization. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected references to reflect the approved default-policy change. -3. Persist the metadata change with `scripts/customization_config.py apply --input `. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify the workflow references still describe the same install and release behavior. - -## Validation - -1. Verify `references/repo-maintenance-layout.md` still matches the managed asset tree. -2. Verify `references/release-modes.md` still matches `assets/repo-maintenance/release.sh`. -3. Verify every customization knob is described consistently across `SKILL.md`, this file, and `references/automation-prompts.md`. diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/customization.template.yaml b/plugins/repository-skills/skills/maintain-project-repo/references/customization.template.yaml deleted file mode 100644 index ed649883a..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/references/customization.template.yaml +++ /dev/null @@ -1,4 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - defaultReleaseMode: "standard" diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/pre-commit-vs-ci.md b/plugins/repository-skills/skills/maintain-project-repo/references/pre-commit-vs-ci.md index 02c068974..942c6fb39 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/references/pre-commit-vs-ci.md +++ b/plugins/repository-skills/skills/maintain-project-repo/references/pre-commit-vs-ci.md @@ -1,16 +1,9 @@ -# Pre-Commit vs CI +# Local Validation and CI -Use `maintain-project-repo` with a local-first split: +`just repo-validate` is the complete validation entrypoint for people, agents, +and CI. The managed GitHub workflow only installs the required runtime and +invokes that recipe. Repository-specific checks belong in root-owned +`scripts/repo-maintenance/validations/*.fsx` hooks. -- `scripts/repo-maintenance/validate-all.sh` - - the full local validation command - - the same command CI should call -- `.github/workflows/validate-repo-maintenance.yml` - - a thin wrapper that calls the local script - - install SwiftFormat and SwiftLint before validation so Apple-profile checks are reproducible on fresh macOS runners - - keep workflow logic limited to runner bootstrap and the local validation call -- `scripts/repo-maintenance/hooks/pre-commit.sample` - - an opt-in sample for cheap local checks - - do not turn it into the only validation surface - -Keep expensive or repo-shaping logic in the repo-owned scripts, not in GitHub workflow YAML. +Do not install pre-commit automation, duplicate checks in workflow YAML, or +introduce another command surface. diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md b/plugins/repository-skills/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md index 9d4545d1b..ed83f2ec1 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md +++ b/plugins/repository-skills/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md @@ -1,30 +1,14 @@ -# Project Docs Maintenance Automation Prompts +# Project Documentation Prompts -Use these prompts when scheduling or delegating the documentation phase owned by -`maintain-project-repo`. +## Audit -## Check-Only Sweep +Run `just docs-check`. Report findings for README, CONTRIBUTING, AGENTS, and +ROADMAP in that order, including responsibility drift. Do not mutate files. -Run `maintain-project-repo --operation report-only` for the target repository. -Report the planned toolkit actions and audit README, CONTRIBUTING, AGENTS, and -ROADMAP in that order. Include owner-skill findings, cross-document -responsibility drift, and stale command evidence. Do not edit files, commit, -push, or open a pull request. +## Apply -## Bounded Apply Sweep +Run `just docs-apply`. Apply the planned four-document transaction atomically, +verify the result, and require a second apply to be byte-identical. -Run `maintain-project-repo --operation refresh` after the operator approves the -repository refresh. Let each owner document workflow edit only its own target -file. Report remaining cross-document issues separately from fixes already -applied. Do not move content across files unless the operator explicitly -requested that cleanup. - -## Subagent Discovery - -When the repository is large, ask subagents for read-only findings before the main thread edits: - -- one worker checks README and contributor docs for stale commands -- one worker checks AGENTS and nested guidance for routing or policy drift -- one worker checks ROADMAP and issue state for small-ticket candidates - -Require file references and concise evidence from each worker. The main thread owns the final edits and validation. +These are the only supported documentation operations. Never split work by +file or add a project-specific schema, status vocabulary, or fix policy. diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/release-modes.md b/plugins/repository-skills/skills/maintain-project-repo/references/release-modes.md index 46646bbc9..48e77efde 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/references/release-modes.md +++ b/plugins/repository-skills/skills/maintain-project-repo/references/release-modes.md @@ -1,68 +1,20 @@ -# Release Modes +# Release Workflow -Use these modes only when the current task is actually a release, publish, merge, tag, or protected-main release preparation task. They are not the default completion path for ordinary questions, investigations, local edits, documentation maintenance, or targeted validation. +The managed runtime supports one standard protected-main release workflow. +Run it only for an explicit release task and only through: -## `standard` - -Use this mode for an ordinary standalone repository whose release line is a protected `main` branch. - -Run it from a feature branch or worktree. Do not run standard release mode from `main`; the script treats `main` as the protected integration branch that receives the release through a pull request. - -- run `--operation prepare` for local validation, the version bump, branch push, PR creation, one remote snapshot, and a continuation packet -- require committed changes and a clean worktree -- run the repo-specific version bump hook at the selected profile root: - `scripts/repo-maintenance/version-bump.sh` for every profile -- commit the version bump as `release: bump versions for vX.Y.Z` -- push the branch -- perform one immediate branch-visibility re-read; if it is not visible, emit a continuation packet instead of polling -- open or update a pull request against `main` -- use `--operation inspect` for one PR/check/review snapshot; it emits a continuation packet for unknown or pending remote state -- create one host-native continuation no sooner than five minutes later, then reuse that same matching scheduler item while the gate stays pending and healthy; do not delete/recreate it after an unchanged snapshot. Codex uses heartbeat, Hermes uses an updated continuable `cronjob` with `deliver="origin"` and `attach_to_session=true` -- on wakeup run `inspect` first, then use `--operation advance` only if the packet's branch, commit, PR, and tag identities still match -- stop with a clear message if any required check fails or remains pending, changes are requested, or unresolved comments remain -- stop on requested changes or comments so the maintainer can address valid concerns, add out-of-scope concerns to `ROADMAP.md`, resolve the threads, push, and rerun the same script -- merge the PR with a merge commit once CI is green and the comment pass is clear -- fast-forward local `main` from `origin/main` -- create the annotated release tag locally from the reviewed local `main` -- push the tag -- perform one immediate tag-visibility re-read; if it is not visible, emit a continuation packet instead of polling -- create the GitHub release unless skipped, preferring `docs/releases/vX.Y.Z.md` and then `docs/releases/X.Y.Z.md` as its checked-in body; when neither exists, log the fallback and use GitHub-generated notes. Pass `--prerelease` for SemVer prerelease tags such as `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`, or preview-style suffixes -- perform one immediate GitHub-release re-read; if it is not readable, emit a continuation packet instead of polling -- verify the GitHub release object's prerelease metadata matches the release tag before calling release publication complete -- verify `git log origin/main..main` or the repository's equivalent base/remote comparison is empty before claiming the local base branch is synchronized -- enumerate every local branch still not contained by `main` and account for each branch as already preserved elsewhere, intentionally still in progress, newly archived, newly merged, or safe to delete -- prune stale remote tracking refs and delete only local branches already merged into `main` after branch accounting proves they are safe - -Treat branch accounting as a hard completion gate, not optional cleanup. Use `git branch --no-merged ` or the repository's equivalent branch inventory before cleanup, and do not say a release, publish, merge, or cleanup step is done until every local branch not contained by the local base branch has been accounted for. Do not say work is on `main`, merged, recovered, preserved, or safe to clean up until commit reachability has been verified in the exact local repository and remote that statement refers to. Do not delete local branches, remote branches, worktrees, archive refs, or temporary rescue refs until branch accounting is complete and any non-base history is either merged or preserved on an explicit archive ref. - -Example: - -```bash -bash scripts/repo-maintenance/release.sh --mode standard --version v1.2.0 --operation prepare +```text +just repo-release-prepare +just repo-release-inspect +just repo-release-advance ``` -When a release intentionally has no repo version surfaces, pass `--skip-version-bump`. When the PR comment pass has already been handled and only historical comments remain visible through GitHub, rerun with `--review-comments-addressed`. +Prepare validates, runs the optional root-owned `version-bump.fsx`, checks +release notes, commits, pushes, and opens or updates the release PR. Inspect +takes one bounded snapshot of identity, CI, reviews, and comments. Advance +rechecks the snapshot, merges only when every gate is clear, updates the owning +main worktree, tags, pushes, publishes, and performs branch accounting. -Remote waiting is never a release-script operation. `prepare`, `inspect`, and `advance` each take one bounded snapshot and either make an immediate safe transition or emit a continuation packet. Agents create one host-native wakeup no sooner than five minutes later, reuse that same matching scheduler item while the gate remains pending and healthy, and pause/delete it only on resolution, failure, cancellation, or identity drift. Create/update a replacement only after the prior item fires or becomes stale. Resume with `inspect`, and use `advance` only after packet identities match. Do not use shell `sleep`, `gh pr checks --watch`, timer loops, or one-to-four-minute rechecks. - -## `submodule` - -Use this mode when the current repository is checked out as a git submodule inside a larger parent repository: - -- run local validation first -- require a clean worktree -- require an actual superproject relationship -- create the release tag locally -- push the branch and tag in the submodule repository -- perform one immediate branch and tag visibility re-read; if either is absent, create or reuse the matching host-native continuation no sooner than five minutes later, then inspect before another release action rather than polling -- create the GitHub release when `gh` is available, passing `--prerelease` for SemVer prerelease tags such as `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`, or preview-style suffixes -- perform one immediate GitHub release re-read after creation; if it is absent, create or reuse the matching host-native continuation no sooner than five minutes later, then inspect before another release action rather than polling -- verify the GitHub release object's prerelease metadata matches the release tag before calling release publication complete -- verify the submodule branch and tag are visible on the intended remote before calling that work preserved or released -- leave the parent-repo pointer update as a separate explicit follow-up step - -Example: - -```bash -bash scripts/repo-maintenance/release.sh --mode submodule --version v1.2.0 -``` +There is no polling mode. Pending remote state produces a continuation packet. +Never delete branches, worktrees, tags, or refs until reachability and branch +accounting prove the action safe. diff --git a/plugins/repository-skills/skills/maintain-project-repo/references/repo-maintenance-layout.md b/plugins/repository-skills/skills/maintain-project-repo/references/repo-maintenance-layout.md index 6563759db..c9788e107 100644 --- a/plugins/repository-skills/skills/maintain-project-repo/references/repo-maintenance-layout.md +++ b/plugins/repository-skills/skills/maintain-project-repo/references/repo-maintenance-layout.md @@ -1,44 +1,23 @@ # Repo Maintenance Layout -The managed target layout is: +The installer owns one fixed runtime under `scripts/repo-maintenance/`: ```text -scripts/ - repo-maintenance/ - validate-all.sh - sync-shared.sh - release.sh - version-bump.sh (optional repo-specific hook) - lib/ - common.sh - validations/ - 10-toolkit-layout.sh - 20-agents-guidance.sh - 30-ci-wrapper.sh - syncing/ - release/ - 10-preflight.sh - 20-tag-release.sh - 30-push-release.sh - 40-github-release.sh - config/ - validation.env - release.env - hooks/ - pre-commit.sample -.github/ - workflows/ - validate-repo-maintenance.yml -.swiftformat (Apple profiles) -.swiftlint.yml (Apple profiles) +maintain-project-docs.fsx +repo-maintenance.fsx +repo-maintenance.just +managed-assets.json +config/profile.json +docs/ +validations/*.fsx +syncing/*.fsx +version-bump.fsx (optional repo-owned release hook) ``` -## Design Rules +The root `justfile` imports `repo-maintenance.just`. Operators use `just`; the +runtime discovers root-owned `.fsx` hooks lexically. Managed files refresh in +place, while files outside the manifest remain repo-owned. -- Top-level scripts are stable entrypoints. -- Ordered `validations/*.sh`, `syncing/*.sh`, and `release/*.sh` are discovered automatically. -- Managed files are safe to refresh in place. -- Repo-specific extra scripts are allowed as long as they do not reuse the managed filenames. -- Apple profiles install `.swiftformat` and `.swiftlint.yml` samples together; SwiftFormat remains the formatting authority and SwiftLint stays scoped to complementary non-formatting checks. -- Standard release mode uses the optional repo-specific `version-bump.sh` hook when it exists and requires either that hook or an explicit `--skip-version-bump` decision. -- The managed GitHub workflow exposes `validate` as the required branch-protection check context. Do not configure protected branches to require the display-style string `Validate Repo Maintenance / validate`. +The only documentation recipes are `docs-check` and `docs-apply`. Both process +all four documents. Do not add Python, shell, per-document recipes, nested +tests, configuration schemas, or duplicate workflow implementations. diff --git a/plugins/repository-skills/skills/maintain-project-repo/scripts/customization_config.py b/plugins/repository-skills/skills/maintain-project-repo/scripts/customization_config.py deleted file mode 100755 index e932fb806..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "maintain-project-repo" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/repository-skills/skills/maintain-project-repo/scripts/install_maintain_project_repo.py b/plugins/repository-skills/skills/maintain-project-repo/scripts/install_maintain_project_repo.py deleted file mode 100755 index 06010265a..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/scripts/install_maintain_project_repo.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Install or refresh the managed maintain-project-repo files.""" - -from __future__ import annotations - -import argparse -import json -import shutil -from pathlib import Path - -PROFILE_CHOICES = { - "generic": "Generic repo-maintenance baseline with no Swift or Xcode specialization.", - "xcode-workspace": "Canonical Swift workspace repo-maintenance profile for Apps, Packages, and Services roots.", -} -PROFILE_TOOLKIT_ROOTS = { - "generic": Path("scripts/repo-maintenance"), - "xcode-workspace": Path("scripts/repo-maintenance"), -} -PROFILE_OVERLAY_FILES = { - "xcode-workspace": [ - ("profiles/apple/repo-maintenance/.swiftformat", ".swiftformat"), - ("profiles/apple/repo-maintenance/.swiftlint.yml", ".swiftlint.yml"), - ( - "profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh", - "scripts/repo-maintenance/validations/40-xcode-workspace-layout.sh", - ), - ( - "profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh", - "scripts/repo-maintenance/workspace/validate-components.sh", - ), - ], -} -PROFILE_WORKFLOW_FILES = { - "xcode-workspace": "profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml", -} -MANAGED_TOOLKIT_FILES = [ - ("repo-maintenance/validate-all.sh", "scripts/repo-maintenance/validate-all.sh"), - ("repo-maintenance/sync-shared.sh", "scripts/repo-maintenance/sync-shared.sh"), - ("repo-maintenance/release.sh", "scripts/repo-maintenance/release.sh"), - ("repo-maintenance/lib/common.sh", "scripts/repo-maintenance/lib/common.sh"), - ("repo-maintenance/validations/10-toolkit-layout.sh", "scripts/repo-maintenance/validations/10-toolkit-layout.sh"), - ("repo-maintenance/validations/20-agents-guidance.sh", "scripts/repo-maintenance/validations/20-agents-guidance.sh"), - ("repo-maintenance/validations/30-ci-wrapper.sh", "scripts/repo-maintenance/validations/30-ci-wrapper.sh"), - ("repo-maintenance/syncing/README.md", "scripts/repo-maintenance/syncing/README.md"), - ("repo-maintenance/release/10-preflight.sh", "scripts/repo-maintenance/release/10-preflight.sh"), - ("repo-maintenance/release/20-tag-release.sh", "scripts/repo-maintenance/release/20-tag-release.sh"), - ("repo-maintenance/release/30-push-release.sh", "scripts/repo-maintenance/release/30-push-release.sh"), - ("repo-maintenance/release/40-github-release.sh", "scripts/repo-maintenance/release/40-github-release.sh"), - ("repo-maintenance/config/validation.env", "scripts/repo-maintenance/config/validation.env"), - ("repo-maintenance/config/release.env", "scripts/repo-maintenance/config/release.env"), - ("repo-maintenance/hooks/pre-commit.sample", "scripts/repo-maintenance/hooks/pre-commit.sample"), -] -MANAGED_WORKFLOW_FILE = ".github/workflows/validate-repo-maintenance.yml" -DEFAULT_TOOLKIT_ROOT = Path("scripts/repo-maintenance") -LEGACY_XCODE_TOOLKIT_ROOT = Path("Scripts/repo-maintenance") -EXECUTABLE_SUFFIXES = {".sh", ".py", ".sample"} - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root", required=True) - parser.add_argument("--operation", choices=("install", "refresh", "report-only"), default="install") - parser.add_argument("--profile", choices=sorted(PROFILE_CHOICES), default="generic") - parser.add_argument("--skip-github-workflow", action="store_true") - parser.add_argument("--dry-run", action="store_true") - return parser - - -def assets_root() -> Path: - return Path(__file__).resolve().parents[1] / "assets" - - -def toolkit_root(profile: str) -> Path: - return PROFILE_TOOLKIT_ROOTS[profile] - - -def profile_file(profile: str) -> Path: - return toolkit_root(profile) / "config/profile.env" - - -def profile_target_path(profile: str, target_relative: str) -> Path: - target = Path(target_relative) - try: - suffix = target.relative_to(DEFAULT_TOOLKIT_ROOT) - except ValueError: - return target - return toolkit_root(profile) / suffix - - -def target_pairs(profile: str, skip_github_workflow: bool) -> list[tuple[Path, Path]]: - root = assets_root() - pairs: list[tuple[Path, Path]] = [] - - def add_pair(source_relative: str, target_relative: str) -> None: - target = profile_target_path(profile, target_relative) - for index, (_, existing_target) in enumerate(pairs): - if existing_target == target: - pairs[index] = (root / source_relative, target) - return - pairs.append((root / source_relative, target)) - - for source_relative, target_relative in MANAGED_TOOLKIT_FILES: - if profile == "xcode-workspace" and source_relative == "repo-maintenance/hooks/pre-commit.sample": - continue - add_pair(source_relative, target_relative) - for source_relative, target_relative in PROFILE_OVERLAY_FILES.get(profile, []): - add_pair(source_relative, target_relative) - if not skip_github_workflow: - workflow_source = PROFILE_WORKFLOW_FILES.get( - profile, - "github/repo-maintenance-workflows/validate-repo-maintenance.yml", - ) - pairs.append( - ( - root / workflow_source, - Path(MANAGED_WORKFLOW_FILE), - ) - ) - return pairs - - -def ensure_safe_target(repo_root: Path, relative_target: Path) -> None: - target = repo_root / relative_target - if target.exists() and not target.is_file(): - raise RuntimeError( - f"The managed target path {target} exists but is not a regular file." - ) - - -def xcode_workspace_findings(repo_root: Path) -> list[str]: - findings: list[str] = [] - workspaces = list(repo_root.glob("*.xcworkspace")) - if len(workspaces) != 1: - findings.append(f"expected exactly one root .xcworkspace, found {len(workspaces)}") - - apps_root = repo_root / "Apps" - if not apps_root.is_dir(): - findings.append("expected Apps/ at the repository root") - - if not (repo_root / "project.yml").is_file(): - findings.append("expected root project.yml") - elif not any(repo_root.glob("*.xcodeproj")): - findings.append("expected one generated root .xcodeproj") - - packages_root = repo_root / "Packages" - if not packages_root.is_dir(): - findings.append("expected Packages/ at the repository root") - services_root = repo_root / "Services" - if not services_root.is_dir(): - findings.append("expected Services/ at the repository root") - - component_found = ( - any(apps_root.glob("**/target.y*ml")) - or any(packages_root.glob("**/Package.swift")) - or any(services_root.glob("**/Package.swift")) - ) - if not component_found: - findings.append("expected at least one component under Apps/, Packages/, or Services/") - return findings - - -def ensure_profile_shape(repo_root: Path, profile: str) -> None: - if profile != "xcode-workspace": - return - findings = xcode_workspace_findings(repo_root) - if findings: - raise RuntimeError( - "The xcode-workspace profile requires a canonical Swift product workspace: " - + "; ".join(findings) - + ". Create or align the product through bootstrap-xcode-workspace." - ) - - -def legacy_xcode_toolkit_migration(repo_root: Path, profile: str) -> tuple[Path, Path] | None: - if profile != "xcode-workspace": - return None - - legacy_parent = next((path for path in repo_root.iterdir() if path.name == "Scripts"), None) - if legacy_parent is None: - return None - - legacy_root = legacy_parent / "repo-maintenance" - desired_root = repo_root / toolkit_root(profile) - if not legacy_root.exists(): - return None - - if not legacy_root.is_dir(): - raise RuntimeError( - f"The {profile} profile expects repo-maintenance under " - f"{toolkit_root(profile).as_posix()}, but the legacy path " - f"{LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} exists and is not a directory." - ) - - if desired_root.exists(): - try: - if legacy_root.samefile(desired_root): - temporary_parent = repo_root / ".maintain-project-repo-scripts-case-migration" - if temporary_parent.exists(): - raise RuntimeError( - f"Cannot normalize {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} while " - f"{temporary_parent.name} already exists. Remove or preserve that temporary path " - "and rerun maintain-project-repo." - ) - return legacy_root, desired_root - except OSError: - pass - raise RuntimeError( - f"The {profile} profile expects repo-maintenance under " - f"{toolkit_root(profile).as_posix()}, but both {DEFAULT_TOOLKIT_ROOT.as_posix()} " - f"and {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} already exist as separate paths. " - "Choose the intentional toolkit root, preserve any repo-specific custom files, " - "and rerun maintain-project-repo." - ) - - return legacy_root, desired_root - - -def apply_legacy_xcode_toolkit_migration(repo_root: Path, profile: str) -> str | None: - migration = legacy_xcode_toolkit_migration(repo_root, profile) - if migration is None: - return None - - legacy_root, desired_root = migration - try: - same_root = legacy_root.samefile(desired_root) - except OSError: - same_root = False - - if same_root: - legacy_parent = legacy_root.parent - temporary_parent = repo_root / ".maintain-project-repo-scripts-case-migration" - legacy_parent.rename(temporary_parent) - temporary_parent.rename(desired_root.parent) - return ( - f"normalized legacy {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} to " - f"{toolkit_root(profile).as_posix()} for {profile} profile" - ) - - desired_root.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(legacy_root), str(desired_root)) - try: - legacy_root.parent.rmdir() - except OSError: - pass - return ( - f"migrated legacy {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} to " - f"{toolkit_root(profile).as_posix()} for {profile} profile" - ) - - -def copy_file(source: Path, target: Path, profile: str) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - if profile == "xcode-workspace": - content = source.read_text(encoding="utf-8") - content = content.replace("Scripts/repo-maintenance", "scripts/repo-maintenance") - target.write_text(content, encoding="utf-8") - else: - shutil.copyfile(source, target) - if source.suffix in EXECUTABLE_SUFFIXES: - target.chmod(0o755) - - -def render_profile_env(profile: str) -> str: - description = PROFILE_CHOICES[profile] - return ( - "# Managed by maintain-project-repo. Do not hand-edit unless you also control the installer contract.\n" - f'REPO_MAINTENANCE_PROFILE="{profile}"\n' - f'REPO_MAINTENANCE_PROFILE_DESCRIPTION="{description}"\n' - ) - - -def write_profile_env(repo_root: Path, profile: str) -> None: - target = repo_root / profile_file(profile) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(render_profile_env(profile), encoding="utf-8") - - -def main() -> int: - args = build_parser().parse_args() - repo_root = Path(args.repo_root).expanduser().resolve() - actions: list[str] = [] - managed_files = [relative.as_posix() for _, relative in target_pairs(args.profile, args.skip_github_workflow)] - managed_profile_file = profile_file(args.profile) - managed_files.append(managed_profile_file.as_posix()) - - if not repo_root.exists(): - print( - json.dumps( - { - "status": "blocked", - "path_type": "primary", - "repo_root": str(repo_root), - "managed_files": managed_files, - "actions": actions, - "stderr": "The requested repo root does not exist.", - "next_step": "Create or choose an existing repository root and rerun the workflow.", - }, - indent=2, - sort_keys=True, - ) - ) - return 1 - - if not repo_root.is_dir(): - print( - json.dumps( - { - "status": "blocked", - "path_type": "primary", - "repo_root": str(repo_root), - "managed_files": managed_files, - "actions": actions, - "stderr": "The requested repo root is not a directory.", - "next_step": "Use a directory path for --repo-root and rerun the workflow.", - }, - indent=2, - sort_keys=True, - ) - ) - return 1 - - try: - ensure_profile_shape(repo_root, args.profile) - planned_migration = legacy_xcode_toolkit_migration(repo_root, args.profile) - for _, relative_target in target_pairs(args.profile, args.skip_github_workflow): - ensure_safe_target(repo_root, relative_target) - ensure_safe_target(repo_root, managed_profile_file) - except RuntimeError as exc: - print( - json.dumps( - { - "status": "blocked", - "path_type": "primary", - "repo_root": str(repo_root), - "managed_files": managed_files, - "actions": actions, - "stderr": str(exc), - "next_step": "Resolve the conflicting target path and rerun the workflow.", - }, - indent=2, - sort_keys=True, - ) - ) - return 1 - - if args.operation == "report-only" or args.dry_run: - if planned_migration is not None: - actions.append( - f"migrate legacy {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} to " - f"{toolkit_root(args.profile).as_posix()} for {args.profile} profile" - ) - for source, relative_target in target_pairs(args.profile, args.skip_github_workflow): - target = repo_root / relative_target - if target.exists(): - actions.append(f"refresh {relative_target.as_posix()} from {source.relative_to(assets_root()).as_posix()}") - else: - actions.append(f"install {relative_target.as_posix()} from {source.relative_to(assets_root()).as_posix()}") - profile_target = repo_root / managed_profile_file - if profile_target.exists(): - actions.append(f"refresh {managed_profile_file.as_posix()} for {args.profile} profile") - else: - actions.append(f"install {managed_profile_file.as_posix()} for {args.profile} profile") - print( - json.dumps( - { - "status": "success", - "path_type": "fallback", - "repo_root": str(repo_root), - "profile": args.profile, - "managed_files": managed_files, - "actions": actions, - "validation_result": "skipped (--dry-run)" if args.dry_run else "skipped (report-only)", - "next_step": "Run without --dry-run or report-only to install or refresh maintain-project-repo.", - }, - indent=2, - sort_keys=True, - ) - ) - return 0 - - migration_action = apply_legacy_xcode_toolkit_migration(repo_root, args.profile) - if migration_action is not None: - actions.append(migration_action) - - for source, relative_target in target_pairs(args.profile, args.skip_github_workflow): - target = repo_root / relative_target - action = "refreshed" if target.exists() else "installed" - copy_file(source, target, args.profile) - actions.append(f"{action} {relative_target.as_posix()}") - profile_target = repo_root / managed_profile_file - profile_action = "refreshed" if profile_target.exists() else "installed" - write_profile_env(repo_root, args.profile) - actions.append(f"{profile_action} {managed_profile_file.as_posix()} for {args.profile} profile") - - print( - json.dumps( - { - "status": "success", - "path_type": "primary", - "repo_root": str(repo_root), - "profile": args.profile, - "managed_files": managed_files, - "actions": actions, - "validation_result": "managed files synced", - "next_step": f"Use {toolkit_root(args.profile).as_posix()}/validate-all.sh locally and keep CI as a thin wrapper around that command.", - }, - indent=2, - sort_keys=True, - ) - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-docs.fsx b/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-docs.fsx new file mode 100644 index 000000000..6b8056734 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-docs.fsx @@ -0,0 +1,20 @@ +#!/usr/bin/env -S dotnet fsi +#load "../../../shared/project-docs/ProjectDocs.fsx" +#load "../../../shared/project-docs/DocsCoordinator.fsx" + +open System.IO +open DocsCoordinator + +let pluginRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let asset skill target template = + let root = Path.Combine(pluginRoot, "skills", skill, "assets") + { Name = skill; Target = target; Contract = Path.Combine(root, "document.contract.json"); Template = Path.Combine(root, template) } + +let assets = [ + asset "maintain-project-readme" "README.md" "README.template.md" + asset "maintain-project-contributing" "CONTRIBUTING.md" "CONTRIBUTING.template.md" + asset "maintain-project-agents" "AGENTS.md" "AGENTS.template.md" + asset "maintain-project-roadmap" "ROADMAP.md" "ROADMAP.template.md" +] + +fsi.CommandLineArgs |> Array.skip 1 |> execute assets |> exit diff --git a/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx b/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx new file mode 100644 index 000000000..64555f771 --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain-project-repo.fsx @@ -0,0 +1,147 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json + +type ManagedFile = { Source: string; Target: string; Profile: string option } +type Action = { Action: string; Target: string } +type Report = { + Status: string + Operation: string + Profile: string + RepoRoot: string + ManagedFiles: string list + Actions: Action list + DocumentationResult: string + Errors: string list +} + +let scriptRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let skillRoot = Path.GetFullPath(Path.Combine(scriptRoot, "..")) +let pluginRoot = Path.GetFullPath(Path.Combine(skillRoot, "..", "..")) +let manifestPath = Path.Combine(skillRoot, "assets", "managed-assets.json") + +let parseArgs argv = + let mutable repoRoot = "." + let mutable operation = "install" + let mutable profile = "generic" + let rec loop args = + match args with + | [] -> () + | "--repo-root" :: value :: tail -> repoRoot <- value; loop tail + | "--operation" :: value :: tail -> operation <- value; loop tail + | "--profile" :: value :: tail -> profile <- value; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + Path.GetFullPath(repoRoot), operation, profile + +let loadManifest () = + use document = JsonDocument.Parse(File.ReadAllText(manifestPath)) + if document.RootElement.GetProperty("schemaVersion").GetInt32() <> 1 then failwith "Unsupported managed-assets schema." + document.RootElement.GetProperty("files").EnumerateArray() + |> Seq.map (fun item -> + let hasProfile, profile = item.TryGetProperty("profile") + { Source = item.GetProperty("source").GetString(); Target = item.GetProperty("target").GetString(); Profile = if hasProfile then Some(profile.GetString()) else None }) + |> Seq.toList + +let ensureInside (root: string) (relative: string) = + if Path.IsPathRooted(relative) || relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) |> Array.contains ".." then + failwith $"Managed target must be repository-relative: {relative}" + Path.Combine(root, relative) |> Path.GetFullPath + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let ensureJustImport (root: string) (apply: bool) = + let path = Path.Combine(root, "justfile") + let importLine = "import 'scripts/repo-maintenance/repo-maintenance.just'" + let existing = if File.Exists(path) then File.ReadAllText(path).Replace("\r\n", "\n") else "" + if existing.Contains(importLine) then None + else + let updated = existing.TrimEnd() + (if String.IsNullOrWhiteSpace(existing) then "" else "\n\n") + "# BEGIN managed repo-maintenance\n" + importLine + "\n# END managed repo-maintenance\n" + if apply then atomicWrite path updated + Some { Action = (if File.Exists(path) then "update" else "install"); Target = "justfile" } + +let writeProfile (root: string) (profile: string) (apply: bool) = + let target = "scripts/repo-maintenance/config/profile.json" + let path = ensureInside root target + let content = $"{{\n \"schemaVersion\": 1,\n \"profile\": \"{profile}\"\n}}\n" + let action = if File.Exists(path) && File.ReadAllText(path) = content then "unchanged" elif File.Exists(path) then "update" else "install" + if apply && action <> "unchanged" then atomicWrite path content + { Action = action; Target = target } + +let copyManaged (root: string) (apply: bool) (managed: ManagedFile) = + let source = Path.Combine(pluginRoot, managed.Source) |> Path.GetFullPath + if not (File.Exists(source)) then failwith $"Managed source is missing: {source}" + let target = ensureInside root managed.Target + let content = File.ReadAllText(source).Replace("\r\n", "\n") + let action = if File.Exists(target) && File.ReadAllText(target).Replace("\r\n", "\n") = content then "unchanged" elif File.Exists(target) then "update" else "install" + if apply && action <> "unchanged" then atomicWrite target content + { Action = action; Target = managed.Target } + +let runDocs (root: string) (mode: string) = + let startInfo = ProcessStartInfo("dotnet") + startInfo.WorkingDirectory <- root + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in [ "fsi"; Path.Combine(scriptRoot, "maintain-project-docs.fsx"); "--project-root"; root; "--run-mode"; mode; "--format"; "json" ] do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + if child.ExitCode <> 0 then failwith $"Managed documentation {mode} failed: {stderr.Trim()}\n{stdout.Trim()}" + if String.IsNullOrWhiteSpace(stdout) then failwith "Managed documentation returned no report." + stdout.Trim() + +let isGitRepository root = + let startInfo = ProcessStartInfo("git") + startInfo.WorkingDirectory <- root + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in [ "rev-parse"; "--show-toplevel" ] do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + child.StandardOutput.ReadToEnd() |> ignore + child.StandardError.ReadToEnd() |> ignore + child.WaitForExit() + child.ExitCode = 0 + +let jsonOptions = + let value = JsonSerializerOptions(WriteIndented = true) + value.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + value + +let execute argv = + try + let root, operation, profile = parseArgs argv + if not (Directory.Exists(root)) then failwith $"Repository root does not exist: {root}" + if not (isGitRepository root) then failwith $"Path is not a Git repository: {root}" + if not (List.contains operation [ "install"; "refresh"; "report-only" ]) then failwith $"Unsupported operation: {operation}" + if not (List.contains profile [ "generic"; "xcode-workspace" ]) then failwith $"Unsupported profile: {profile}" + let apply = operation <> "report-only" + let managed = loadManifest () |> List.filter (fun file -> file.Profile.IsNone || file.Profile = Some profile) + let actions = managed |> List.map (copyManaged root apply) |> ResizeArray + actions.Add(writeProfile root profile apply) + match ensureJustImport root apply with Some action -> actions.Add(action) | None -> () + let docs = runDocs root (if apply then "apply" else "check-only") + let report = { + Status = "success"; Operation = operation; Profile = profile; RepoRoot = root + ManagedFiles = (managed |> List.map (fun file -> file.Target)) @ [ "scripts/repo-maintenance/config/profile.json"; "justfile" ] + Actions = List.ofSeq actions; DocumentationResult = docs; Errors = [] + } + Console.Out.Write(JsonSerializer.Serialize(report, jsonOptions) + "\n") + 0 + with error -> + let report = { Status = "failed"; Operation = ""; Profile = ""; RepoRoot = ""; ManagedFiles = []; Actions = []; DocumentationResult = ""; Errors = [ error.Message ] } + Console.Out.Write(JsonSerializer.Serialize(report, jsonOptions) + "\n") + 1 + +fsi.CommandLineArgs |> Array.skip 1 |> execute |> exit diff --git a/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain_project_docs.py b/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain_project_docs.py deleted file mode 100644 index f3409aedf..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/scripts/maintain_project_docs.py +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - - -SKILL_ROOT = Path(__file__).resolve().parents[1] -PLUGIN_ROOT = SKILL_ROOT.parents[1] - - -@dataclass(frozen=True) -class DocumentWorkflow: - key: str - label: str - filename: str - script: Path - path_arg: str - - -DOCUMENT_WORKFLOWS: Tuple[DocumentWorkflow, ...] = ( - DocumentWorkflow( - key="readme", - label="README", - filename="README.md", - script=PLUGIN_ROOT - / "skills/maintain-project-readme/scripts/maintain_project_readme.py", - path_arg="--readme-path", - ), - DocumentWorkflow( - key="contributing", - label="CONTRIBUTING", - filename="CONTRIBUTING.md", - script=PLUGIN_ROOT - / "skills/maintain-project-contributing/scripts/maintain_project_contributing.py", - path_arg="--contributing-path", - ), - DocumentWorkflow( - key="agents", - label="AGENTS", - filename="AGENTS.md", - script=PLUGIN_ROOT - / "skills/maintain-project-agents/scripts/maintain_project_agents.py", - path_arg="--agents-path", - ), - DocumentWorkflow( - key="roadmap", - label="ROADMAP", - filename="ROADMAP.md", - script=PLUGIN_ROOT - / "skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py", - path_arg="--roadmap-path", - ), -) - -ISSUE_KEYS = ( - "schema_violations", - "content_quality_issues", - "command_integrity_issues", - "workflow_drift_issues", - "validation_drift_issues", - "boundary_and_safety_issues", - "claim_integrity_issues", - "verification_evidence_issues", - "post_fix_status", -) - - -def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Run a coordinated project documentation maintenance sweep." - ) - parser.add_argument( - "--project-root", required=True, help="Absolute project root path" - ) - parser.add_argument( - "--run-mode", - required=True, - choices=["check-only", "apply"], - help="Execution mode", - ) - parser.add_argument( - "--include", help="Comma-separated document workflow keys to include" - ) - parser.add_argument("--skip", help="Comma-separated document workflow keys to skip") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument( - "--fail-on-issues", - action="store_true", - help="Exit non-zero when findings remain", - ) - parser.add_argument( - "--collect-source-tickets", - action="store_true", - help="Pass source TODO/FIXME collection through to the roadmap workflow.", - ) - parser.add_argument( - "--collect-github-issues", - action="store_true", - help="Pass GitHub issue collection through to the roadmap workflow.", - ) - parser.add_argument( - "--github-repo", - help="Optional OWNER/REPO override for roadmap GitHub issue collection", - ) - return parser.parse_args(argv) - - -def split_keys(raw: Optional[str]) -> List[str]: - if not raw: - return [] - return [part.strip().lower() for part in raw.split(",") if part.strip()] - - -def select_workflows( - include: Optional[str], skip: Optional[str], project_root: Optional[Path] = None -) -> Tuple[List[DocumentWorkflow], List[str]]: - known = {workflow.key: workflow for workflow in DOCUMENT_WORKFLOWS} - errors: List[str] = [] - include_keys = split_keys(include) - skip_keys = set(split_keys(skip)) - for key in [*include_keys, *skip_keys]: - if key not in known: - errors.append(f"Unknown document workflow key: {key}") - if include_keys: - selected = [known[key] for key in include_keys if key in known] - else: - selected = list(DOCUMENT_WORKFLOWS) - return [workflow for workflow in selected if workflow.key not in skip_keys], errors - - -def build_child_command( - args: argparse.Namespace, workflow: DocumentWorkflow, project_root: Path -) -> List[str]: - command = [ - sys.executable, - str(workflow.script), - "--project-root", - str(project_root), - workflow.path_arg, - str(project_root / workflow.filename), - "--run-mode", - args.run_mode, - "--print-json", - ] - if workflow.key == "roadmap": - if args.collect_source_tickets: - command.append("--collect-source-tickets") - if args.collect_github_issues: - command.append("--collect-github-issues") - if args.github_repo: - command.extend(["--github-repo", args.github_repo]) - return command - - -def run_child( - args: argparse.Namespace, workflow: DocumentWorkflow, project_root: Path -) -> Dict[str, Any]: - command = build_child_command(args, workflow, project_root) - proc = subprocess.run( - command, cwd=project_root, capture_output=True, text=True, check=False - ) - child: Dict[str, Any] = { - "key": workflow.key, - "label": workflow.label, - "path": str(project_root / workflow.filename), - "returncode": proc.returncode, - "report": {}, - "errors": [], - } - if proc.stderr.strip(): - child["stderr"] = proc.stderr.strip() - try: - child["report"] = json.loads(proc.stdout) - except json.JSONDecodeError: - child["errors"].append(f"{workflow.label} workflow did not return JSON output.") - if proc.stdout.strip(): - child["stdout"] = proc.stdout.strip() - if proc.returncode != 0: - child["errors"].append( - f"{workflow.label} workflow exited with status {proc.returncode}." - ) - return child - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def heading_present(text: str, heading: str) -> bool: - pattern = rf"(?im)^#+\s+{re.escape(heading)}\s*$" - return re.search(pattern, text) is not None - - -def responsibility_issue( - file: Path, issue_id: str, message: str, destination: str -) -> Dict[str, Any]: - return { - "issue_id": issue_id, - "severity": "warning", - "file": str(file), - "message": message, - "suggested_owner": destination, - } - - -def audit_responsibility_boundaries( - project_root: Path, selected: Sequence[DocumentWorkflow] -) -> List[Dict[str, Any]]: - selected_keys = {workflow.key for workflow in selected} - issues: List[Dict[str, Any]] = [] - - def maybe_read(key: str, filename: str) -> Tuple[Path, str]: - path = project_root / filename - if key not in selected_keys or not path.is_file(): - return path, "" - return path, read_text(path) - - readme_path, readme = maybe_read("readme", "README.md") - if readme: - for heading in ( - "Contribution Workflow", - "Review Expectations", - "Release Process", - ): - if heading_present(readme, heading): - issues.append( - responsibility_issue( - readme_path, - "readme-contains-maintainer-workflow", - f"README.md contains a `{heading}` section; keep README product-focused and link out.", - "CONTRIBUTING.md or maintainer docs", - ) - ) - - contributing_path, contributing = maybe_read("contributing", "CONTRIBUTING.md") - if contributing: - for heading in ("Product Principles", "Milestones", "Small Tickets"): - if heading_present(contributing, heading): - issues.append( - responsibility_issue( - contributing_path, - "contributing-contains-planning-content", - f"CONTRIBUTING.md contains a `{heading}` section; keep planning and backlog content in ROADMAP.md.", - "ROADMAP.md", - ) - ) - - agents_path, agents = maybe_read("agents", "AGENTS.md") - if agents: - for heading in ("Quick Start", "Usage", "Known Gaps"): - if heading_present(agents, heading): - destination = ( - "README.md" if heading in {"Quick Start", "Usage"} else "ROADMAP.md" - ) - issues.append( - responsibility_issue( - agents_path, - "agents-contains-non-agent-content", - f"AGENTS.md contains a `{heading}` section; keep agent guidance focused on durable operating rules.", - destination, - ) - ) - - roadmap_path, roadmap = maybe_read("roadmap", "ROADMAP.md") - if roadmap: - for heading in ("Contribution Workflow", "Local Setup", "Safety Boundaries"): - if heading_present(roadmap, heading): - destination = ( - "CONTRIBUTING.md" if heading != "Safety Boundaries" else "AGENTS.md" - ) - issues.append( - responsibility_issue( - roadmap_path, - "roadmap-contains-procedural-guidance", - f"ROADMAP.md contains a `{heading}` section; keep roadmap content focused on planning.", - destination, - ) - ) - return issues - - -def child_issue_count(child: Dict[str, Any]) -> int: - report = child.get("report") - if not isinstance(report, dict): - return len(child.get("errors", [])) - return sum( - len(report.get(key, [])) - for key in ISSUE_KEYS - if isinstance(report.get(key), list) - ) + len(child.get("errors", [])) - - -def child_fixes(child: Dict[str, Any]) -> List[Dict[str, Any]]: - report = child.get("report") - if not isinstance(report, dict): - return [] - fixes = report.get("fixes_applied", report.get("apply_actions", [])) - return fixes if isinstance(fixes, list) else [] - - -def child_post_fix_status(child: Dict[str, Any]) -> List[Dict[str, Any]]: - report = child.get("report") - if not isinstance(report, dict): - return [] - post_fix = report.get("post_fix_status", []) - return post_fix if isinstance(post_fix, list) else [] - - -def markdown_report(report: Dict[str, Any]) -> str: - lines: List[str] = [ - "# Project Docs Maintenance Report", - "", - "## Document Workflows", - "", - ] - for child in report["document_reports"]: - issue_count = child_issue_count(child) - lines.append( - f"- `{child['key']}`: exit `{child['returncode']}`, {issue_count} issue(s)" - ) - - lines.extend(["", "## Responsibility Issues", ""]) - if report["responsibility_issues"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}` in `{issue['file']}`: {issue['message']} Suggested owner: {issue['suggested_owner']}." - for issue in report["responsibility_issues"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Fixes Applied", ""]) - if report["fixes_applied"]: - for fix in report["fixes_applied"]: - action = fix.get("action", "unknown") - reason = fix.get("reason", "") - file = fix.get("file", "") - lines.append(f"- `{action}` in `{file}`: {reason}") - else: - lines.append("- None.") - - lines.extend(["", "## Errors", ""]) - if report["errors"]: - lines.extend(f"- {error}" for error in report["errors"]) - else: - lines.append("- None.") - return "\n".join(lines).rstrip() + "\n" - - -def write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def unresolved_issues(report: Dict[str, Any]) -> bool: - return bool( - report["responsibility_issues"] or report["errors"] or report["post_fix_status"] - ) or any(child_issue_count(child) for child in report["document_reports"]) - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - selected, selection_errors = select_workflows(args.include, args.skip, project_root) - report: Dict[str, Any] = { - "run_context": { - "project_root": str(project_root), - "run_mode": args.run_mode, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "collect_source_tickets": bool(args.collect_source_tickets), - "collect_github_issues": bool(args.collect_github_issues), - "github_repo": args.github_repo or "", - }, - "document_order": [workflow.key for workflow in selected], - "document_reports": [], - "responsibility_issues": [], - "fixes_applied": [], - "post_fix_status": [], - "errors": selection_errors, - } - if not project_root.is_dir(): - report["errors"].append( - f"Project root does not exist or is not a directory: {project_root}" - ) - return report, markdown_report(report) - - if not report["errors"]: - for workflow in selected: - child = run_child(args, workflow, project_root) - report["document_reports"].append(child) - report["fixes_applied"].extend(child_fixes(child)) - report["post_fix_status"].extend(child_post_fix_status(child)) - report["errors"].extend(child["errors"]) - report["responsibility_issues"] = audit_responsibility_boundaries( - project_root, selected - ) - - return report, markdown_report(report) - - -def main() -> int: - args = parse_args() - report, md = run_maintenance(args) - payload = json.dumps(report, indent=2, sort_keys=True) + "\n" - - if args.json_out: - write_text(Path(args.json_out), payload) - if args.md_out: - write_text(Path(args.md_out), md) - - if args.print_json: - sys.stdout.write(payload) - elif args.print_md: - sys.stdout.write(md) - else: - if not unresolved_issues(report): - sys.stdout.write("No findings.\n") - else: - sys.stdout.write(md) - - if report["errors"]: - return 1 - if args.fail_on_issues and unresolved_issues(report): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/repository-skills/skills/maintain-project-repo/scripts/run_workflow.py b/plugins/repository-skills/skills/maintain-project-repo/scripts/run_workflow.py deleted file mode 100755 index dbb21ae8e..000000000 --- a/plugins/repository-skills/skills/maintain-project-repo/scripts/run_workflow.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Install or refresh repository tooling and canonical project documentation.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path -from typing import Any - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root") - parser.add_argument("--operation", choices=("install", "refresh", "report-only")) - parser.add_argument("--profile", choices=("generic", "xcode-workspace")) - parser.add_argument("--skip-github-workflow", action="store_true") - parser.add_argument("--dry-run", action="store_true") - return parser - - -def decode_payload(proc: subprocess.CompletedProcess[str], fallback: dict[str, Any]) -> dict[str, Any]: - if not proc.stdout.strip(): - return fallback - try: - payload = json.loads(proc.stdout) - except json.JSONDecodeError: - return { - **fallback, - "stdout": proc.stdout, - "stderr": proc.stderr, - } - return payload if isinstance(payload, dict) else fallback - - -def run_documentation(repo_root: str, run_mode: str) -> tuple[int, dict[str, Any], str]: - helper_path = Path(__file__).with_name("maintain_project_docs.py") - command = [ - sys.executable, - str(helper_path), - "--project-root", - repo_root, - "--run-mode", - run_mode, - "--print-json", - ] - proc = subprocess.run(command, capture_output=True, text=True, check=False) - fallback = { - "run_context": {"project_root": repo_root, "run_mode": run_mode}, - "document_order": [], - "document_reports": [], - "responsibility_issues": [], - "fixes_applied": [], - "post_fix_status": [], - "errors": ["The integrated documentation workflow did not return JSON output."], - } - return proc.returncode, decode_payload(proc, fallback), proc.stderr.strip() - - -def main() -> int: - args = build_parser().parse_args() - repo_root = str(Path(args.repo_root or ".").expanduser().resolve()) - operation = args.operation or "install" - profile = args.profile or "generic" - normalized_inputs = { - "repo_root": repo_root, - "operation": operation, - "profile": profile, - "skip_github_workflow": args.skip_github_workflow, - "dry_run": args.dry_run, - } - - helper_path = Path(__file__).with_name("install_maintain_project_repo.py") - command = [ - str(helper_path), - "--repo-root", - repo_root, - "--operation", - operation, - "--profile", - profile, - ] - if args.skip_github_workflow: - command.append("--skip-github-workflow") - if args.dry_run: - command.append("--dry-run") - - proc = subprocess.run(command, capture_output=True, text=True, check=False) - return_code = proc.returncode - payload = decode_payload(proc, { - "status": "failed", - "path_type": "primary", - "repo_root": repo_root, - "normalized_inputs": normalized_inputs, - "managed_files": [], - "actions": [], - "validation_result": None, - "stdout": proc.stdout, - "stderr": proc.stderr, - "next_step": "Fix the maintain-project-repo workflow error and rerun the workflow.", - }) - payload.setdefault("normalized_inputs", normalized_inputs) - if proc.returncode == 0: - documentation_mode = ( - "check-only" - if operation == "report-only" or args.dry_run - else "apply" - ) - docs_code, docs_payload, docs_stderr = run_documentation( - repo_root, documentation_mode - ) - payload["documentation"] = docs_payload - payload["documentation_result"] = ( - "checked (no writes)" - if documentation_mode == "check-only" - else "canonical documents created or refreshed" - ) - if docs_code != 0: - payload["status"] = "failed" - payload["documentation_result"] = "failed after toolkit update" - existing_stderr = str(payload.get("stderr", "")).strip() - details = docs_stderr or "The integrated documentation workflow failed." - payload["stderr"] = "\n".join( - part for part in (existing_stderr, details) if part - ) - payload["next_step"] = ( - "Fix the reported documentation workflow error and rerun " - "maintain-project-repo so tooling and canonical docs agree." - ) - return_code = 1 - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if return_code == 0 else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/SKILL.md b/plugins/repository-skills/skills/maintain-project-roadmap/SKILL.md index 215d8de4f..120489851 100644 --- a/plugins/repository-skills/skills/maintain-project-roadmap/SKILL.md +++ b/plugins/repository-skills/skills/maintain-project-roadmap/SKILL.md @@ -1,150 +1,62 @@ --- name: maintain-project-roadmap -description: Maintain checklist-style ROADMAP.md files against a canonical base schema with deterministic check-only and bounded apply modes. Use for milestone planning, issue-sized tickets, TODO/FIXME imports, normalization, or targeted fixes. +description: Maintain ROADMAP.md as the planning member of the canonical four-document repository suite. --- # Maintain Project Roadmap -Maintain checklist-style `ROADMAP.md` files through one deterministic base-template workflow. - -This skill is the general template layer for roadmap maintenance. It defines the canonical shared checklist-roadmap contract that downstream language-, framework-, stack-, or repository-specific customization can adapt through explicit configuration instead of ad hoc structure drift. It also owns small planning tickets that are too small or too unplanned for a milestone, so ordinary bug-fix TODOs do not need a separate `TODO.md` surface by default. - -## Inputs - -- Required: `--project-root ` -- Required: `--run-mode ` -- Optional: `--roadmap-path ` -- Optional: `--config ` -- Optional: `--collect-source-tickets` -- Optional: `--collect-github-issues` -- Optional: `--github-repo ` -- Optional: `--ticket-section ` -- Optional: `--ticket-text ` -- Optional: `--ticket-state ` -- Optional: `--ticket-source ` -- Optional: `--ticket-match ` -- Optional: `--allow-duplicate` - -## Workflow - -1. Validate the project root and resolve the target `ROADMAP.md`. -2. Load the canonical roadmap schema from the built-in template config, then merge any explicit customization override. -3. In `check-only`, audit title requirements, top-level section names and order, the required table of contents, milestone ordering, milestone subsection names, milestone status values, milestone progress consistency, small-ticket placement, checkbox syntax, legacy format, and root `TODO.md` files that still need migration into the canonical roadmap structure. -4. When requested, collect small-ticket candidates from source TODO/FIXME comments or open GitHub issues and report them under `small_ticket_candidates`. -5. In `apply`, keep edits bounded to the target `ROADMAP.md` while normalizing the roadmap into the configured canonical checklist structure. If source or GitHub ticket collection was requested, append new candidates to `Small Tickets` without rewriting source files. -6. If an explicit roadmap ticket mutation was requested, add or update one checklist item in `Small Tickets`, `Backlog Candidates`, or a milestone `Tickets` subsection. Dedupe by default, and use `--allow-duplicate` only when the duplicate is intentional. -7. Preserve useful preamble material before the first H2 when normalizing the structural contract around it. -8. Use the bundled roadmap template when bootstrapping a missing `ROADMAP.md`. -9. Re-run the same audit to confirm post-fix status. - -## Writing Expectations - -- `Vision` should describe the long-term outcome the roadmap is meant to deliver, not restate what the project already is. -- `Product Principles` should capture a small set of planning and delivery rules that shape roadmap decisions, not general branding or philosophy. -- `Milestone Progress` should stay a concise rollup of milestone names and statuses, not a second task-management surface. -- `Milestone > Status` should be one plain allowed status value. -- `Milestone > Scope` should describe boundary and intended outcome, not duplicate the ticket list. -- `Milestone > Tickets` should be the actionable checklist for work inside the milestone. -- `Milestone > Exit Criteria` should define what must be true before the milestone counts as complete. -- `Small Tickets` should hold issue-sized fixes, TODO/FIXME imports, and cleanup work that is not substantial enough for a milestone yet. Keep these as checklist items that can be linked to GitHub issues, source comments, or milestone tickets when the evidence exists. -- `Backlog Candidates` should hold plausible future work that is not yet committed to a milestone. -- `History` should record only notable roadmap changes such as milestone additions, scope cuts, resets, or major replans. -- A root `TODO.md` is a legacy planning surface once `ROADMAP.md` has `Small Tickets`. Report it as a migration-needed finding instead of treating it as a parallel canonical backlog. - -## Codex Subagent Fit - -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy roadmap discovery before the main workflow edits or reports: checking one milestone family per worker, comparing roadmap claims against release notes, or gathering evidence from docs and issues for backlog triage. - -Keep `apply` edits in the main thread because this skill owns one target roadmap and must preserve one coherent planning structure. Ask workers for concise findings, candidate changes, and references instead of direct roadmap rewrites. - -## Small Ticket Collection - -- Use `--collect-source-tickets` to scan ordinary source and documentation files for TODO/FIXME comments and report candidate `Small Tickets` entries with repo-relative file and line references. -- Use `--collect-github-issues` to call `gh issue list` for open issues. Pass `--github-repo ` when the current checkout's GitHub remote is not the intended issue source. -- In `check-only`, collection is report-only and does not mutate files. -- In `apply`, collection appends new entries to `Small Tickets` in `ROADMAP.md`. It does not rewrite source comments yet; source comment rewrites need a separate explicit mode so code files are not changed as a side effect of roadmap normalization. - -## Explicit Ticket Mutation - -Use explicit ticket mutation when another agent, skill, report, or maintainer -workflow has one known checklist item to add or update in `ROADMAP.md`. - -Examples: - -```bash -scripts/maintain_project_roadmap.py \ - --project-root . \ - --run-mode apply \ - --ticket-section "Backlog Candidates" \ - --ticket-text "Add guarded roadmap apply support" \ - --ticket-source "docs/agents/roadmap-maintenance.md" -``` +## Purpose -```bash -scripts/maintain_project_roadmap.py \ - --project-root . \ - --run-mode apply \ - --ticket-section "Small Tickets" \ - --ticket-text "Add guarded roadmap apply support" \ - --ticket-state done -``` +Keep checklist-style `ROADMAP.md` milestones, tickets, progress, backlog, and +history structurally consistent while the entire canonical documentation suite +is maintained together. + +## Commands -```bash -scripts/maintain_project_roadmap.py \ - --project-root . \ - --run-mode apply \ - --ticket-section "Milestone 2: Tickets" \ - --ticket-text "Wire roadmap ticket mutation into the maintainer workflow" +The only documentation commands are: + +```text +just docs-check +just docs-apply ``` -Rules: +Both always process README, CONTRIBUTING, AGENTS, and ROADMAP. Ticket mutation, +source collection, and GitHub issue collection are not separate command modes; +the managed full-document pass owns deterministic roadmap normalization. -- Ticket mutation requires `--run-mode apply`. -- Ticket mutation requires both `--ticket-section` and `--ticket-text`. -- Supported sections are `Small Tickets`, `Backlog Candidates`, and `Milestone N: Tickets`. -- `--ticket-state open` writes `[ ]`; `--ticket-state done` writes `[x]`. -- `--ticket-source` must be repo-relative or inside the project root when passed as an absolute path. -- Existing matching checklist items are updated by default instead of duplicated. -- Use `--ticket-match` when the existing item text differs from the replacement text. -- Use `--allow-duplicate` only when an intentional duplicate checklist item is needed. +## Managed Contract -## Canonical Base Contract +- `assets/document.contract.json` fixes sections, milestone subsections, + allowed statuses, and a small fixed alias set. +- `assets/ROADMAP.template.md` supplies bootstrap and missing-section content. +- Repositories cannot customize status vocabulary, headings, aliases, order, + or automatic-fix policy. -The authoritative default shared roadmap structure lives in: +## ROADMAP Ownership -- `config/roadmap-customization.template.yaml` -- `assets/ROADMAP.template.md` +ROADMAP owns vision, product principles, milestone progress, milestone scope, +tickets, exit criteria, small tickets, backlog candidates, and notable planning +history. Setup and procedure belong to CONTRIBUTING or maintainer docs; safety +policy belongs to AGENTS. -Treat those two files as the source of truth for the canonical base schema and the canonical bootstrap document. Downstream plugins may extend or change that structure through explicit customization, but this base skill treats the required table of contents plus the configured checklist roadmap section block as hard-enforced. +Milestone Progress and the table of contents are regenerated from the canonical +milestone sections. Missing milestone subsections are added from the managed +template. Known status aliases normalize deterministically; unknown semantic +states remain blocking findings rather than being invented. -## Output Contract +## Deterministic Workflow -- Return Markdown plus JSON with: - - `run_context` - - `customization_state` - - `schema_contract` - - `findings` - - `small_ticket_candidates` - - `apply_actions` - - `errors` -- If there are no findings, no small-ticket candidates, no apply actions, and no errors, output exactly `No findings.` +Use `just docs-check` for the full no-write audit and `just docs-apply` for the +atomic, byte-idempotent four-document normalization transaction. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent roadmap status, milestone names, or ticket details that are not grounded in the existing file or the canonical template scaffolding. -- Never edit files other than the target `ROADMAP.md`. -- Never use explicit ticket mutation as a generic prose editor; it may only add or update one checklist item per run. -- Never rewrite source TODO/FIXME comments unless a future explicit source-rewrite mode is implemented and requested. -- Keep checklist-style `ROADMAP.md` as the canonical format. -- Treat legacy table-style roadmap layouts as migration sources, not as an alternate canonical output mode. -- Treat root `TODO.md` as a migration source, not as an alternate canonical output mode. Do not auto-delete or auto-flatten it; migrate useful entries into `ROADMAP.md` in a reviewed documentation pass. +- Never maintain or mutate ROADMAP independently of the full document suite. +- Never invent milestone status, scope, ticket details, or completion claims. +- Never add customization or alternate roadmap formats. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `config/roadmap-customization.template.yaml` +- `assets/document.contract.json` - `assets/ROADMAP.template.md` -- `references/roadmap-automation-prompts.md` -- `references/roadmap-customization.md` -- `references/roadmap-config-schema.md` diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/assets/document.contract.json b/plugins/repository-skills/skills/maintain-project-roadmap/assets/document.contract.json new file mode 100644 index 000000000..02d18d7ec --- /dev/null +++ b/plugins/repository-skills/skills/maintain-project-roadmap/assets/document.contract.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "document": "roadmap", + "targetFile": "ROADMAP.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Vision", "Product Principles", "Milestone Progress", "Small Tickets", "Backlog Candidates", "History"], + "sectionOrder": ["Vision", "Product Principles", "Milestone Progress", "__MILESTONES__", "Small Tickets", "Backlog Candidates", "History"], + "requiredSubsections": { + "__MILESTONE__": ["Status", "Scope", "Tickets", "Exit Criteria"] + }, + "sectionAliases": { + "Product Principles": ["Product principles"], + "Small Tickets": ["Small tickets", "TODO", "Todo", "TODOs", "Fixes", "Bug Fixes"], + "Backlog Candidates": ["Backlog candidates"] + }, + "subsectionAliases": { + "Status": ["status"], + "Exit Criteria": ["Exit criteria"] + }, + "allowedStatuses": ["Planned", "In Progress", "Completed", "Blocked", "De-scoped"], + "statusAliases": { + "In Progress": ["Implementation Complete; Release Pending", "Release candidate"] + } +} diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml b/plugins/repository-skills/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml deleted file mode 100644 index 6945ebb09..000000000 --- a/plugins/repository-skills/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml +++ /dev/null @@ -1,70 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - statusValues: - - Planned - - In Progress - - Completed - - Blocked - - De-scoped - requiredSections: - - Vision - - Product Principles - - Milestone Progress - - Small Tickets - - Backlog Candidates - - History - sectionOrder: - - Vision - - Product Principles - - Milestone Progress - - __MILESTONES__ - - Small Tickets - - Backlog Candidates - - History - requiredMilestoneSubsections: - - Status - - Scope - - Tickets - - Exit Criteria - sectionAliases: - Product Principles: - - Product principles - Small Tickets: - - Small tickets - - TODO - - Todo - - TODOs - - Fixes - - Bug Fixes - Backlog Candidates: - - Backlog candidates - milestoneSubsectionAliases: - Status: - - status - Exit Criteria: - - Exit criteria - sectionTemplates: - Vision: | - - Describe the long-term outcome this roadmap is meant to deliver, not just what the project currently is. - Product Principles: | - - State the few planning and delivery rules that should shape roadmap decisions and tradeoffs. - Small Tickets: | - - [ ] Record issue-sized fixes, TODO/FIXME imports, and cleanup work that is too small or too unplanned for a milestone. - Backlog Candidates: | - - [ ] Record plausible future work that is not yet committed to a milestone. - History: | - - Initial roadmap scaffold created. - - Record only notable roadmap changes here, such as milestone additions, scope cuts, resets, or major replans. - milestoneSubsectionTemplates: - Status: | - Planned - Scope: | - - [ ] Describe the boundary and intended outcome of this milestone without turning Scope into a task list. - Tickets: | - - [ ] Add the first concrete implementation task for this milestone. - Exit Criteria: | - - [ ] Describe what must be true before this milestone counts as complete. diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md b/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md deleted file mode 100644 index 57bfe40f0..000000000 --- a/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md +++ /dev/null @@ -1,125 +0,0 @@ -# Roadmap Automation Prompt Templates - -Use this section order in this file: Suitability, App template, CLI template, Placeholders, Customization Points. - -## Suitability - -- Codex App: `Conditional` - useful for recurring checklist-roadmap audits, bounded updates, and legacy migrations that stay limited to `ROADMAP.md` -- Codex CLI: `Conditional` - useful for scripted check/apply workflows when roadmap edits stay limited to one file -- Source and GitHub ticket collection: `Conditional` - useful when a planning sweep should report or append TODO/FIXME comments and open GitHub issues as `Small Tickets` - -## Codex App Automation Prompt Template - -```markdown -Use $maintain-project-roadmap. - -Scope: -- Project root: -- Target file: -- Run mode: - -Execution policy: -- Restrict all edits to only. -- Preserve useful roadmap content while normalizing it into the canonical checklist roadmap structure. -- Enforce the required table of contents. -- Enforce canonical top-level sections and configured milestone subsection headings. -- Ensure milestone progress matches the actual milestone sections, order, and milestone status values. -- Ensure checklist items use valid markdown checkbox syntax. -- Allow `[P]` only inside milestone `Tickets` subsections. -- If requested, collect source TODO/FIXME comments or open GitHub issues as `Small Tickets` candidates. -- If legacy table-style format is detected: - - In `apply` mode: migrate in-place to checklist standard while preserving useful milestone identity. - - In `check-only` mode: report migration required without editing. -- Never edit unrelated files. -- Never rewrite source TODO/FIXME comments. -- Never commit, push, or open PRs. - -Output contract: -- Report whether the roadmap matches the configured checklist contract. -- If updates were applied, summarize structural changes and why. -- If check-only, report required changes without editing. -- If ticket collection is requested, report `small_ticket_candidates` with source, title, and links. - -No-findings handling: -- If no updates are needed, output exactly `No findings.` and archive the run. -- Otherwise keep the run in inbox triage with a concise change summary. - -Failure handling: -- If roadmap file is missing in check-only mode, report the missing required path. -- If apply mode is blocked by permissions or sandboxing, report the minimum required access. -``` - -## Codex CLI Automation Prompt Template (codex exec) - -### Variant A: Check-only - -- Recommended sandbox: `read-only` - -Prompt template: - -```markdown -Use $maintain-project-roadmap. - -Check roadmap consistency at for project . -Do not edit files. - -Validate: -- the roadmap has a title and the required table of contents -- canonical top-level sections are present and ordered correctly -- milestone sections are ordered deterministically -- each milestone includes the configured required subsections -- milestone statuses use allowed status values -- milestone progress matches the actual milestone headings and statuses -- checklist items use valid markdown checkbox syntax -- `[P]` appears only in milestone `Tickets` subsections -- if legacy table-style sections are present, report that migration is required -- if requested, source TODO/FIXME comments or open GitHub issues that could become `Small Tickets` - -If no updates are needed, output exactly `No findings.`. -Otherwise output a concise required-changes report. -``` - -### Variant B: Apply bounded updates - -- Recommended sandbox: `workspace-write` - -Prompt template: - -```markdown -Use $maintain-project-roadmap. - -Apply bounded updates to for project . -Edit this file only. - -Enforce the configured checklist roadmap structure: -- required table of contents -- canonical top-level sections -- milestone sections in deterministic order -- required milestone subsections -- milestone progress aligned with milestone sections and statuses -- valid checkbox syntax - -If ticket collection is requested, append new source TODO/FIXME or GitHub issue candidates to `Small Tickets` in ROADMAP.md without editing source files. -If legacy table-style format is present, migrate in-place using the canonical checklist template as the target structure. -Keep edits minimal, deterministic, and grounded in the existing roadmap plus bundled scaffold wording. -Never edit other files. -Never commit or push. - -If no updates are needed, output exactly `No findings.`. -If blocked by permissions or sandboxing, report the minimum required access. -``` - -## Placeholders - -- ``: absolute project path -- ``: absolute path to `ROADMAP.md` -- ``: `check-only` or `apply` -- Optional ticket collection flags: `--collect-source-tickets`, `--collect-github-issues`, and `--github-repo ` - -## Customization Points - -- top-level section set and ordering -- milestone subsection set and ordering -- heading alias migrations -- scaffolding text for base sections and milestone subsections -- additional-section preservation policy diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-config-schema.md b/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-config-schema.md deleted file mode 100644 index fc209220e..000000000 --- a/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-config-schema.md +++ /dev/null @@ -1,37 +0,0 @@ -# Roadmap Configuration Schema - -Persistent roadmap customization for `maintain-project-roadmap` is defined in: - -- Template defaults: `config/roadmap-customization.template.yaml` -- User overrides: `config/roadmap-customization.yaml` - -Checklist roadmap mode is canonical. - -## Top-level fields - -- `schemaVersion`: integer schema version (`1`) -- `isCustomized`: `true` when user overrides exist in `config/roadmap-customization.yaml` -- `profile`: short profile label such as `base`, `team-delivery`, or `quarterly` -- `settings`: roadmap behavior and structure controls - -## `settings` fields - -- `preservePreamble`: whether to preserve preamble content beneath the title before the first H2 -- `allowAdditionalSections`: whether non-canonical top-level sections are preserved after the canonical roadmap block -- `statusValues`: allowed milestone status values used for milestone `Status` subsections and milestone-progress rollups -- `requiredSections`: required non-milestone H2 sections -- `sectionOrder`: canonical roadmap order, including the milestone slot marker `__MILESTONES__` -- `requiredMilestoneSubsections`: required H3 subsections inside every milestone -- `sectionAliases`: top-level heading aliases migrated to canonical names during apply -- `milestoneSubsectionAliases`: milestone subsection aliases migrated to canonical names during apply -- `sectionTemplates`: default body scaffolding for required top-level sections -- `milestoneSubsectionTemplates`: default body scaffolding for required milestone subsections - -Base interpretation notes: - -- `Milestone Progress` should summarize milestone names plus statuses only. -- `Scope` should stay outcome- and boundary-oriented. -- `Small Tickets` should hold issue-sized fixes, TODO/FIXME imports, and cleanup work that is too small or too unplanned for a milestone. -- `Tickets` should carry actionable checklist work. -- `Backlog Candidates` should hold plausible future work that is not yet committed to a milestone or small-ticket item. -- `History` should stay high-signal and record only notable roadmap changes. diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-customization.md b/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-customization.md deleted file mode 100644 index 896f7cae8..000000000 --- a/plugins/repository-skills/skills/maintain-project-roadmap/references/roadmap-customization.md +++ /dev/null @@ -1,66 +0,0 @@ -# Roadmap Customization Guide - -## Canonical Base Contract - -`maintain-project-roadmap` treats checklist-style `ROADMAP.md` as canonical. - -The default shared roadmap structure is defined in: - -- `config/roadmap-customization.template.yaml` -- `assets/ROADMAP.template.md` - -That base contract requires: - -- a top-level `# ...` title -- `## Table of Contents` -- `## Vision` -- `## Product Principles` -- `## Milestone Progress` -- one or more milestone sections named `## Milestone N: Name` -- `## Backlog Candidates` -- `## History` - -Each milestone must include: - -- `### Status` -- `### Scope` -- `### Tickets` -- `### Exit Criteria` - -Interpretation guidance: - -- `Vision` is for the long-term outcome, not a project description. -- `Product Principles` is for roadmap decision rules, not general product philosophy. -- `Milestone Progress` is a status rollup, not a second checklist surface. -- `Status` is one allowed value only. -- `Scope` defines the milestone boundary and intended outcome, not the implementation task inventory. -- `Tickets` is the actionable checklist surface. -- `Exit Criteria` defines what must be true for completion. -- `Backlog Candidates` is for uncommitted future work. -- `History` is for notable roadmap changes, not every minor edit. - -## Customization Model - -Downstream plugins may customize roadmap structure through `config/roadmap-customization.yaml`. - -The intended customization surface is structural and explicit: - -- required top-level sections -- top-level section order -- required milestone subsections -- heading aliases for migration -- section and milestone-subsection scaffolding -- whether additional non-canonical sections are preserved - -## Legacy Migration - -Legacy roadmap layouts such as `Current Milestone` sections or milestone tables are not canonical output modes. - -Runtime policy: - -- in `check-only`, report legacy format as a migration finding -- in `apply`, migrate legacy layout into checklist-roadmap structure -- preserve useful milestone identity where possible -- use canonical template scaffolding when legacy content is incomplete -- when a root `TODO.md` exists beside a canonical `ROADMAP.md`, report it as a migration-needed finding -- do not automatically delete or flatten root `TODO.md`; move useful entries into `ROADMAP.md` milestones, `Small Tickets`, or `Backlog Candidates` in a reviewed documentation pass diff --git a/plugins/repository-skills/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py b/plugins/repository-skills/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py deleted file mode 100644 index b1228cd66..000000000 --- a/plugins/repository-skills/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py +++ /dev/null @@ -1,1647 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Checklist ROADMAP maintainer with deterministic check-only and apply modes.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -CHECKBOX_RE = re.compile(r"^\s*-\s+\[( |x)\]\s+.+$") -ANY_CHECKBOX_RE = re.compile(r"^\s*-\s+\[[^\]]\]\s+.+$") -MILESTONE_HEADING_RE = re.compile(r"^Milestone\s+(\d+)\s*:\s*(.+?)\s*$") -PROGRESS_LINE_RE = re.compile(r"^\s*-\s+Milestone\s+(\d+)\s*:\s*(.+?)\s+-\s+(.+?)\s*$") -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] -SOURCE_TICKET_RE = re.compile( - r"(?:^|\s)(?://|#warning\(?\"?|#|/\*||\"\)?\s*)?$", - re.IGNORECASE, -) -SOURCE_TICKET_EXTENSIONS = { - ".c", - ".cc", - ".cpp", - ".cs", - ".css", - ".go", - ".h", - ".hpp", - ".html", - ".java", - ".js", - ".jsx", - ".kt", - ".m", - ".mm", - ".py", - ".rb", - ".rs", - ".sh", - ".swift", - ".ts", - ".tsx", -} -IGNORED_SOURCE_PARTS = { - ".build", - ".git", - ".pytest_cache", - ".ruff_cache", - ".venv", - "__pycache__", - "node_modules", -} - -MILESTONE_SLOT = "__MILESTONES__" - - -@dataclass -class Finding: - finding_id: str - category: str - severity: str - message: str - file: str - auto_fixable: bool - - def to_dict(self) -> Dict[str, Any]: - return { - "finding_id": self.finding_id, - "category": self.category, - "severity": self.severity, - "message": self.message, - "file": self.file, - "auto_fixable": self.auto_fixable, - } - - -@dataclass -class ApplyAction: - action: str - reason: str - file: str - - def to_dict(self) -> Dict[str, str]: - return {"action": self.action, "reason": self.reason, "file": self.file} - - -@dataclass -class SmallTicketCandidate: - source: str - kind: str - title: str - detail: str - file: str = "" - line: Optional[int] = None - url: str = "" - number: Optional[int] = None - - def identity(self) -> str: - if self.url: - return self.url - if self.file and self.line is not None: - return f"{self.file}#L{self.line}" - return f"{self.source}:{self.kind}:{self.title}" - - def to_dict(self) -> Dict[str, Any]: - return { - "source": self.source, - "kind": self.kind, - "title": self.title, - "detail": self.detail, - "file": self.file, - "line": self.line, - "url": self.url, - "number": self.number, - "identity": self.identity(), - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded checklist ROADMAP maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--roadmap-path", help="Optional roadmap path (default: /ROADMAP.md)") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional roadmap config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when findings remain") - parser.add_argument( - "--collect-source-tickets", - action="store_true", - help="Scan source files for TODO/FIXME comments and report or append Small Tickets entries.", - ) - parser.add_argument( - "--collect-github-issues", - action="store_true", - help="Collect open GitHub issues with gh and report or append Small Tickets entries.", - ) - parser.add_argument( - "--github-repo", - help="Optional GitHub OWNER/REPO override for --collect-github-issues.", - ) - parser.add_argument( - "--ticket-section", - help=( - "Optional roadmap checklist target. Use 'Small Tickets', 'Backlog Candidates', " - "or 'Milestone N: Tickets'. Requires --run-mode apply and --ticket-text." - ), - ) - parser.add_argument("--ticket-text", help="Optional roadmap checklist item text to add or update.") - parser.add_argument( - "--ticket-state", - choices=["open", "done"], - default="open", - help="Checklist state for --ticket-text. Defaults to open.", - ) - parser.add_argument( - "--ticket-source", - help="Optional repo-relative source reference appended to a new checklist item.", - ) - parser.add_argument( - "--ticket-match", - help="Optional existing checklist item text to update instead of matching --ticket-text.", - ) - parser.add_argument( - "--allow-duplicate", - action="store_true", - help="Append --ticket-text even when a matching checklist item already exists.", - ) - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def relative_path(project_root: Path, path: Path) -> str: - try: - return path.relative_to(project_root).as_posix() - except ValueError: - return path.as_posix() - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "roadmap-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "roadmap-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - value = settings.get("requiredSections", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def section_order(settings: Dict[str, Any]) -> List[str]: - value = settings.get("sectionOrder", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def required_milestone_subsections(settings: Dict[str, Any]) -> List[str]: - value = settings.get("requiredMilestoneSubsections", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def status_values(settings: Dict[str, Any]) -> List[str]: - value = settings.get("statusValues", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - return {str(key): [str(item) for item in value] for key, value in raw.items() if isinstance(value, list)} - - -def subsection_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("milestoneSubsectionAliases", {}) - if not isinstance(raw, dict): - return {} - return {str(key): [str(item) for item in value] for key, value in raw.items() if isinstance(value, list)} - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def milestone_subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("milestoneSubsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def allow_additional_sections(settings: Dict[str, Any]) -> bool: - return bool(settings.get("allowAdditionalSections", True)) - - -def preserve_preamble(settings: Dict[str, Any]) -> bool: - return bool(settings.get("preservePreamble", True)) - - -def slugify_heading(heading: str) -> str: - slug = heading.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"\s+", "-", slug) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def build_toc(headings: Sequence[str]) -> str: - return "\n".join(f"- [{heading}](#{slugify_heading(heading)})" for heading in headings) - - -def toc_entries(body: str) -> List[str]: - entries: List[str] = [] - for line in body.splitlines(): - match = re.match(r"^\s*-\s+\[(.+?)\]\(#(.+?)\)\s*$", line.strip()) - if match: - entries.append(match.group(1).strip()) - return entries - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - heading = match.group(1).strip() - body = text[start:end].strip("\n") - sections.append((heading, body)) - return preamble, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - heading = match.group(1).strip() - subsection_body = body[start:end].strip("\n") - subsections.append((heading, subsection_body)) - return preamble, subsections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def parse_title(preamble: str) -> Tuple[Optional[str], List[str]]: - lines = [line.rstrip() for line in preamble.splitlines()] - title: Optional[str] = None - extras: List[str] = [] - title_index: Optional[int] = None - - for idx, line in enumerate(lines): - if line.startswith("# "): - title = line[2:].strip() - title_index = idx - break - - if title_index is None: - return None, lines - - for idx, line in enumerate(lines): - if idx == title_index: - continue - extras.append(line) - return title, extras - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, keep_extras: bool) -> str: - title, extras = parse_title(preamble) - normalized_title = title or "Project Roadmap" - - lines = [f"# {normalized_title}"] - if keep_extras: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - lines.extend(["", *extra_lines]) - return "\n".join(lines).strip() - - -def is_milestone_heading(heading: str) -> bool: - return MILESTONE_HEADING_RE.match(heading) is not None - - -def parse_milestone_heading(heading: str) -> Optional[Tuple[int, str]]: - match = MILESTONE_HEADING_RE.match(heading) - if not match: - return None - return int(match.group(1)), match.group(2).strip() - - -def parse_progress(body: str) -> Dict[int, Tuple[str, str]]: - progress: Dict[int, Tuple[str, str]] = {} - for line in body.splitlines(): - match = PROGRESS_LINE_RE.match(line) - if match: - progress[int(match.group(1))] = (match.group(2).strip(), match.group(3).strip()) - return progress - - -def alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - aliases = section_aliases(settings) - reverse: Dict[str, str] = {} - for canonical, names in aliases.items(): - for alias in names: - reverse[alias] = canonical - return reverse - - -def subsection_alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - aliases = subsection_aliases(settings) - reverse: Dict[str, str] = {} - for canonical, names in aliases.items(): - for alias in names: - reverse[alias] = canonical - return reverse - - -def render_template_bootstrap() -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "ROADMAP.template.md" - return normalize_whitespace(read_text(template_path)) - - -def has_legacy_format(text: str) -> bool: - if re.search(r"^##\s+Current Milestone\s*$", text, flags=re.MULTILINE): - return True - if re.search(r"^##\s+Milestones\s*$", text, flags=re.MULTILINE) and "|" in text: - return True - if re.search(r"\|\s*Milestone\s*\|", text, flags=re.IGNORECASE): - return True - return False - - -def is_ignored_source_path(path: Path) -> bool: - return any(part in IGNORED_SOURCE_PARTS for part in path.parts) - - -def source_ticket_title(body: str) -> str: - normalized = re.sub(r"\s+", " ", body).strip() - normalized = normalized.strip("*/#- ") - if len(normalized) <= 90: - return normalized - return normalized[:87].rstrip() + "..." - - -def collect_source_ticket_candidates(project_root: Path) -> List[SmallTicketCandidate]: - candidates: List[SmallTicketCandidate] = [] - for path in sorted(project_root.rglob("*")): - if not path.is_file() or is_ignored_source_path(path): - continue - if path.suffix.lower() not in SOURCE_TICKET_EXTENSIONS: - continue - rel_path = relative_path(project_root, path) - if rel_path == "ROADMAP.md": - continue - try: - lines = read_text(path).splitlines() - except OSError: - continue - for line_number, line in enumerate(lines, start=1): - match = SOURCE_TICKET_RE.search(line) - if not match: - continue - body = match.group("body").strip() - if not body or re.fullmatch(r"(TODO|FIXME)-\d+", body, flags=re.IGNORECASE): - continue - kind = match.group("kind").upper() - candidates.append( - SmallTicketCandidate( - source="source", - kind=kind, - title=source_ticket_title(body), - detail=body, - file=rel_path, - line=line_number, - ) - ) - return candidates - - -def run_gh_issue_list(project_root: Path, github_repo: Optional[str]) -> subprocess.CompletedProcess[str]: - args = [ - "gh", - "issue", - "list", - "--state", - "open", - "--limit", - "100", - "--json", - "number,title,url,labels", - ] - if github_repo: - args.extend(["--repo", github_repo]) - return subprocess.run(args, cwd=project_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - -def collect_github_issue_candidates(project_root: Path, github_repo: Optional[str]) -> Tuple[List[SmallTicketCandidate], List[str]]: - result = run_gh_issue_list(project_root, github_repo) - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "gh issue list failed without output" - return [], [f"GitHub issue collection failed: {detail}"] - try: - issues = json.loads(result.stdout or "[]") - except json.JSONDecodeError as error: - return [], [f"GitHub issue collection returned invalid JSON: {error}"] - if not isinstance(issues, list): - return [], ["GitHub issue collection returned an unexpected JSON shape."] - - candidates: List[SmallTicketCandidate] = [] - for issue in issues: - if not isinstance(issue, dict): - continue - title = str(issue.get("title", "")).strip() - url = str(issue.get("url", "")).strip() - number_value = issue.get("number") - number = number_value if isinstance(number_value, int) else None - if not title or not url or number is None: - continue - labels = issue.get("labels", []) - label_names = [ - str(label.get("name", "")).strip() - for label in labels - if isinstance(label, dict) and str(label.get("name", "")).strip() - ] - candidates.append( - SmallTicketCandidate( - source="github", - kind="GitHub Issue", - title=title, - detail=", ".join(label_names), - url=url, - number=number, - ) - ) - return candidates, [] - - -def parse_legacy_milestones(text: str) -> List[Tuple[int, str, str]]: - rows: List[Tuple[int, str, str]] = [] - lines = text.splitlines() - in_table = False - for line in lines: - if re.match(r"^\|\s*Milestone\s*\|", line, flags=re.IGNORECASE): - in_table = True - continue - if in_table and re.match(r"^\|\s*[-:]+\s*\|", line): - continue - if in_table and line.strip().startswith("|"): - cols = [c.strip() for c in line.strip().strip("|").split("|")] - if len(cols) >= 2: - name = cols[0] - status = cols[1] - match = re.search(r"(\d+)", name) - idx = int(match.group(1)) if match else len(rows) - title = re.sub(r"^Milestone\s*\d+\s*[:\-]?\s*", "", name, flags=re.IGNORECASE).strip() or name - rows.append((idx, title, status)) - elif in_table and line.strip() == "": - in_table = False - return sorted(rows, key=lambda item: item[0]) - - -def build_migrated_from_legacy(text: str, settings: Dict[str, Any]) -> str: - rows = parse_legacy_milestones(text) - if not rows: - rows = [(0, "Foundation", "Planned")] - - section_template_map = section_templates(settings) - subsection_template_map = milestone_subsection_templates(settings) - required = required_sections(settings) - order = section_order(settings) - milestone_children = required_milestone_subsections(settings) - - section_bodies: Dict[str, str] = { - "Vision": "- Preserve the long-term project direction while migrating this roadmap into checklist format.", - "Product Principles": "- Keep roadmap updates checklist-based, reviewable, and tied to real delivery.", - "Small Tickets": section_template_map.get("Small Tickets", ""), - "Backlog Candidates": section_template_map.get("Backlog Candidates", ""), - } - - milestones: List[Tuple[int, str, str]] = [] - for idx, title, status in rows: - lines: List[str] = [] - for child in milestone_children: - template = subsection_template_map.get(child, "") - if child == "Status": - template = status.strip() or template - elif child == "Scope": - template = f"- [ ] Preserve or restate the milestone scope from the legacy roadmap entry ({status})." - elif child == "Tickets": - template = "- [ ] Reconcile legacy milestone work into explicit checklist tickets." - elif child == "Exit Criteria": - template = "- [ ] Confirm this migrated milestone is complete, current, and internally consistent." - lines.extend([f"### {child}", "", template.strip(), ""]) - milestones.append((idx, title, "\n".join(lines).strip())) - - progress_lines = [f"- Milestone {idx}: {title} - {status.strip() or 'Planned'}" for idx, title, status in rows] - section_bodies["Milestone Progress"] = "\n".join(progress_lines).strip() - - return render_document( - title="Project Roadmap", - preamble_lines=[], - ordered_section_bodies=section_bodies, - milestones=milestones, - extra_sections=[], - order=order, - required=required, - allow_additional=allow_additional_sections(settings), - ) - - -def normalize_milestone_subsection_body(body: str, subsection_name: str) -> str: - normalized_lines: List[str] = [] - for line in body.splitlines(): - fixed = re.sub(r"^\s*-\s+\[(X)\]\s+", "- [x] ", line) - if "[P]" in fixed and subsection_name != "Tickets": - fixed = fixed.replace("[P]", "").replace(" ", " ").rstrip() - normalized_lines.append(fixed) - return "\n".join(normalized_lines).strip() - - -def render_milestone_body(existing_body: str, settings: Dict[str, Any]) -> str: - required_children = required_milestone_subsections(settings) - template_map = milestone_subsection_templates(settings) - alias_map = subsection_alias_lookup(settings) - _preamble, subsections = split_subsections(existing_body) - canonical_lookup: Dict[str, str] = {} - - for name, body in subsections: - canonical = alias_map.get(name, name) - canonical_lookup[canonical] = body - - lines: List[str] = [] - for idx, child in enumerate(required_children): - child_body = canonical_lookup.get(child, "").strip() or template_map.get(child, "") - child_body = normalize_milestone_subsection_body(child_body, child) - lines.extend([f"### {child}", "", child_body.strip()]) - if idx < len(required_children) - 1: - lines.append("") - - extras = [(name, body) for name, body in subsections if alias_map.get(name, name) not in set(required_children)] - if extras: - lines.append("") - for idx, (name, body) in enumerate(extras): - lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extras) - 1: - lines.append("") - - return "\n".join(lines).strip() - - -def render_document( - title: str, - preamble_lines: Sequence[str], - ordered_section_bodies: Dict[str, str], - milestones: Sequence[Tuple[int, str, str]], - extra_sections: Sequence[Tuple[str, str]], - order: Sequence[str], - required: Sequence[str], - allow_additional: bool, -) -> str: - rendered_lines: List[str] = [f"# {title}"] - if preamble_lines: - rendered_lines.extend(["", *preamble_lines]) - - toc_headings: List[str] = [] - for item in order: - if item == MILESTONE_SLOT: - toc_headings.extend(f"Milestone {idx}: {name}" for idx, name, _body in milestones) - else: - toc_headings.append(item) - if allow_additional: - toc_headings.extend(heading for heading, _body in extra_sections) - - rendered_lines.extend(["", "## Table of Contents", "", build_toc(toc_headings)]) - - for item in order: - if item == MILESTONE_SLOT: - for idx, name, body in milestones: - rendered_lines.extend(["", f"## Milestone {idx}: {name}", "", body.strip()]) - continue - body = ordered_section_bodies.get(item, "").strip() - rendered_lines.extend(["", f"## {item}", "", body]) - - if allow_additional: - for heading, body in extra_sections: - rendered_lines.extend(["", f"## {heading}", "", body.strip()]) - - return normalize_whitespace("\n".join(rendered_lines)) - - -def small_ticket_line(candidate: SmallTicketCandidate) -> str: - if candidate.source == "github" and candidate.url and candidate.number is not None: - return f"- [ ] GitHub #{candidate.number}: {candidate.title} ([#{candidate.number}]({candidate.url}))" - if candidate.file and candidate.line is not None: - file_link = f"{candidate.file}#L{candidate.line}" - return f"- [ ] {candidate.kind}: {candidate.title} ([{candidate.file}:{candidate.line}]({file_link}))" - return f"- [ ] {candidate.kind}: {candidate.title}" - - -def existing_small_ticket_body(roadmap_text: str) -> str: - _preamble, sections = split_sections(roadmap_text) - return section_map(sections).get("Small Tickets", "") - - -def filter_new_small_ticket_candidates(roadmap_text: str, candidates: Sequence[SmallTicketCandidate]) -> List[SmallTicketCandidate]: - existing_text = roadmap_text - existing_small_tickets = existing_small_ticket_body(roadmap_text) - new_candidates: List[SmallTicketCandidate] = [] - for candidate in candidates: - identity = candidate.identity() - if identity in existing_text: - continue - if small_ticket_line(candidate) in existing_small_tickets: - continue - new_candidates.append(candidate) - return new_candidates - - -def append_small_ticket_candidates(roadmap_text: str, candidates: Sequence[SmallTicketCandidate]) -> Tuple[str, int]: - if not candidates: - return roadmap_text, 0 - preamble, sections = split_sections(roadmap_text) - updated_sections: List[Tuple[str, str]] = [] - inserted = 0 - found = False - for heading, body in sections: - if heading != "Small Tickets": - updated_sections.append((heading, body)) - continue - found = True - lines = body.rstrip().splitlines() if body.strip() else [] - if lines and lines[-1].strip(): - lines.append("") - for candidate in candidates: - lines.append(small_ticket_line(candidate)) - inserted += 1 - updated_sections.append((heading, "\n".join(lines).strip())) - if not found: - return roadmap_text, 0 - - rendered = preamble.strip() - for heading, body in updated_sections: - rendered += f"\n\n## {heading}\n\n{body.strip()}" - return normalize_whitespace(rendered), inserted - - -def normalize_ticket_text(text: str) -> str: - normalized = re.sub(r"^\s*-\s+\[[ xX]\]\s+", "", text).strip() - normalized = re.sub(r"\s+\([^)]*\)\s*$", "", normalized).strip() - normalized = re.sub(r"\s+", " ", normalized) - return normalized - - -def roadmap_ticket_line(text: str, state: str, source: str = "") -> str: - checkbox = "x" if state == "done" else " " - line = f"- [{checkbox}] {normalize_ticket_text(text)}" - if source: - line += f" ({source})" - return line - - -def render_sections(preamble: str, sections: Sequence[Tuple[str, str]]) -> str: - rendered = preamble.strip() - for heading, body in sections: - rendered += f"\n\n## {heading}\n\n{body.strip()}" - return normalize_whitespace(rendered) - - -def parse_ticket_section(section: str) -> Tuple[str, Optional[int], str]: - normalized = section.strip() - milestone_match = re.fullmatch( - r"Milestone\s+(\d+)(?::\s*(?:Tickets)?)?", - normalized, - flags=re.IGNORECASE, - ) - if milestone_match: - return "milestone", int(milestone_match.group(1)), "Tickets" - - milestone_tickets_match = re.fullmatch( - r"Milestone\s+(\d+)\s*:\s*Tickets", - normalized, - flags=re.IGNORECASE, - ) - if milestone_tickets_match: - return "milestone", int(milestone_tickets_match.group(1)), "Tickets" - - if normalized in {"Small Tickets", "Backlog Candidates"}: - return "top-level", None, normalized - - return "unknown", None, normalized - - -def mutate_checklist_body( - body: str, - *, - ticket_text: str, - ticket_state: str, - ticket_source: str, - ticket_match: Optional[str], - allow_duplicate: bool, -) -> Tuple[str, str]: - desired_text = normalize_ticket_text(ticket_text) - match_text = normalize_ticket_text(ticket_match or ticket_text) - new_line = roadmap_ticket_line(desired_text, ticket_state, ticket_source) - lines = body.rstrip().splitlines() if body.strip() else [] - - if not allow_duplicate: - for index, line in enumerate(lines): - if not CHECKBOX_RE.match(line): - continue - if normalize_ticket_text(line) != match_text: - continue - lines[index] = roadmap_ticket_line(desired_text, ticket_state, ticket_source) - return "\n".join(lines).strip(), "update-roadmap-ticket" - - if lines and lines[-1].strip(): - lines.append("") - lines.append(new_line) - return "\n".join(lines).strip(), "add-roadmap-ticket" - - -def apply_roadmap_ticket_request( - project_root: Path, - roadmap_text: str, - *, - section: str, - ticket_text: str, - ticket_state: str, - ticket_source: str, - ticket_match: Optional[str], - allow_duplicate: bool, -) -> Tuple[str, ApplyAction]: - source = ticket_source.strip() - if source: - source_path = Path(source).expanduser() - if source_path.is_absolute(): - try: - source = source_path.resolve().relative_to(project_root).as_posix() - except ValueError as error: - raise ValueError( - "Roadmap ticket source must be repo-relative or inside the project root." - ) from error - - target_type, milestone_number, target_name = parse_ticket_section(section) - if target_type == "unknown": - raise ValueError( - "Unsupported --ticket-section. Use 'Small Tickets', 'Backlog Candidates', " - "or 'Milestone N: Tickets'." - ) - - preamble, sections = split_sections(roadmap_text) - - if target_type == "top-level": - updated_sections: List[Tuple[str, str]] = [] - action_name: Optional[str] = None - for heading, body in sections: - if heading != target_name: - updated_sections.append((heading, body)) - continue - updated_body, action_name = mutate_checklist_body( - body, - ticket_text=ticket_text, - ticket_state=ticket_state, - ticket_source=source, - ticket_match=ticket_match, - allow_duplicate=allow_duplicate, - ) - updated_sections.append((heading, updated_body)) - if action_name: - return render_sections(preamble, updated_sections), ApplyAction( - action=action_name, - reason=f"Updated {target_name} with roadmap ticket: {normalize_ticket_text(ticket_text)}.", - file="ROADMAP.md", - ) - raise ValueError(f"ROADMAP is missing required section '## {target_name}'.") - - updated_sections = [] - milestone_action_name: Optional[str] = None - for heading, body in sections: - parsed = parse_milestone_heading(heading) - if not parsed or parsed[0] != milestone_number: - updated_sections.append((heading, body)) - continue - - sub_preamble, subsections = split_subsections(body) - updated_subsections: List[Tuple[str, str]] = [] - found_tickets = False - for subheading, subbody in subsections: - if subheading != target_name: - updated_subsections.append((subheading, subbody)) - continue - found_tickets = True - updated_body, milestone_action_name = mutate_checklist_body( - subbody, - ticket_text=ticket_text, - ticket_state=ticket_state, - ticket_source=source, - ticket_match=ticket_match, - allow_duplicate=allow_duplicate, - ) - updated_subsections.append((subheading, updated_body)) - - if not found_tickets: - raise ValueError(f"Milestone {milestone_number} is missing '### Tickets'.") - - rendered_body = sub_preamble.strip() - for subheading, subbody in updated_subsections: - rendered_body += f"\n\n### {subheading}\n\n{subbody.strip()}" - updated_sections.append((heading, rendered_body.strip())) - if milestone_action_name: - return render_sections(preamble, updated_sections), ApplyAction( - action=milestone_action_name, - reason=( - f"Updated Milestone {milestone_number} Tickets with roadmap ticket: " - f"{normalize_ticket_text(ticket_text)}." - ), - file="ROADMAP.md", - ) - - raise ValueError(f"ROADMAP is missing milestone {milestone_number}.") - - -def validate_schema( - project_root: Path, - roadmap_path: Path, - roadmap_text: str, - config: Dict[str, Any], -) -> List[Finding]: - settings = config_settings(config) - required = required_sections(settings) - section_alias_map = alias_lookup(settings) - milestone_child_alias_map = subsection_alias_lookup(settings) - required_children = required_milestone_subsections(settings) - allowed_status_values = status_values(settings) - - findings: List[Finding] = [] - preamble, sections = split_sections(roadmap_text) - lookup = section_map(sections) - title, _extras = parse_title(preamble) - - if not title: - findings.append( - Finding( - finding_id="missing-title", - category="schema", - severity="high", - message="ROADMAP is missing a top-level '# ' heading.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if "Table of Contents" not in lookup: - findings.append( - Finding( - finding_id="missing-table-of-contents", - category="schema", - severity="medium", - message="ROADMAP is missing the required '## Table of Contents' section.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - headings = [heading for heading, _body in sections] - milestones = [(heading, body) for heading, body in sections if is_milestone_heading(heading)] - milestone_numbers: List[int] = [] - - for heading in required: - if heading not in lookup: - alias_found = next((alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in lookup), None) - if alias_found: - findings.append( - Finding( - finding_id=f"non-canonical-heading-{slugify_heading(heading)}", - category="schema", - severity="medium", - message=f"ROADMAP uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - else: - findings.append( - Finding( - finding_id=f"missing-section-{slugify_heading(heading)}", - category="schema", - severity="high", - message=f"ROADMAP is missing required section '## {heading}'.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if not milestones: - findings.append( - Finding( - finding_id="missing-milestones", - category="schema", - severity="high", - message="ROADMAP is missing milestone sections (expected headings like '## Milestone N: Name').", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - for heading, body in milestones: - parsed = parse_milestone_heading(heading) - if not parsed: - continue - number, _name = parsed - milestone_numbers.append(number) - _sub_preamble, subsections = split_subsections(body) - found_names = [milestone_child_alias_map.get(name, name) for name, _sub_body in subsections] - subsection_lookup = {milestone_child_alias_map.get(name, name): sub_body for name, sub_body in subsections} - for child in required_children: - if child not in found_names: - findings.append( - Finding( - finding_id=f"milestone-{number}-missing-{slugify_heading(child)}", - category="schema", - severity="high", - message=f"Milestone {number} is missing required subsection '### {child}'.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - continue - lines = [line for line in subsection_lookup[child].splitlines() if line.strip()] - if child != "Status" and not any(CHECKBOX_RE.match(line) for line in lines): - findings.append( - Finding( - finding_id=f"milestone-{number}-{slugify_heading(child)}-missing-checklists", - category="schema", - severity="medium", - message=f"Milestone {number} subsection '{child}' should contain checklist items.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - status_body = subsection_lookup.get("Status", "").strip() - if status_body: - status_lines = [line.strip() for line in status_body.splitlines() if line.strip()] - if len(status_lines) != 1: - findings.append( - Finding( - finding_id=f"milestone-{number}-status-format", - category="schema", - severity="medium", - message=f"Milestone {number} status should be a single plain status value.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - elif allowed_status_values and status_lines[0] not in allowed_status_values: - findings.append( - Finding( - finding_id=f"milestone-{number}-invalid-status", - category="schema", - severity="medium", - message=f"Milestone {number} status '{status_lines[0]}' is not in the allowed status vocabulary.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - current_block = "" - for line in body.splitlines(): - if line.startswith("### "): - current_block = milestone_child_alias_map.get(line[4:].strip(), line[4:].strip()) - continue - if ANY_CHECKBOX_RE.match(line) and not CHECKBOX_RE.match(line): - findings.append( - Finding( - finding_id=f"invalid-checkbox-milestone-{number}-{slugify_heading(line)}", - category="schema", - severity="medium", - message=f"Milestone {number} contains invalid checkbox syntax; use [ ] or [x].", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - if "[P]" in line and current_block != "Tickets": - findings.append( - Finding( - finding_id=f"parallel-marker-milestone-{number}", - category="schema", - severity="medium", - message=f"Milestone {number} uses '[P]' outside the 'Tickets' subsection.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if any(pattern.search(body) for pattern in PLACEHOLDER_PATTERNS): - findings.append( - Finding( - finding_id=f"placeholder-content-milestone-{number}", - category="content-quality", - severity="medium", - message=f"Milestone {number} contains placeholder-style content.", - file=str(roadmap_path), - auto_fixable=False, - ) - ) - - if milestone_numbers and milestone_numbers != sorted(milestone_numbers): - findings.append( - Finding( - finding_id="milestone-order", - category="schema", - severity="medium", - message="Milestone sections are not in deterministic ascending order.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if "Milestone Progress" in lookup and milestones: - progress = parse_progress(lookup["Milestone Progress"]) - parsed_milestones = [ - parsed - for parsed in (parse_milestone_heading(heading) for heading, _body in milestones) - if parsed is not None - ] - milestone_statuses: Dict[int, str] = {} - for heading, body in milestones: - parsed = parse_milestone_heading(heading) - if not parsed: - continue - number, _name = parsed - _sub_preamble, subsections = split_subsections(body) - subsection_lookup = {milestone_child_alias_map.get(name, name): sub_body for name, sub_body in subsections} - status_lines = [line.strip() for line in subsection_lookup.get("Status", "").splitlines() if line.strip()] - milestone_statuses[number] = status_lines[0] if status_lines else "" - expected = [ - f"Milestone {number}: {name} - {milestone_statuses.get(number, '').strip()}" - for number, name in sorted(parsed_milestones, key=lambda item: item[0]) - ] - actual = [f"Milestone {number}: {title} - {status}" for number, (title, status) in sorted(progress.items())] - if actual != expected: - findings.append( - Finding( - finding_id="stale-milestone-progress", - category="schema", - severity="medium", - message="Milestone Progress does not match the current milestone section list, order, and statuses.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if "Table of Contents" in lookup: - expected_toc = [heading for heading, _body in sections if heading != "Table of Contents"] - actual_toc = toc_entries(lookup["Table of Contents"]) - if actual_toc != expected_toc: - findings.append( - Finding( - finding_id="stale-table-of-contents", - category="schema", - severity="low", - message="Table of contents entries do not match the canonical roadmap headings in order.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - positions = {heading: index for index, heading in enumerate(headings)} - if "Milestone Progress" in positions and milestones: - first_milestone_position = min(positions[heading] for heading, _body in milestones) - if positions["Milestone Progress"] > first_milestone_position: - findings.append( - Finding( - finding_id="milestone-progress-order", - category="schema", - severity="medium", - message="'Milestone Progress' should appear before milestone sections.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - if "Backlog Candidates" in positions and milestones: - last_milestone_position = max(positions[heading] for heading, _body in milestones) - if positions["Backlog Candidates"] < last_milestone_position: - findings.append( - Finding( - finding_id="backlog-order", - category="schema", - severity="medium", - message="'Backlog Candidates' should appear after milestone sections.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - if "Small Tickets" in positions and milestones: - last_milestone_position = max(positions[heading] for heading, _body in milestones) - if positions["Small Tickets"] < last_milestone_position: - findings.append( - Finding( - finding_id="small-tickets-order", - category="schema", - severity="medium", - message="'Small Tickets' should appear after milestone sections.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - legacy_todo_path = project_root / "TODO.md" - if "Small Tickets" in lookup and legacy_todo_path.is_file() and legacy_todo_path.resolve() != roadmap_path.resolve(): - findings.append( - Finding( - finding_id="legacy-todo-md-migration-needed", - category="schema", - severity="medium", - message=( - "Root TODO.md exists while ROADMAP.md has a canonical Small Tickets section. " - "Migrate useful TODO.md backlog items into ROADMAP.md milestones, Small Tickets, " - "or Backlog Candidates, then remove TODO.md in a reviewed documentation pass." - ), - file=str(legacy_todo_path), - auto_fixable=False, - ) - ) - - if has_legacy_format(roadmap_text): - findings.append( - Finding( - finding_id="legacy-format", - category="schema", - severity="high", - message="Legacy roadmap sections detected (`Current Milestone` / `Milestones` table).", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - return findings - - -def apply_fixes(project_root: Path, roadmap_path: Path, roadmap_text: str, config: Dict[str, Any]) -> Tuple[str, List[ApplyAction]]: - if not roadmap_text.strip(): - bootstrap = render_template_bootstrap() - write_text(roadmap_path, bootstrap) - return ( - bootstrap, - [ - ApplyAction( - action="create-roadmap-from-template", - reason="Created a missing ROADMAP.md from the bundled canonical roadmap template.", - file=str(roadmap_path), - ) - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = section_order(settings) - section_alias_map = alias_lookup(settings) - template_map = section_templates(settings) - allow_additional = allow_additional_sections(settings) - keep_preamble = preserve_preamble(settings) - - if has_legacy_format(roadmap_text): - migrated = build_migrated_from_legacy(roadmap_text, settings) - write_text(roadmap_path, migrated) - return ( - migrated, - [ - ApplyAction( - action="migrate-legacy-roadmap", - reason="Migrated a legacy roadmap layout into the canonical checklist roadmap structure.", - file=str(roadmap_path), - ) - ], - ) - - preamble, sections = split_sections(roadmap_text) - normalized_preamble = normalize_preamble(preamble, keep_preamble) - title, preamble_extras = parse_title(normalized_preamble) - title = title or "Project Roadmap" - preamble_lines = collapse_blank_lines(preamble_extras) if keep_preamble else [] - existing_lookup = section_map(sections) - - section_bodies: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - milestones: List[Tuple[int, str, str]] = [] - progress = parse_progress(existing_lookup.get("Milestone Progress", "")) - used_aliases: List[str] = [] - - for heading, body in sections: - if heading == "Table of Contents": - continue - parsed = parse_milestone_heading(heading) - if parsed: - number, name = parsed - milestones.append((number, name, render_milestone_body(body, settings))) - continue - canonical = section_alias_map.get(heading, heading) - if canonical in required: - if heading != canonical: - used_aliases.append(heading) - section_bodies[canonical] = body.strip() - elif allow_additional: - extra_sections.append((heading, body.strip())) - - if not milestones: - template_text = render_template_bootstrap() - _template_preamble, template_sections = split_sections(template_text) - for heading, body in template_sections: - if is_milestone_heading(heading): - parsed = parse_milestone_heading(heading) - if parsed: - milestones.append((parsed[0], parsed[1], body.strip())) - - milestones = sorted(milestones, key=lambda item: item[0]) - - for heading in required: - if heading == "Milestone Progress": - continue - body = section_bodies.get(heading, "").strip() or template_map.get(heading, "") - section_bodies[heading] = body.strip() - - milestone_alias_map = subsection_alias_lookup(settings) - progress_lines = [] - for number, name, body in milestones: - _sub_preamble, subsections = split_subsections(body) - subsection_lookup = {milestone_alias_map.get(subheading, subheading): sub_body for subheading, sub_body in subsections} - status_line_candidates = [line.strip() for line in subsection_lookup.get("Status", "").splitlines() if line.strip()] - status_value = status_line_candidates[0] if status_line_candidates else progress.get(number, (name, "Planned"))[1] - progress_lines.append(f"- Milestone {number}: {name} - {status_value}") - section_bodies["Milestone Progress"] = "\n".join(progress_lines).strip() - - updated = render_document( - title=title, - preamble_lines=preamble_lines, - ordered_section_bodies=section_bodies, - milestones=milestones, - extra_sections=extra_sections, - order=order, - required=required, - allow_additional=allow_additional, - ) - - actions: List[ApplyAction] = [] - if updated != normalize_whitespace(roadmap_text): - write_text(roadmap_path, updated) - actions.append( - ApplyAction( - action="normalize-roadmap-schema", - reason="Normalized the roadmap into the configured canonical checklist structure.", - file=str(roadmap_path), - ) - ) - if used_aliases: - actions.append( - ApplyAction( - action="migrate-alias-headings", - reason=f"Migrated alias headings into canonical heading names: {', '.join(sorted(set(used_aliases)))}.", - file=str(roadmap_path), - ) - ) - - return updated, actions - - -def markdown_report(report: Dict[str, Any]) -> str: - lines = [ - "# Maintain Project Roadmap Report", - "", - "## Run Context", - "", - f"- Project root: `{report['run_context']['project_root']}`", - f"- Roadmap path: `{report['run_context']['roadmap_path']}`", - f"- Run mode: `{report['run_context']['run_mode']}`", - f"- Timestamp: `{report['run_context']['timestamp_utc']}`", - "", - "## Customization State", - "", - f"- Config path: `{report['customization_state'].get('config_path', 'none')}`", - f"- Default config path: `{report['customization_state'].get('default_config_path', 'none')}`", - f"- Profile: `{report['customization_state'].get('profile', 'base')}`", - f"- Customized: `{report['customization_state'].get('is_customized', False)}`", - "", - "## Schema Contract", - "", - f"- Required sections: `{', '.join(report['schema_contract'].get('required_sections', []))}`", - f"- Canonical order: `{', '.join(report['schema_contract'].get('section_order', []))}`", - f"- Required milestone subsections: `{', '.join(report['schema_contract'].get('required_milestone_subsections', []))}`", - "", - "## Findings", - "", - ] - - if report["findings"]: - lines.extend( - f"- `{finding['severity']}` `{finding['finding_id']}`: {finding['message']}" - for finding in report["findings"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Changes Applied", ""]) - if report["apply_actions"]: - lines.extend(f"- `{action['action']}`: {action['reason']}" for action in report["apply_actions"]) - else: - lines.append("- None.") - - lines.extend(["", "## Small Ticket Candidates", ""]) - if report["small_ticket_candidates"]: - for candidate in report["small_ticket_candidates"]: - if candidate["source"] == "github": - lines.append(f"- GitHub #{candidate['number']}: {candidate['title']} ({candidate['url']})") - elif candidate["file"] and candidate["line"]: - lines.append( - f"- {candidate['kind']} in `{candidate['file']}:{candidate['line']}`: {candidate['title']}" - ) - else: - lines.append(f"- {candidate['kind']}: {candidate['title']}") - else: - lines.append("- None.") - - lines.extend(["", "## Errors", ""]) - if report["errors"]: - lines.extend(f"- {error}" for error in report["errors"]) - else: - lines.append("- None.") - - return "\n".join(lines).rstrip() + "\n" - - -def unresolved_issues(report: Dict[str, Any]) -> List[Dict[str, Any]]: - return list(report["findings"]) - - -def schema_contract(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config_settings(config) - return { - "required_sections": required_sections(settings), - "section_order": section_order(settings), - "required_milestone_subsections": required_milestone_subsections(settings), - "status_values": status_values(settings), - } - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - roadmap_path = Path(args.roadmap_path).expanduser().resolve() if args.roadmap_path else (project_root / "ROADMAP.md") - - report: Dict[str, Any] = { - "run_context": { - "project_root": str(project_root), - "roadmap_path": str(roadmap_path), - "run_mode": args.run_mode, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - }, - "customization_state": {}, - "schema_contract": {}, - "findings": [], - "small_ticket_candidates": [], - "apply_actions": [], - "errors": [], - } - - ticket_requested = bool(args.ticket_section or args.ticket_text or args.ticket_match or args.ticket_source) - if ticket_requested: - if args.run_mode != "apply": - report["errors"].append("Roadmap ticket mutation requires --run-mode apply.") - if not args.ticket_section: - report["errors"].append("Roadmap ticket mutation requires --ticket-section.") - if not args.ticket_text: - report["errors"].append("Roadmap ticket mutation requires --ticket-text.") - - if not project_root.is_dir(): - report["errors"].append(f"Project root does not exist or is not a directory: {project_root}") - return report, markdown_report(report) - - config = load_config(project_root, args.config) - report["customization_state"] = { - "config_path": config.get("configPath", "none"), - "default_config_path": config.get("defaultConfigPath", "none"), - "profile": config.get("profile", "base"), - "is_customized": bool(config.get("isCustomized", False)), - } - report["schema_contract"] = schema_contract(config) - - if roadmap_path.is_file(): - roadmap_text = read_text(roadmap_path) - findings = validate_schema(project_root, roadmap_path, roadmap_text, config) - report["findings"] = [finding.to_dict() for finding in findings] - elif args.run_mode == "apply": - roadmap_text = "" - report["findings"] = [ - Finding( - finding_id="missing-roadmap", - category="schema", - severity="high", - message=f"ROADMAP file is missing at {roadmap_path}.", - file=str(roadmap_path), - auto_fixable=True, - ).to_dict() - ] - else: - roadmap_text = "" - report["findings"] = [ - Finding( - finding_id="missing-roadmap", - category="schema", - severity="high", - message=f"ROADMAP file is missing at {roadmap_path}.", - file=str(roadmap_path), - auto_fixable=True, - ).to_dict() - ] - - small_ticket_candidates: List[SmallTicketCandidate] = [] - if args.collect_source_tickets: - small_ticket_candidates.extend(collect_source_ticket_candidates(project_root)) - if args.collect_github_issues: - github_candidates, github_errors = collect_github_issue_candidates(project_root, args.github_repo) - small_ticket_candidates.extend(github_candidates) - report["errors"].extend(github_errors) - if small_ticket_candidates: - small_ticket_candidates = filter_new_small_ticket_candidates(roadmap_text, small_ticket_candidates) - report["small_ticket_candidates"] = [candidate.to_dict() for candidate in small_ticket_candidates] - - if args.run_mode == "apply" and not report["errors"]: - updated_text, actions = apply_fixes(project_root, roadmap_path, roadmap_text, config) - new_candidates = filter_new_small_ticket_candidates(updated_text, small_ticket_candidates) - updated_text, inserted_count = append_small_ticket_candidates(updated_text, new_candidates) - if inserted_count: - write_text(roadmap_path, updated_text) - actions.append( - ApplyAction( - action="append-small-ticket-candidates", - reason=f"Appended {inserted_count} collected source or GitHub issue candidate(s) to Small Tickets.", - file=str(roadmap_path), - ) - ) - if ticket_requested and args.ticket_section and args.ticket_text: - try: - updated_text, ticket_action = apply_roadmap_ticket_request( - project_root, - updated_text, - section=args.ticket_section, - ticket_text=args.ticket_text, - ticket_state=args.ticket_state, - ticket_source=args.ticket_source or "", - ticket_match=args.ticket_match, - allow_duplicate=bool(args.allow_duplicate), - ) - write_text(roadmap_path, updated_text) - actions.append(ticket_action) - except ValueError as error: - report["errors"].append(str(error)) - report["apply_actions"] = [action.to_dict() for action in actions] - post_findings = validate_schema(project_root, roadmap_path, updated_text, config) - report["findings"] = [finding.to_dict() for finding in post_findings] - - markdown = markdown_report(report) - return report, markdown - - -def main() -> int: - args = parse_args() - report, markdown = run_maintenance(args) - payload = json.dumps(report, indent=2, sort_keys=True) + "\n" - - if args.json_out: - write_text(Path(args.json_out), payload) - if args.md_out: - write_text(Path(args.md_out), markdown) - - if args.print_json: - sys.stdout.write(payload) - elif args.print_md: - sys.stdout.write(markdown) - else: - if ( - not unresolved_issues(report) - and not report["small_ticket_candidates"] - and not report["apply_actions"] - and not report["errors"] - ): - sys.stdout.write("No findings.\n") - else: - sys.stdout.write(markdown) - - if report["errors"]: - return 1 - if args.fail_on_issues and (unresolved_issues(report) or report["errors"]): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/repo-maintenance/config/profile.json b/scripts/repo-maintenance/config/profile.json new file mode 100644 index 000000000..17a1b7278 --- /dev/null +++ b/scripts/repo-maintenance/config/profile.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "profile": "generic" +} diff --git a/scripts/repo-maintenance/docs/agents/AGENTS.template.md b/scripts/repo-maintenance/docs/agents/AGENTS.template.md new file mode 100644 index 000000000..217754745 --- /dev/null +++ b/scripts/repo-maintenance/docs/agents/AGENTS.template.md @@ -0,0 +1,65 @@ +# AGENTS.md + +Use this file for durable repo-local guidance that Codex should follow before changing code, docs, or project workflow surfaces in this repository. + +## Repository Scope + +### What This File Covers + +Explain what this root-level AGENTS file governs for the repository. + +### Where To Look First + +Point to the few highest-value docs, directories, or files Codex should check first before it starts reading broadly. + +## Working Rules + +### Change Scope + +Explain how to keep work bounded and what kinds of scope expansion should be surfaced explicitly. + +### Source of Truth + +Explain which files, docs, or project surfaces Codex should trust first when there is ambiguity. + +### Communication and Escalation + +Explain when Codex should stop, surface tradeoffs, or ask before widening scope, especially when the next step has non-obvious consequences. + +## Commands + +### Setup + +Document the grounded setup or sync commands for the repository. + +### Validation + +Document the grounded validation commands for the repository. + +### Optional Project Commands + +Document any other important repo-specific commands here, or say plainly that there are no additional project commands worth calling out. + +## Review and Delivery + +### Review Expectations + +Explain what Codex should include or check before handing work back for review. + +### Definition of Done + +Explain what must be true before work should be considered complete in this repository, including grounded verification and any nearby docs or tests that should be updated. + +## Safety Boundaries + +### Never Do + +List the highest-signal actions Codex must not take in this repository. + +### Ask Before + +List the decisions or changes that require explicit approval first. + +## Local Overrides + +Explain whether more specific AGENTS files or fallback instruction files exist in subdirectories, and make clear that deeper guidance refines this root file when work happens there. diff --git a/scripts/repo-maintenance/docs/agents/document.contract.json b/scripts/repo-maintenance/docs/agents/document.contract.json new file mode 100644 index 000000000..c16e8ed64 --- /dev/null +++ b/scripts/repo-maintenance/docs/agents/document.contract.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "document": "agents", + "targetFile": "AGENTS.md", + "requireTableOfContents": false, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Repository Scope", "Working Rules", "Commands", "Review and Delivery", "Safety Boundaries", "Local Overrides"], + "sectionOrder": ["Repository Scope", "Working Rules", "Commands", "Review and Delivery", "Safety Boundaries", "Local Overrides"], + "requiredSubsections": { + "Repository Scope": ["What This File Covers", "Where To Look First"], + "Working Rules": ["Change Scope", "Source of Truth", "Communication and Escalation"], + "Commands": ["Setup", "Validation", "Optional Project Commands"], + "Review and Delivery": ["Review Expectations", "Definition of Done"], + "Safety Boundaries": ["Never Do", "Ask Before"] + }, + "sectionAliases": { + "Repository Scope": ["Repository Expectations"], + "Working Rules": ["Standards and Guidance"], + "Review and Delivery": ["Review"], + "Safety Boundaries": ["Safety and Boundaries"] + }, + "subsectionAliases": { + "What This File Covers": ["Purpose"], + "Where To Look First": ["Priority Files"], + "Change Scope": ["Scope"], + "Source of Truth": ["Truth Sources"], + "Communication and Escalation": ["Escalation"], + "Optional Project Commands": ["Project Commands"], + "Review Expectations": ["PR Expectations"], + "Definition of Done": ["Done"], + "Never Do": ["Never"], + "Ask Before": ["Approval Gates"] + } +} diff --git a/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md b/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md new file mode 100644 index 000000000..0ad49ca90 --- /dev/null +++ b/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md @@ -0,0 +1,69 @@ +# Contributing to {{PROJECT_NAME}} + +Use this guide when preparing changes so the project stays understandable, runnable, and reviewable for the next contributor. + +## Table of Contents + +## Overview + +### Who This Guide Is For + +Explain who should use this guide and what kinds of contributions it is meant to support. + +### Before You Start + +Call out the most important prerequisites before someone begins work, such as reading nearby docs, checking open work, or understanding repo constraints. + +## Contribution Workflow + +### Choosing Work + +Explain how contributors should choose or confirm work before they begin. + +### Making Changes + +Explain the normal path for making changes in this repository, including how to keep work bounded and coherent. + +### Asking For Review + +Explain when a change is ready for review and what contributors should double-check first. + +## Local Setup + +### Runtime Config + +Document the concrete local configuration contributors need, including files, secrets, environment variables, or local services. + +### Runtime Behavior + +Explain what needs to be running locally and how contributors can tell the project is actually working. + +## Development Expectations + +### Naming Conventions + +Describe the terminology, casing, and naming patterns contributors should match when extending the project. + +### Accessibility Expectations + +Contributors must keep changes aligned with the project's accessibility contract in [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). + +If a change affects UI semantics, input behavior, focus flow, labels, announcements, motion, contrast, zoom behavior, content structure, or assistive-technology compatibility, verify the affected surface against the documented accessibility standards before asking for review. + +If a change introduces a new accessibility limitation, exception, or remediation plan, update `ACCESSIBILITY.md` in the same pass unless maintainers have explicitly agreed on a different tracking path. + +### Verification + +Prefer grounded validation commands with fenced code blocks and language info strings when examples help. + +## Pull Request Expectations + +Explain what a good pull request should contain so reviewers get the right context quickly. + +## Communication + +Explain how contributors should surface questions, design uncertainty, or larger-scope changes before they drift. + +## License and Contribution Terms + +State any practical contribution terms here. If there is nothing unusual, point contributors to the project license directly. diff --git a/scripts/repo-maintenance/docs/contributing/document.contract.json b/scripts/repo-maintenance/docs/contributing/document.contract.json new file mode 100644 index 000000000..dbc65d4d6 --- /dev/null +++ b/scripts/repo-maintenance/docs/contributing/document.contract.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "document": "contributing", + "targetFile": "CONTRIBUTING.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Overview", "Contribution Workflow", "Local Setup", "Development Expectations", "Pull Request Expectations", "Communication", "License and Contribution Terms"], + "sectionOrder": ["Overview", "Contribution Workflow", "Local Setup", "Development Expectations", "Pull Request Expectations", "Communication", "License and Contribution Terms"], + "requiredSubsections": { + "Overview": ["Who This Guide Is For", "Before You Start"], + "Contribution Workflow": ["Choosing Work", "Making Changes", "Asking For Review"], + "Local Setup": ["Runtime Config", "Runtime Behavior"], + "Development Expectations": ["Naming Conventions", "Accessibility Expectations", "Verification"] + }, + "sectionAliases": { + "Development Expectations": ["Development"], + "License and Contribution Terms": ["Contribution Terms", "License"] + }, + "subsectionAliases": { + "Who This Guide Is For": ["Audience"], + "Before You Start": ["Prerequisites"], + "Choosing Work": ["Picking Work"], + "Making Changes": ["Implementation Workflow"], + "Asking For Review": ["Requesting Review"], + "Naming Conventions": ["Naming"], + "Accessibility Expectations": ["Accessibility", "A11y Expectations"], + "Verification": ["Validation"] + } +} diff --git a/scripts/repo-maintenance/docs/readme/README.template.md b/scripts/repo-maintenance/docs/readme/README.template.md new file mode 100644 index 000000000..19e6e31d1 --- /dev/null +++ b/scripts/repo-maintenance/docs/readme/README.template.md @@ -0,0 +1,57 @@ +# {{PROJECT_NAME}} + +{{ONE_LINE_SUMMARY}} + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Usage](#usage) +- [Development](#development) +- [Repo Structure](#repo-structure) +- [Release Notes](#release-notes) +- [License](#license) + +## Overview + +### Status + +TBD + +### What This Project Is + +TBD + +### Motivation + +TBD + +## Quick Start + +Give a short, succinct, human-friendly quick start for trying or using the project. If the project is still too early for a real quick start, say that plainly and direct curious readers to the Development section for contributor documentation. + +## Usage + +Keep this section concise, consolidated, and human-focused. Prefer fenced code blocks with language info strings when examples help explain normal usage. + +## Development + +For setup, local workflow, validation, and contribution expectations, see [CONTRIBUTING.md](./CONTRIBUTING.md). + +## Repo Structure + +```text +. +├── path/ +└── path/ +``` + +Replace this outline with a short directory tree for the important repository surfaces. + +## Release Notes + +Summarize how releases, version notes, or notable shipped changes are tracked for this project. + +## License + +See [LICENSE](./LICENSE). diff --git a/scripts/repo-maintenance/docs/readme/document.contract.json b/scripts/repo-maintenance/docs/readme/document.contract.json new file mode 100644 index 000000000..f7579eb92 --- /dev/null +++ b/scripts/repo-maintenance/docs/readme/document.contract.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "document": "readme", + "targetFile": "README.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Overview", "Quick Start", "Usage", "Development", "Repo Structure", "Release Notes", "License"], + "sectionOrder": ["Overview", "Quick Start", "Usage", "Development", "Repo Structure", "Release Notes", "License"], + "requiredSubsections": { + "Overview": ["Status", "What This Project Is", "Motivation"] + }, + "sectionAliases": { + "Quick Start": ["Getting Started", "Installation"], + "Usage": ["Examples"] + }, + "subsectionAliases": {} +} diff --git a/scripts/repo-maintenance/docs/roadmap/ROADMAP.template.md b/scripts/repo-maintenance/docs/roadmap/ROADMAP.template.md new file mode 100644 index 000000000..769061113 --- /dev/null +++ b/scripts/repo-maintenance/docs/roadmap/ROADMAP.template.md @@ -0,0 +1,58 @@ +# Project Roadmap + +Use this roadmap to track milestone-level delivery through checklist sections. + +## Table of Contents + +- [Vision](#vision) +- [Product Principles](#product-principles) +- [Milestone Progress](#milestone-progress) +- [Milestone 0: Foundation](#milestone-0-foundation) +- [Small Tickets](#small-tickets) +- [Backlog Candidates](#backlog-candidates) +- [History](#history) + +## Vision + +- Describe the long-term outcome this roadmap is meant to deliver, not just what the project currently is. + +## Product Principles + +- State the few planning and delivery rules that should shape roadmap decisions and tradeoffs. + +## Milestone Progress + +Use this section as a concise rollup of milestone names and statuses, not as a second task list. + +- Milestone 0: Foundation - Planned + +## Milestone 0: Foundation + +### Status + +Planned + +### Scope + +- [ ] Describe the boundary and intended outcome of this milestone without turning Scope into a task list. + +### Tickets + +- [ ] Add the first concrete implementation task for this milestone. + +### Exit Criteria + +- [ ] Describe what must be true before this milestone counts as complete. + +## Small Tickets + +- [ ] Record issue-sized fixes, TODO/FIXME imports, and cleanup work that is too small or too unplanned for a milestone. + +## Backlog Candidates + +- [ ] Record plausible future work that is not yet committed to a milestone. + +## History + +- Initial roadmap scaffold created. +- Record only notable roadmap changes here, such as milestone additions, scope cuts, resets, or major replans. diff --git a/scripts/repo-maintenance/docs/roadmap/document.contract.json b/scripts/repo-maintenance/docs/roadmap/document.contract.json new file mode 100644 index 000000000..02d18d7ec --- /dev/null +++ b/scripts/repo-maintenance/docs/roadmap/document.contract.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "document": "roadmap", + "targetFile": "ROADMAP.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Vision", "Product Principles", "Milestone Progress", "Small Tickets", "Backlog Candidates", "History"], + "sectionOrder": ["Vision", "Product Principles", "Milestone Progress", "__MILESTONES__", "Small Tickets", "Backlog Candidates", "History"], + "requiredSubsections": { + "__MILESTONE__": ["Status", "Scope", "Tickets", "Exit Criteria"] + }, + "sectionAliases": { + "Product Principles": ["Product principles"], + "Small Tickets": ["Small tickets", "TODO", "Todo", "TODOs", "Fixes", "Bug Fixes"], + "Backlog Candidates": ["Backlog candidates"] + }, + "subsectionAliases": { + "Status": ["status"], + "Exit Criteria": ["Exit criteria"] + }, + "allowedStatuses": ["Planned", "In Progress", "Completed", "Blocked", "De-scoped"], + "statusAliases": { + "In Progress": ["Implementation Complete; Release Pending", "Release candidate"] + } +} diff --git a/scripts/repo-maintenance/lib/DocsCoordinator.fsx b/scripts/repo-maintenance/lib/DocsCoordinator.fsx new file mode 100644 index 000000000..901a8a1c9 --- /dev/null +++ b/scripts/repo-maintenance/lib/DocsCoordinator.fsx @@ -0,0 +1,116 @@ +module DocsCoordinator + +open System +open System.IO +open System.Text.Json +open ProjectDocs + +type DocumentAsset = { Name: string; Target: string; Contract: string; Template: string } + +type DocsReport = { + Mode: string + DocumentOrder: string list + Documents: DocumentReport list + ResponsibilityIssues: Finding list + Applied: bool + Errors: string list +} + +let private parseArgs argv = + let mutable projectRoot = "." + let mutable mode = CheckOnly + let mutable format = "markdown" + let mutable failOnIssues = false + let rec loop args = + match args with + | [] -> () + | "--project-root" :: value :: tail -> projectRoot <- value; loop tail + | "--run-mode" :: "check-only" :: tail -> mode <- CheckOnly; loop tail + | "--run-mode" :: "apply" :: tail -> mode <- Apply; loop tail + | "--format" :: value :: tail -> format <- value; loop tail + | "--fail-on-issues" :: tail -> failOnIssues <- true; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + projectRoot, mode, format, failOnIssues + +let private headingPresent (heading: string) (text: string) = + let pattern = $"(?im)^#+\\s+{System.Text.RegularExpressions.Regex.Escape(heading)}\\s*$" + System.Text.RegularExpressions.Regex.IsMatch(text, pattern) + +let private auditResponsibilities root = + let read file = let path = Path.Combine(root, file) in if File.Exists(path) then File.ReadAllText(path) else "" + let findings = ResizeArray<Finding>() + let check file headings owner id = + let text = read file + for heading in headings do + if headingPresent heading text then + findings.Add({ Id = id; Severity = "warning"; Message = $"{file} contains '{heading}', whose canonical owner is {owner}." }) + check "README.md" [ "Contribution Workflow"; "Review Expectations"; "Release Process" ] "CONTRIBUTING.md or maintainer docs" "readme-responsibility-drift" + check "CONTRIBUTING.md" [ "Product Principles"; "Milestones"; "Small Tickets" ] "ROADMAP.md" "contributing-responsibility-drift" + check "AGENTS.md" [ "Quick Start"; "Usage"; "Known Gaps" ] "README.md or ROADMAP.md" "agents-responsibility-drift" + check "ROADMAP.md" [ "Contribution Workflow"; "Local Setup"; "Safety Boundaries" ] "CONTRIBUTING.md or AGENTS.md" "roadmap-responsibility-drift" + List.ofSeq findings + +let private jsonOptions = + let value = JsonSerializerOptions(WriteIndented = true) + value.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + value + +let private renderMarkdown report = + let lines = ResizeArray<string>() + lines.Add("# Project documentation maintenance report") + lines.Add("") + lines.Add($"- Mode: `{report.Mode}`") + lines.Add($"- Applied: `{report.Applied.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Documents") + lines.Add("") + for document in report.Documents do + lines.Add($"- `{document.Document}`: {document.Findings.Length} finding(s), {document.Fixes.Length} fix(es), changed `{document.Changed.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Responsibility issues") + lines.Add("") + if List.isEmpty report.ResponsibilityIssues then lines.Add("- None.") + else for issue in report.ResponsibilityIssues do lines.Add($"- `{issue.Id}`: {issue.Message}") + lines.Add("") + lines.Add("## Errors") + lines.Add("") + if List.isEmpty report.Errors then lines.Add("- None.") + else for error in report.Errors do lines.Add($"- {error}") + String.Join("\n", lines) + "\n" + +let execute (assets: DocumentAsset list) argv = + try + let rootArg, mode, format, failOnIssues = parseArgs argv + let root = Path.GetFullPath(rootArg) + let options target = { + ProjectRoot = root; TargetPath = Some target; RunMode = mode; Format = format + FailOnIssues = failOnIssues; CollectSourceTickets = false; CollectGithubIssues = false + GithubRepo = None; TicketSection = None; TicketText = None; TicketState = None + TicketSource = None; TicketMatch = None; AllowDuplicate = false + } + let plans = assets |> List.map (fun asset -> planDocument asset.Contract asset.Template (options asset.Target)) + let planningErrors = plans |> List.collect (fun plan -> plan.Report.Errors) + let applyErrors, applied = + if mode = Apply && List.isEmpty planningErrors then + match applyPlans plans with | Ok () -> [], true | Error errors -> errors, false + else [], false + let responsibilityIssues = auditResponsibilities root + let report = { + Mode = if mode = Apply then "apply" else "check-only" + DocumentOrder = assets |> List.map (fun asset -> asset.Target) + Documents = plans |> List.map (fun plan -> plan.Report) + ResponsibilityIssues = responsibilityIssues + Applied = applied + Errors = planningErrors @ applyErrors + } + Console.Out.Write(if format = "json" then JsonSerializer.Serialize(report, jsonOptions) + "\n" else renderMarkdown report) + let issueCount = + report.Documents + |> List.sumBy (fun document -> document.Findings |> List.filter (fun finding -> finding.Severity = "error") |> List.length) + if not (List.isEmpty report.Errors) then 1 + elif failOnIssues && (issueCount > 0 || not (List.isEmpty responsibilityIssues)) then 2 + else 0 + with error -> + Console.Error.WriteLine($"ERROR: {error.Message}") + 1 diff --git a/scripts/repo-maintenance/lib/ProjectDocs.fsx b/scripts/repo-maintenance/lib/ProjectDocs.fsx new file mode 100644 index 000000000..d5733cb0c --- /dev/null +++ b/scripts/repo-maintenance/lib/ProjectDocs.fsx @@ -0,0 +1,663 @@ +module ProjectDocs + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json +open System.Text.RegularExpressions + +type RunMode = + | CheckOnly + | Apply + +type DocumentKind = + | Readme + | Contributing + | Agents + | Roadmap + +type Section = { + Heading: string + Body: string +} + +type ParsedDocument = { + Preamble: string + Sections: Section list +} + +type Alias = { + Canonical: string + Values: string list +} + +type Contract = { + SchemaVersion: int + Kind: DocumentKind + TargetFile: string + RequireTableOfContents: bool + PreservePreamble: bool + AllowAdditionalSections: bool + RequiredSections: string list + SectionOrder: string list + RequiredSubsections: Map<string, string list> + SectionAliases: Alias list + SubsectionAliases: Alias list + AllowedStatuses: string list + StatusAliases: Alias list +} + +type Finding = { + Id: string + Severity: string + Message: string +} + +type Fix = { + Id: string + Message: string +} + +type DocumentReport = { + Document: string + Path: string + Mode: string + Findings: Finding list + Fixes: Fix list + Changed: bool + Errors: string list +} + +type DocumentPlan = { + Report: DocumentReport + TargetPath: string + Original: string option + Rendered: string +} + +type CliOptions = { + ProjectRoot: string + TargetPath: string option + RunMode: RunMode + Format: string + FailOnIssues: bool + CollectSourceTickets: bool + CollectGithubIssues: bool + GithubRepo: string option + TicketSection: string option + TicketText: string option + TicketState: string option + TicketSource: string option + TicketMatch: string option + AllowDuplicate: bool +} + +let private normalizeNewlines (text: string) = + text.Replace("\r\n", "\n").Replace("\r", "\n") + +let private normalizedBody (text: string) = + normalizeNewlines text + |> fun value -> value.Trim('\n') + +let private canonicalText (text: string) = + normalizeNewlines text + |> fun value -> value.TrimEnd() + |> fun value -> value + "\n" + +let private headingRegex level = + Regex($"^#{{{level}}}\\s+(.+?)\\s*$", RegexOptions.Compiled) + +let private splitAtHeadings level (text: string) = + let lines = normalizeNewlines text |> fun value -> value.Split('\n') + let regex = headingRegex level + let mutable inFence = false + let mutable preamble = ResizeArray<string>() + let sections = ResizeArray<Section>() + let mutable currentHeading: string option = None + let mutable currentBody = ResizeArray<string>() + + let flush () = + match currentHeading with + | Some heading -> + sections.Add({ Heading = heading; Body = String.Join("\n", currentBody) |> normalizedBody }) + | None -> preamble <- ResizeArray<string>(currentBody) + currentBody <- ResizeArray<string>() + + for line in lines do + if line.TrimStart().StartsWith("```") || line.TrimStart().StartsWith("~~~") then + inFence <- not inFence + + let matched = if inFence then Match.Empty else regex.Match(line) + if matched.Success then + flush () + currentHeading <- Some(matched.Groups[1].Value.Trim()) + else + currentBody.Add(line) + + flush () + normalizedBody (String.Join("\n", preamble)), List.ofSeq sections + +let parseDocument text = + let preamble, sections = splitAtHeadings 2 text + { Preamble = preamble; Sections = sections } + +let private parseSubsections body = + let intro, sections = splitAtHeadings 3 body + intro, sections + +let private slugify (heading: string) = + let lowered = heading.Trim().ToLowerInvariant() + Regex.Replace(lowered, "[^a-z0-9\\s-]", "") + |> fun value -> Regex.Replace(value, "[\\s-]+", "-") + |> fun value -> value.Trim('-') + +let private sectionMap sections = + sections + |> List.map (fun section -> section.Heading, section) + |> Map.ofList + +let private aliasMap aliases = + aliases + |> List.collect (fun alias -> alias.Values |> List.map (fun value -> value, alias.Canonical)) + |> Map.ofList + +let private parseKind value = + match value with + | "readme" -> Readme + | "contributing" -> Contributing + | "agents" -> Agents + | "roadmap" -> Roadmap + | unsupported -> failwith $"Unsupported managed document kind: {unsupported}" + +let private stringList (element: JsonElement) = + element.EnumerateArray() + |> Seq.map (fun item -> item.GetString() |> Option.ofObj |> Option.defaultValue "") + |> Seq.toList + +let private aliases (root: JsonElement) (propertyName: string) = + match root.TryGetProperty(propertyName) with + | true, value -> + value.EnumerateObject() + |> Seq.map (fun property -> { Canonical = property.Name; Values = stringList property.Value }) + |> Seq.toList + | false, _ -> [] + +let loadContract path = + use document = JsonDocument.Parse(File.ReadAllText(path)) + let root = document.RootElement + let requiredSubsections = + match root.TryGetProperty("requiredSubsections") with + | true, value -> + value.EnumerateObject() + |> Seq.map (fun property -> property.Name, stringList property.Value) + |> Map.ofSeq + | false, _ -> Map.empty + + let optionalList (propertyName: string) = + match root.TryGetProperty(propertyName) with + | true, value -> stringList value + | false, _ -> [] + + { + SchemaVersion = root.GetProperty("schemaVersion").GetInt32() + Kind = root.GetProperty("document").GetString() |> parseKind + TargetFile = root.GetProperty("targetFile").GetString() + RequireTableOfContents = root.GetProperty("requireTableOfContents").GetBoolean() + PreservePreamble = root.GetProperty("preservePreamble").GetBoolean() + AllowAdditionalSections = root.GetProperty("allowAdditionalSections").GetBoolean() + RequiredSections = stringList (root.GetProperty("requiredSections")) + SectionOrder = stringList (root.GetProperty("sectionOrder")) + RequiredSubsections = requiredSubsections + SectionAliases = aliases root "sectionAliases" + SubsectionAliases = aliases root "subsectionAliases" + AllowedStatuses = optionalList "allowedStatuses" + StatusAliases = aliases root "statusAliases" + } + +let private sectionAliasLookup contract = aliasMap contract.SectionAliases + +let private subsectionAliasLookup contract = aliasMap contract.SubsectionAliases + +let private canonicalizeHeading lookup heading = + lookup |> Map.tryFind heading |> Option.defaultValue heading + +let private canonicalizeSections contract sections = + let lookup = sectionAliasLookup contract + sections + |> List.map (fun section -> { section with Heading = canonicalizeHeading lookup section.Heading }) + +let private milestoneRegex = Regex("^Milestone\\s+(\\d+)\\s*:\\s*(.+?)\\s*$", RegexOptions.Compiled) + +let private isMilestone (heading: string) = milestoneRegex.IsMatch(heading) + +let private requiredSubsectionsFor contract sectionHeading = + match contract.RequiredSubsections |> Map.tryFind sectionHeading with + | Some required -> Some required + | None when isMilestone sectionHeading -> contract.RequiredSubsections |> Map.tryFind "__MILESTONE__" + | None -> None + +let private renderSubsections contract sectionHeading existingBody templateBody = + match requiredSubsectionsFor contract sectionHeading with + | None -> existingBody, [] + | Some required -> + let intro, existing = parseSubsections existingBody + let _, templates = parseSubsections templateBody + let lookup = subsectionAliasLookup contract + let normalizedExisting = + existing + |> List.map (fun section -> { section with Heading = canonicalizeHeading lookup section.Heading }) + let existingMap = sectionMap normalizedExisting + let templateMap = sectionMap templates + let fixes = ResizeArray<Fix>() + let ordered = + required + |> List.map (fun heading -> + match existingMap |> Map.tryFind heading with + | Some section -> section + | None -> + fixes.Add({ Id = "add-subsection"; Message = $"Added missing subsection '{sectionHeading} > {heading}'." }) + if isMilestone sectionHeading then { Heading = heading; Body = "" } + else + templateMap + |> Map.tryFind heading + |> Option.defaultValue { Heading = heading; Body = "TBD" }) + let extras = normalizedExisting |> List.filter (fun section -> not (List.contains section.Heading required)) + let rendered = + [ if not (String.IsNullOrWhiteSpace intro) then yield normalizedBody intro + for section in ordered @ extras do + yield $"### {section.Heading}\n\n{normalizedBody section.Body}" ] + |> String.concat "\n\n" + rendered, List.ofSeq fixes + +let private milestoneNumber (heading: string) = + let matched = milestoneRegex.Match(heading) + if matched.Success then Int32.Parse(matched.Groups[1].Value) else Int32.MaxValue + +let private topLevelOrder contract sections = + let byHeading = sectionMap sections + let milestones = sections |> List.filter (fun section -> isMilestone section.Heading) |> List.sortBy (fun section -> milestoneNumber section.Heading) + let required = Set.ofList contract.RequiredSections + let aliases = sectionAliasLookup contract |> Map.toSeq |> Seq.map fst |> Set.ofSeq + let extras = + sections + |> List.filter (fun section -> + section.Heading <> "Table of Contents" + && not (required.Contains section.Heading) + && not (aliases.Contains section.Heading) + && not (isMilestone section.Heading)) + contract.SectionOrder + |> List.collect (fun heading -> + if heading = "__MILESTONES__" then milestones + else byHeading |> Map.tryFind heading |> Option.toList) + |> fun ordered -> if contract.AllowAdditionalSections then ordered @ extras else ordered + +let private buildToc sections = + sections + |> List.filter (fun section -> section.Heading <> "Table of Contents") + |> List.map (fun section -> $"- [{section.Heading}](#{slugify section.Heading})") + |> String.concat "\n" + +let private textOutsideFences (text: string) = + let mutable inFence = false + normalizeNewlines text + |> fun value -> value.Split('\n') + |> Array.choose (fun line -> + if line.TrimStart().StartsWith("```") || line.TrimStart().StartsWith("~~~") then + inFence <- not inFence + None + elif inFence then None + else Some line) + |> String.concat "\n" + +let private containsManagedPlaceholder body = + let lines = textOutsideFences body |> fun value -> value.Split('\n') + lines + |> Array.exists (fun line -> + let value = line.Trim() + value = "TBD" + || value.StartsWith("Explain ") + || value.StartsWith("Describe ") + || value.StartsWith("Summarize ") + || value.StartsWith("State any ") + || value.StartsWith("Record ") + || value.StartsWith("Add the first ") + || value.StartsWith("Replace this ")) + +let private canonicalStatus contract (value: string) = + contract.AllowedStatuses + |> List.tryFind (fun allowed -> String.Equals(allowed, value.Trim(), StringComparison.OrdinalIgnoreCase)) + |> Option.orElseWith (fun () -> + contract.StatusAliases + |> List.tryPick (fun alias -> + if alias.Values |> List.exists (fun candidate -> String.Equals(candidate, value.Trim(), StringComparison.OrdinalIgnoreCase)) then Some alias.Canonical else None)) + +let private normalizeMilestoneStatus contract body = + let intro, children = parseSubsections body + let fixes = ResizeArray<Fix>() + let normalized = + children + |> List.map (fun child -> + if child.Heading <> "Status" then child + else + match canonicalStatus contract child.Body with + | Some status when status <> child.Body.Trim() -> + fixes.Add({ Id = "normalize-milestone-status"; Message = $"Normalized milestone status '{child.Body.Trim()}' to '{status}'." }) + { child with Body = status } + | _ -> child) + let rendered = + [ if not (String.IsNullOrWhiteSpace intro) then yield normalizedBody intro + for child in normalized do yield $"### {child.Heading}\n\n{normalizedBody child.Body}" ] + |> String.concat "\n\n" + rendered, List.ofSeq fixes + +let private milestoneStatus body = + let _, children = parseSubsections body + children |> List.tryFind (fun child -> child.Heading = "Status") |> Option.map (fun child -> child.Body.Trim()) |> Option.defaultValue "Planned" + +let private audit contract (document: ParsedDocument) = + let findings = ResizeArray<Finding>() + let normalizedSections = canonicalizeSections contract document.Sections + let headings = normalizedSections |> List.map (fun section -> section.Heading) + + for required in contract.RequiredSections do + if not (List.contains required headings) then + findings.Add({ Id = "missing-section"; Severity = "error"; Message = $"Missing required section '{required}'." }) + + if contract.RequireTableOfContents && not (List.contains "Table of Contents" headings) then + findings.Add({ Id = "missing-table-of-contents"; Severity = "error"; Message = "Missing required Table of Contents." }) + + if contract.RequireTableOfContents then + match normalizedSections |> List.tryFind (fun section -> section.Heading = "Table of Contents") with + | Some toc -> + let expected = normalizedSections |> List.filter (fun section -> section.Heading <> "Table of Contents") |> buildToc + if normalizedBody toc.Body <> normalizedBody expected then + findings.Add({ Id = "stale-table-of-contents"; Severity = "error"; Message = "Table of Contents does not match the canonical top-level heading order." }) + | None -> () + + for section in normalizedSections do + match requiredSubsectionsFor contract section.Heading with + | None -> () + | Some requiredChildren -> + let _, children = parseSubsections section.Body + let lookup = subsectionAliasLookup contract + let childHeadings = children |> List.map (fun child -> canonicalizeHeading lookup child.Heading) + for child in requiredChildren do + if not (List.contains child childHeadings) then + findings.Add({ Id = "missing-subsection"; Severity = "error"; Message = $"Missing required subsection '{section.Heading} > {child}'." }) + + for section in normalizedSections do + if containsManagedPlaceholder section.Body then + findings.Add({ Id = "placeholder-content"; Severity = "warning"; Message = $"Section '{section.Heading}' contains managed placeholder content." }) + + if contract.Kind = Roadmap then + for section in normalizedSections |> List.filter (fun value -> isMilestone value.Heading) do + let _, children = parseSubsections section.Body + match children |> List.tryFind (fun child -> child.Heading = "Status") with + | Some status when canonicalStatus contract status.Body |> Option.isNone -> + findings.Add({ Id = "invalid-milestone-status"; Severity = "error"; Message = $"{section.Heading} has unsupported status '{status.Body.Trim()}'." }) + | _ -> () + + List.ofSeq findings + +let private renderDocument preamble sections = + [ if not (String.IsNullOrWhiteSpace preamble) then yield normalizedBody preamble + for section in sections do + yield $"## {section.Heading}\n\n{normalizedBody section.Body}" ] + |> String.concat "\n\n" + |> canonicalText + +let private normalizeDocument contract template current = + let templateSections = template.Sections |> canonicalizeSections contract + let currentSections = current.Sections |> canonicalizeSections contract + let templateMap = sectionMap templateSections + let currentMap = sectionMap currentSections + let fixes = ResizeArray<Fix>() + + let materialized = + contract.RequiredSections + |> List.map (fun heading -> + let templateSection = templateMap |> Map.tryFind heading |> Option.defaultValue { Heading = heading; Body = "TBD" } + let existing = + match currentMap |> Map.tryFind heading with + | Some section -> section + | None -> + fixes.Add({ Id = "add-section"; Message = $"Added missing section '{heading}'." }) + templateSection + let body, subsectionFixes = renderSubsections contract heading existing.Body templateSection.Body + fixes.AddRange(subsectionFixes) + { existing with Body = body }) + + let milestones = currentSections |> List.filter (fun section -> isMilestone section.Heading) + let milestoneTemplate = templateSections |> List.tryFind (fun section -> isMilestone section.Heading) + let normalizedMilestones = + milestones + |> List.map (fun milestone -> + match milestoneTemplate with + | None -> milestone + | Some templateMilestone -> + let body, subsectionFixes = renderSubsections contract milestone.Heading milestone.Body templateMilestone.Body + fixes.AddRange(subsectionFixes) + let statusBody, statusFixes = normalizeMilestoneStatus contract body + fixes.AddRange(statusFixes) + { milestone with Body = statusBody }) + + let extras = + currentSections + |> List.filter (fun section -> + section.Heading <> "Table of Contents" + && not (List.contains section.Heading contract.RequiredSections) + && not (isMilestone section.Heading)) + + let materializedWithProgress = + if contract.Kind <> Roadmap then materialized + else + materialized + |> List.map (fun section -> + if section.Heading <> "Milestone Progress" then section + else + let progress = + normalizedMilestones + |> List.map (fun milestone -> $"- {milestone.Heading} - {milestoneStatus milestone.Body}") + |> String.concat "\n" + if normalizedBody section.Body <> normalizedBody progress then + fixes.Add({ Id = "refresh-milestone-progress"; Message = "Regenerated Milestone Progress from canonical milestone headings and statuses." }) + { section with Body = progress }) + let allWithoutToc = materializedWithProgress @ normalizedMilestones @ extras |> topLevelOrder contract + let withToc = + if contract.RequireTableOfContents then + { Heading = "Table of Contents"; Body = buildToc allWithoutToc } :: allWithoutToc + else allWithoutToc + let preamble = + if contract.PreservePreamble && not (String.IsNullOrWhiteSpace current.Preamble) then current.Preamble + else template.Preamble + renderDocument preamble withToc, List.ofSeq fixes + +let private resolveInside (root: string) (requested: string option) (fallback: string) = + let rootPath = Path.GetFullPath(root) + let candidate = + requested + |> Option.map (fun path -> if Path.IsPathRooted(path) then path else Path.Combine(rootPath, path)) + |> Option.defaultValue (Path.Combine(rootPath, fallback)) + |> Path.GetFullPath + let prefix = rootPath.TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar + if candidate <> rootPath && not (candidate.StartsWith(prefix, StringComparison.Ordinal)) then + failwith $"Target path must remain inside project root: {candidate}" + candidate + +let private atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let planDocument contractPath templatePath options = + try + let root = Path.GetFullPath(options.ProjectRoot) + if not (Directory.Exists(root)) then failwith $"Project root does not exist: {root}" + let contract = loadContract contractPath + if contract.SchemaVersion <> 1 then failwith $"Unsupported document contract schema: {contract.SchemaVersion}" + let target = resolveInside root options.TargetPath contract.TargetFile + let template = File.ReadAllText(templatePath) |> parseDocument + let currentText = if File.Exists(target) then File.ReadAllText(target) else File.ReadAllText(templatePath) + let current = parseDocument currentText + let beforeFindings = audit contract current + let rendered, fixes = normalizeDocument contract template current + let changed = not (File.Exists(target)) || canonicalText currentText <> rendered + let finalDocument = if options.RunMode = Apply then parseDocument rendered else current + let finalFindings = audit contract finalDocument + let report = { + Document = contract.TargetFile + Path = Path.GetRelativePath(root, target) + Mode = if options.RunMode = Apply then "apply" else "check-only" + Findings = if options.RunMode = Apply then finalFindings else beforeFindings + Fixes = if options.RunMode = Apply then fixes else [] + Changed = options.RunMode = Apply && changed + Errors = [] + } + { + Report = report + TargetPath = target + Original = if File.Exists(target) then Some currentText else None + Rendered = rendered + } + with error -> + let target = options.TargetPath |> Option.defaultValue "" + { + Report = { + Document = Path.GetFileName(target) + Path = target + Mode = if options.RunMode = Apply then "apply" else "check-only" + Findings = [] + Fixes = [] + Changed = false + Errors = [ error.Message ] + } + TargetPath = target + Original = None + Rendered = "" + } + +let applyPlans plans = + let errors = plans |> List.collect (fun plan -> plan.Report.Errors) + if not (List.isEmpty errors) then Error errors + else + let changed = plans |> List.filter (fun plan -> plan.Report.Changed) + let completed = ResizeArray<DocumentPlan>() + try + for plan in changed do + atomicWrite plan.TargetPath plan.Rendered + completed.Add(plan) + Ok () + with error -> + for plan in Seq.rev completed do + match plan.Original with + | Some content -> atomicWrite plan.TargetPath content + | None when File.Exists(plan.TargetPath) -> File.Delete(plan.TargetPath) + | None -> () + Error [ $"Documentation apply failed and completed writes were rolled back: {error.Message}" ] + +let runDocument contractPath templatePath options = + let plan = planDocument contractPath templatePath options + if options.RunMode = Apply then + match applyPlans [ plan ] with + | Ok () -> plan.Report + | Error errors -> { plan.Report with Changed = false; Errors = errors } + else plan.Report + +let private jsonOptions = + let options = JsonSerializerOptions(WriteIndented = true) + options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + options + +let reportJson report = JsonSerializer.Serialize(report, jsonOptions) + "\n" + +let reportMarkdown report = + let lines = ResizeArray<string>() + lines.Add($"# {report.Document} maintenance report") + lines.Add("") + lines.Add($"- Mode: `{report.Mode}`") + lines.Add($"- Changed: `{report.Changed.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Findings") + lines.Add("") + if List.isEmpty report.Findings then lines.Add("- None.") + else for finding in report.Findings do lines.Add($"- `{finding.Severity}` `{finding.Id}`: {finding.Message}") + lines.Add("") + lines.Add("## Fixes") + lines.Add("") + if List.isEmpty report.Fixes then lines.Add("- None.") + else for fix in report.Fixes do lines.Add($"- `{fix.Id}`: {fix.Message}") + lines.Add("") + lines.Add("## Errors") + lines.Add("") + if List.isEmpty report.Errors then lines.Add("- None.") + else for error in report.Errors do lines.Add($"- {error}") + String.Join("\n", lines) + "\n" + +let parseCli defaultTarget argv = + let mutable root = "." + let mutable target: string option = None + let mutable mode = CheckOnly + let mutable format = "markdown" + let mutable failOnIssues = false + let mutable collectSource = false + let mutable collectGithub = false + let mutable githubRepo: string option = None + let mutable ticketSection: string option = None + let mutable ticketText: string option = None + let mutable ticketState: string option = None + let mutable ticketSource: string option = None + let mutable ticketMatch: string option = None + let mutable allowDuplicate = false + let args = List.ofArray argv + let rec loop remaining = + match remaining with + | [] -> () + | "--project-root" :: value :: tail -> root <- value; loop tail + | "--target-path" :: value :: tail -> target <- Some value; loop tail + | "--run-mode" :: "check-only" :: tail -> mode <- CheckOnly; loop tail + | "--run-mode" :: "apply" :: tail -> mode <- Apply; loop tail + | "--format" :: value :: tail -> format <- value; loop tail + | "--fail-on-issues" :: tail -> failOnIssues <- true; loop tail + | "--collect-source-tickets" :: tail -> collectSource <- true; loop tail + | "--collect-github-issues" :: tail -> collectGithub <- true; loop tail + | "--github-repo" :: value :: tail -> githubRepo <- Some value; loop tail + | "--ticket-section" :: value :: tail -> ticketSection <- Some value; loop tail + | "--ticket-text" :: value :: tail -> ticketText <- Some value; loop tail + | "--ticket-state" :: value :: tail -> ticketState <- Some value; loop tail + | "--ticket-source" :: value :: tail -> ticketSource <- Some value; loop tail + | "--ticket-match" :: value :: tail -> ticketMatch <- Some value; loop tail + | "--allow-duplicate" :: tail -> allowDuplicate <- true; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop args + { + ProjectRoot = root + TargetPath = target |> Option.orElse (Some defaultTarget) + RunMode = mode + Format = format + FailOnIssues = failOnIssues + CollectSourceTickets = collectSource + CollectGithubIssues = collectGithub + GithubRepo = githubRepo + TicketSection = ticketSection + TicketText = ticketText + TicketState = ticketState + TicketSource = ticketSource + TicketMatch = ticketMatch + AllowDuplicate = allowDuplicate + } + +let execute contractPath templatePath defaultTarget argv = + try + let options = parseCli defaultTarget argv + let report = runDocument contractPath templatePath options + let output = if options.Format = "json" then reportJson report else reportMarkdown report + Console.Out.Write(output) + if not (List.isEmpty report.Errors) then 1 + elif options.FailOnIssues && not (List.isEmpty report.Findings) then 2 + else 0 + with error -> + Console.Error.WriteLine($"ERROR: {error.Message}") + 1 diff --git a/scripts/repo-maintenance/maintain-project-docs.fsx b/scripts/repo-maintenance/maintain-project-docs.fsx new file mode 100644 index 000000000..898b1668a --- /dev/null +++ b/scripts/repo-maintenance/maintain-project-docs.fsx @@ -0,0 +1,23 @@ +#!/usr/bin/env -S dotnet fsi +#load "lib/ProjectDocs.fsx" +#load "lib/DocsCoordinator.fsx" + +open System.IO +open DocsCoordinator + +let root = Path.GetFullPath(__SOURCE_DIRECTORY__) +let asset name target folder template = { + Name = name + Target = target + Contract = Path.Combine(root, "docs", folder, "document.contract.json") + Template = Path.Combine(root, "docs", folder, template) +} + +let assets = [ + asset "readme" "README.md" "readme" "README.template.md" + asset "contributing" "CONTRIBUTING.md" "contributing" "CONTRIBUTING.template.md" + asset "agents" "AGENTS.md" "agents" "AGENTS.template.md" + asset "roadmap" "ROADMAP.md" "roadmap" "ROADMAP.template.md" +] + +fsi.CommandLineArgs |> Array.skip 1 |> execute assets |> exit diff --git a/scripts/repo-maintenance/repo-maintenance.fsx b/scripts/repo-maintenance/repo-maintenance.fsx new file mode 100644 index 000000000..b3052eb27 --- /dev/null +++ b/scripts/repo-maintenance/repo-maintenance.fsx @@ -0,0 +1,281 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type CommandResult = { ExitCode: int; Stdout: string; Stderr: string } + +let maintenanceRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let fail message = raise (InvalidOperationException(message)) + +let runIn cwd executable arguments = + let startInfo = ProcessStartInfo(executable) + startInfo.WorkingDirectory <- cwd + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in arguments do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + { ExitCode = child.ExitCode; Stdout = stdout.Trim(); Stderr = stderr.Trim() } + +let requireSuccess description result = + if result.ExitCode <> 0 then + let detail = if String.IsNullOrWhiteSpace(result.Stderr) then result.Stdout else result.Stderr + fail $"{description} failed in {repoRoot}: {detail}" + result.Stdout + +let run executable arguments = runIn repoRoot executable arguments +let git arguments = run "git" arguments +let gh arguments = run "gh" arguments + +let ensureGitRepo () = + git [ "rev-parse"; "--show-toplevel" ] + |> requireSuccess "Git repository check" + |> Path.GetFullPath + |> fun actual -> if actual <> repoRoot then fail $"Repo-maintenance expected repository root {repoRoot}, but Git resolved {actual}." + +let runFsxDirectory name = + let directory = Path.Combine(maintenanceRoot, name) + if Directory.Exists(directory) then + Directory.GetFiles(directory, "*.fsx") + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + |> Array.iter (fun script -> + let result = runIn repoRoot "dotnet" [ "fsi"; script ] + requireSuccess $"Repo-maintenance {name} step {Path.GetFileName(script)}" result |> ignore + if not (String.IsNullOrWhiteSpace(result.Stdout)) then printfn "%s" result.Stdout) + +let validate () = + ensureGitRepo () + let required = [ + "repo-maintenance.fsx" + "maintain-project-docs.fsx" + "repo-maintenance.just" + "lib/ProjectDocs.fsx" + "lib/DocsCoordinator.fsx" + "config/profile.json" + ] + for relative in required do + let path = Path.Combine(maintenanceRoot, relative) + if not (File.Exists(path)) then fail $"Managed repo-maintenance file is missing: {path}" + let justfile = Path.Combine(repoRoot, "justfile") + if not (File.Exists(justfile)) then fail $"Repository justfile is missing: {justfile}" + let justText = File.ReadAllText(justfile) + if not (justText.Contains("scripts/repo-maintenance/repo-maintenance.just")) then + fail "Repository justfile does not import scripts/repo-maintenance/repo-maintenance.just." + runFsxDirectory "validations" + let profile = JsonDocument.Parse(File.ReadAllText(Path.Combine(maintenanceRoot, "config", "profile.json"))).RootElement.GetProperty("profile").GetString() + if profile = "xcode-workspace" then + let components = Path.Combine(maintenanceRoot, "workspace", "validate-components.fsx") + if not (File.Exists(components)) then fail $"xcode-workspace component validator is missing: {components}" + runIn repoRoot "dotnet" [ "fsi"; components ] |> requireSuccess "xcode-workspace component validation" |> ignore + runIn repoRoot "dotnet" [ "fsi"; Path.Combine(maintenanceRoot, "maintain-project-docs.fsx"); "--project-root"; repoRoot; "--run-mode"; "check-only"; "--format"; "markdown"; "--fail-on-issues" ] + |> requireSuccess "Canonical documentation validation" + |> ignore + printfn "Repo-maintenance validation passed." + +let sync () = + ensureGitRepo () + runFsxDirectory "syncing" + validate () + printfn "Repo-maintenance shared sync and validation passed." + +let cleanWorktree (cwd: string) = + let status = runIn cwd "git" [ "status"; "--porcelain" ] |> requireSuccess "Worktree status" + if not (String.IsNullOrWhiteSpace(status)) then fail $"Release requires a clean worktree: {cwd}" + +let currentBranch (cwd: string) = runIn cwd "git" [ "branch"; "--show-current" ] |> requireSuccess "Current branch" + +let normalizeTag (value: string) = + let tag = if value.StartsWith("v") then value else "v" + value + if not (System.Text.RegularExpressions.Regex.IsMatch(tag, "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")) then + fail $"Release version must use SemVer syntax: {value}" + tag + +let optionValue (name: string) (args: string list) = + args + |> List.tryFindIndex ((=) name) + |> Option.bind (fun index -> args |> List.tryItem (index + 1)) + +let hasFlag (name: string) (args: string list) = List.contains name args + +let ensureReleaseNotes (cwd: string) (tag: string) = + let candidates = [ Path.Combine(cwd, "docs", "releases", tag + ".md"); Path.Combine(cwd, "docs", "releases", tag.TrimStart('v') + ".md") ] + candidates |> List.tryFind File.Exists |> Option.defaultWith (fun () -> fail $"Checked-in release notes are required for {tag} under docs/releases/.") + +let branchVisible (branch: string) (expected: string) = + let output = git [ "ls-remote"; "origin"; $"refs/heads/{branch}" ] |> requireSuccess "Remote branch visibility" + not (String.IsNullOrWhiteSpace(output)) && output.Split([|' '; '\t'|], StringSplitOptions.RemoveEmptyEntries).[0] = expected + +let tagVisible (cwd: string) (tag: string) (expected: string) = + let output = runIn cwd "git" [ "ls-remote"; "origin"; $"refs/tags/{tag}^{{}}" ] |> requireSuccess "Remote tag visibility" + not (String.IsNullOrWhiteSpace(output)) && output.Split([|' '; '\t'|], StringSplitOptions.RemoveEmptyEntries).[0] = expected + +let prNumber (branch: string) = + let output = gh [ "pr"; "list"; "--state"; "all"; "--head"; branch; "--base"; "main"; "--limit"; "1"; "--json"; "number" ] |> requireSuccess "Release PR lookup" + use json = JsonDocument.Parse(output) + if json.RootElement.GetArrayLength() = 0 then None else Some(json.RootElement[0].GetProperty("number").GetInt32()) + +type Gate = { Number: int; Url: string; State: string; Head: string; Sha: string; Phase: string; Comments: int } + +let inspectGate number = + let pr = gh [ "pr"; "view"; string number; "--json"; "url,state,headRefName,headRefOid,reviewDecision,comments,reviews" ] |> requireSuccess "Release PR inspection" + use data = JsonDocument.Parse(pr) + let root = data.RootElement + let checksResult = gh [ "pr"; "checks"; string number; "--json"; "name,bucket" ] + let checks = + if String.IsNullOrWhiteSpace(checksResult.Stdout) then [] + else + use parsed = JsonDocument.Parse(checksResult.Stdout) + parsed.RootElement.EnumerateArray() + |> Seq.map (fun item -> item.GetProperty("name").GetString(), item.GetProperty("bucket").GetString()) + |> Seq.toList + let state = root.GetProperty("state").GetString() + let review = root.GetProperty("reviewDecision").GetString() + let comments = root.GetProperty("comments").GetArrayLength() + (root.GetProperty("reviews").EnumerateArray() |> Seq.filter (fun item -> item.GetProperty("state").GetString() = "COMMENTED") |> Seq.length) + let names = checks |> List.map fst |> Set.ofList + let buckets = checks |> List.map snd |> Set.ofList + let phase = + if state = "MERGED" then "merged" + elif state <> "OPEN" then "closed" + elif List.isEmpty checks || not (names.Contains("validate")) then "awaiting-required-checks" + elif buckets.Contains("fail") || buckets.Contains("cancel") then "failed-checks" + elif buckets.Contains("pending") then "awaiting-pr-checks" + elif review = "CHANGES_REQUESTED" then "changes-requested" + elif comments > 0 then "comments-require-review" + else "ready-to-advance" + { Number = number; Url = root.GetProperty("url").GetString(); State = state; Head = root.GetProperty("headRefName").GetString(); Sha = root.GetProperty("headRefOid").GetString(); Phase = phase; Comments = comments } + +let continuation tag gate = + let repository = gh [ "repo"; "view"; "--json"; "nameWithOwner"; "--jq"; ".nameWithOwner" ] |> requireSuccess "Repository identity" + let payload = {| schema = "repo-maintenance-continuation/v1"; operation = "standard-release"; repository = repository; releaseTag = tag; branch = gate.Head; headCommit = gate.Sha; prNumber = gate.Number; phase = gate.Phase; minimumDelayMinutes = 5; resumeCommand = $"just repo-release-inspect {tag}"; advanceCommand = $"just repo-release-advance {tag}" |} + printfn "%s" (JsonSerializer.Serialize(payload)) + +let findMainWorktree () = + let output = git [ "worktree"; "list"; "--porcelain" ] |> requireSuccess "Worktree inventory" + let mutable path: string option = None + let mutable found: string option = None + for line in output.Split('\n') do + if line.StartsWith("worktree ") then path <- Some(line.Substring(9)) + elif line = "branch refs/heads/main" then found <- path + found |> Option.defaultWith (fun () -> fail "No clean worktree owns local main.") + +let accountBranches (mainRoot: string) (supplied: string list) = + let allowed = Set.ofList [ "preserved"; "in-progress"; "archived"; "merged"; "safe-to-delete" ] + let parsed = + supplied + |> List.map (fun value -> + let parts = value.Split('=', 2) + if parts.Length <> 2 || not (allowed.Contains(parts[1])) then fail $"Invalid branch accounting: {value}" + parts[0], parts[1]) + |> Map.ofList + let branches = + runIn mainRoot "git" [ "branch"; "--no-merged"; "main"; "--format=%(refname:short)" ] + |> requireSuccess "Unmerged branch inventory" + |> fun output -> output.Split('\n', StringSplitOptions.RemoveEmptyEntries) |> Array.filter ((<>) "main") |> Array.toList + let missing = branches |> List.filter (fun branch -> not (parsed.ContainsKey(branch))) + if not (List.isEmpty missing) then + let rendered = String.concat ", " missing + fail $"Branch accounting is incomplete for: {rendered}" + branches |> List.map (fun branch -> branch, parsed[branch]) + +let releasePrepare (tag: string) (args: string list) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + if String.IsNullOrWhiteSpace(branch) || branch = "main" then fail "Release prepare must run from a named feature branch, not main." + ensureReleaseNotes repoRoot tag |> ignore + validate () + let versionScript = Path.Combine(maintenanceRoot, "version-bump.fsx") + if not (hasFlag "--skip-version-bump" args) then + if not (File.Exists(versionScript)) then fail $"Version bump script is required: {versionScript}" + let result = runIn repoRoot "dotnet" [ "fsi"; versionScript; tag.TrimStart('v') ] + requireSuccess "Version bump" result |> ignore + let status = git [ "status"; "--porcelain" ] |> requireSuccess "Version bump status" + if String.IsNullOrWhiteSpace(status) then fail "Version bump completed without changing files." + git [ "add"; "-A" ] |> requireSuccess "Stage version bump" |> ignore + git [ "commit"; "-m"; $"release: bump versions for {tag}" ] |> requireSuccess "Commit version bump" |> ignore + cleanWorktree repoRoot + git [ "push"; "-u"; "origin"; branch ] |> requireSuccess "Push release branch" |> ignore + let head = git [ "rev-parse"; "HEAD" ] |> requireSuccess "Release head" + if not (branchVisible branch head) then + let gate = { Number = 0; Url = ""; State = "OPEN"; Head = branch; Sha = head; Phase = "awaiting-branch-visibility"; Comments = 0 } + continuation tag gate + else + let number = + match prNumber branch with + | Some existing -> existing + | None -> + gh [ "pr"; "create"; "--base"; "main"; "--head"; branch; "--title"; $"release: prepare {tag}"; "--body"; $"Prepare {tag} through the canonical repository-maintenance workflow." ] |> requireSuccess "Create release PR" |> ignore + prNumber branch |> Option.defaultWith (fun () -> fail "GitHub did not return the created release PR.") + let gate = inspectGate number + printfn "PR #%d: %s (%s)" gate.Number gate.Phase gate.Url + continuation tag gate + +let releaseInspect (tag: string) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + let number = prNumber branch |> Option.defaultWith (fun () -> fail $"No release PR exists for {branch}.") + let gate = inspectGate number + printfn "PR #%d: %s (%s)" gate.Number gate.Phase gate.Url + continuation tag gate + +let releaseAdvance (tag: string) (args: string list) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + let number = prNumber branch |> Option.defaultWith (fun () -> fail $"No release PR exists for {branch}.") + let gate = inspectGate number + let head = git [ "rev-parse"; "HEAD" ] |> requireSuccess "Release head" + if gate.Head <> branch || gate.Sha <> head then fail "Release PR branch or commit identity changed; inspect before advancing." + if gate.Phase <> "ready-to-advance" && not (gate.Phase = "comments-require-review" && hasFlag "--review-comments-addressed" args) then + continuation tag gate + fail $"Release PR #{number} is not ready to advance: {gate.Phase}." + gh [ "pr"; "merge"; string number; "--merge"; "--delete-branch" ] |> requireSuccess "Merge release PR" |> ignore + let mainRoot = findMainWorktree () + cleanWorktree mainRoot + runIn mainRoot "git" [ "fetch"; "origin"; "main"; "--prune" ] |> requireSuccess "Fetch main" |> ignore + runIn mainRoot "git" [ "pull"; "--ff-only"; "origin"; "main" ] |> requireSuccess "Fast-forward main" |> ignore + let mainHead = runIn mainRoot "git" [ "rev-parse"; "HEAD" ] |> requireSuccess "Reviewed main head" + let accountingValues = + args |> List.mapi (fun index value -> index, value) |> List.choose (fun (index, value) -> if value = "--branch-accounting" then args |> List.tryItem(index + 1) else None) + let accounting = accountBranches mainRoot accountingValues + ensureReleaseNotes mainRoot tag |> ignore + let existingTag = runIn mainRoot "git" [ "rev-parse"; "-q"; "--verify"; $"refs/tags/{tag}" ] + if existingTag.ExitCode <> 0 then runIn mainRoot "git" [ "tag"; "-a"; tag; "-m"; $"Release {tag}" ] |> requireSuccess "Create release tag" |> ignore + runIn mainRoot "git" [ "push"; "origin"; tag ] |> requireSuccess "Push release tag" |> ignore + if not (tagVisible mainRoot tag mainHead) then fail $"Remote tag {tag} is not visible at reviewed main {mainHead}." + let releaseView = runIn mainRoot "gh" [ "release"; "view"; tag; "--json"; "tagName,isPrerelease,url" ] + if releaseView.ExitCode <> 0 then + let notes = ensureReleaseNotes mainRoot tag + let createArgs = [ "release"; "create"; tag; "--verify-tag"; "--title"; tag; "--notes-file"; notes ] @ (if tag.Contains("-") then [ "--prerelease" ] else []) + runIn mainRoot "gh" createArgs |> requireSuccess "Create GitHub release" |> ignore + printfn "Branch accounting:" + if List.isEmpty accounting then printfn "- No local branches remain outside main." + else for branchName, status in accounting do printfn "- %s: %s" branchName status + printfn "Release %s completed from %s." tag mainHead + +let release (operation: string) (args: string list) = + let tag = optionValue "--version" args |> Option.defaultWith (fun () -> fail "Pass --version vX.Y.Z.") |> normalizeTag + match operation with + | "prepare" -> releasePrepare tag args + | "inspect" -> releaseInspect tag + | "advance" -> releaseAdvance tag args + | _ -> fail $"Unsupported release operation: {operation}" + +let main argv = + match List.ofArray argv with + | [ "validate" ] -> validate (); 0 + | [ "sync" ] -> sync (); 0 + | "release" :: operation :: args -> release operation args; 0 + | _ -> fail "Usage: repo-maintenance.fsx validate|sync|release prepare|inspect|advance --version vX.Y.Z" + +try fsi.CommandLineArgs |> Array.skip 1 |> main |> exit +with error -> eprintfn "ERROR: %s" error.Message; exit 1 diff --git a/scripts/repo-maintenance/repo-maintenance.just b/scripts/repo-maintenance/repo-maintenance.just new file mode 100644 index 000000000..03a71ab34 --- /dev/null +++ b/scripts/repo-maintenance/repo-maintenance.just @@ -0,0 +1,20 @@ +docs-check: + dotnet fsi scripts/repo-maintenance/maintain-project-docs.fsx --project-root . --run-mode check-only --format markdown --fail-on-issues + +docs-apply: + dotnet fsi scripts/repo-maintenance/maintain-project-docs.fsx --project-root . --run-mode apply --format markdown --fail-on-issues + +repo-validate: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx validate + +repo-sync: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx sync + +repo-release-prepare version *args: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release prepare --version {{ quote(version) }} {{ args }} + +repo-release-inspect version: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release inspect --version {{ quote(version) }} + +repo-release-advance version *args: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release advance --version {{ quote(version) }} {{ args }} diff --git a/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx new file mode 100644 index 000000000..4c893bf0a --- /dev/null +++ b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx @@ -0,0 +1,51 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO + +let repositoryRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let pluginRoot = Path.Combine(repositoryRoot, "plugins", "repository-skills") + +let copyTree (source: string) (target: string) = + if not (Directory.Exists(source)) then failwith $"Repository-skills source is missing: {source}" + Directory.CreateDirectory(target) |> ignore + for directory in Directory.GetDirectories(source, "*", SearchOption.AllDirectories) do + Directory.CreateDirectory(Path.Combine(target, Path.GetRelativePath(source, directory))) |> ignore + for file in Directory.GetFiles(source, "*", SearchOption.AllDirectories) do + let destination = Path.Combine(target, Path.GetRelativePath(source, file)) + Directory.CreateDirectory(Path.GetDirectoryName(destination)) |> ignore + File.Copy(file, destination, true) + +let replaceTree (source: string) (target: string) = + let parent = Path.GetDirectoryName(target) + Directory.CreateDirectory(parent) |> ignore + let token = Guid.NewGuid().ToString("N") + let staging = Path.Combine(parent, $".{Path.GetFileName(target)}.{token}.staging") + let backup = Path.Combine(parent, $".{Path.GetFileName(target)}.{token}.backup") + try + copyTree source staging + if Directory.Exists(target) then Directory.Move(target, backup) + Directory.Move(staging, target) + if Directory.Exists(backup) then Directory.Delete(backup, true) + with error -> + if Directory.Exists(staging) then Directory.Delete(staging, true) + if not (Directory.Exists(target)) && Directory.Exists(backup) then Directory.Move(backup, target) + raise error + +let skillNames = + [ "maintain-project-readme" + "maintain-project-contributing" + "maintain-project-agents" + "maintain-project-roadmap" + "maintain-project-repo" ] + +for skillName in skillNames do + replaceTree + (Path.Combine(pluginRoot, "skills", skillName)) + (Path.Combine(repositoryRoot, "skills", skillName)) + +replaceTree + (Path.Combine(pluginRoot, "shared", "project-docs")) + (Path.Combine(repositoryRoot, "shared", "project-docs")) + +printfn "Synchronized %d repository skills and the shared documentation runtime." skillNames.Length diff --git a/shared/project-docs/DocsCoordinator.fsx b/shared/project-docs/DocsCoordinator.fsx new file mode 100644 index 000000000..901a8a1c9 --- /dev/null +++ b/shared/project-docs/DocsCoordinator.fsx @@ -0,0 +1,116 @@ +module DocsCoordinator + +open System +open System.IO +open System.Text.Json +open ProjectDocs + +type DocumentAsset = { Name: string; Target: string; Contract: string; Template: string } + +type DocsReport = { + Mode: string + DocumentOrder: string list + Documents: DocumentReport list + ResponsibilityIssues: Finding list + Applied: bool + Errors: string list +} + +let private parseArgs argv = + let mutable projectRoot = "." + let mutable mode = CheckOnly + let mutable format = "markdown" + let mutable failOnIssues = false + let rec loop args = + match args with + | [] -> () + | "--project-root" :: value :: tail -> projectRoot <- value; loop tail + | "--run-mode" :: "check-only" :: tail -> mode <- CheckOnly; loop tail + | "--run-mode" :: "apply" :: tail -> mode <- Apply; loop tail + | "--format" :: value :: tail -> format <- value; loop tail + | "--fail-on-issues" :: tail -> failOnIssues <- true; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + projectRoot, mode, format, failOnIssues + +let private headingPresent (heading: string) (text: string) = + let pattern = $"(?im)^#+\\s+{System.Text.RegularExpressions.Regex.Escape(heading)}\\s*$" + System.Text.RegularExpressions.Regex.IsMatch(text, pattern) + +let private auditResponsibilities root = + let read file = let path = Path.Combine(root, file) in if File.Exists(path) then File.ReadAllText(path) else "" + let findings = ResizeArray<Finding>() + let check file headings owner id = + let text = read file + for heading in headings do + if headingPresent heading text then + findings.Add({ Id = id; Severity = "warning"; Message = $"{file} contains '{heading}', whose canonical owner is {owner}." }) + check "README.md" [ "Contribution Workflow"; "Review Expectations"; "Release Process" ] "CONTRIBUTING.md or maintainer docs" "readme-responsibility-drift" + check "CONTRIBUTING.md" [ "Product Principles"; "Milestones"; "Small Tickets" ] "ROADMAP.md" "contributing-responsibility-drift" + check "AGENTS.md" [ "Quick Start"; "Usage"; "Known Gaps" ] "README.md or ROADMAP.md" "agents-responsibility-drift" + check "ROADMAP.md" [ "Contribution Workflow"; "Local Setup"; "Safety Boundaries" ] "CONTRIBUTING.md or AGENTS.md" "roadmap-responsibility-drift" + List.ofSeq findings + +let private jsonOptions = + let value = JsonSerializerOptions(WriteIndented = true) + value.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + value + +let private renderMarkdown report = + let lines = ResizeArray<string>() + lines.Add("# Project documentation maintenance report") + lines.Add("") + lines.Add($"- Mode: `{report.Mode}`") + lines.Add($"- Applied: `{report.Applied.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Documents") + lines.Add("") + for document in report.Documents do + lines.Add($"- `{document.Document}`: {document.Findings.Length} finding(s), {document.Fixes.Length} fix(es), changed `{document.Changed.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Responsibility issues") + lines.Add("") + if List.isEmpty report.ResponsibilityIssues then lines.Add("- None.") + else for issue in report.ResponsibilityIssues do lines.Add($"- `{issue.Id}`: {issue.Message}") + lines.Add("") + lines.Add("## Errors") + lines.Add("") + if List.isEmpty report.Errors then lines.Add("- None.") + else for error in report.Errors do lines.Add($"- {error}") + String.Join("\n", lines) + "\n" + +let execute (assets: DocumentAsset list) argv = + try + let rootArg, mode, format, failOnIssues = parseArgs argv + let root = Path.GetFullPath(rootArg) + let options target = { + ProjectRoot = root; TargetPath = Some target; RunMode = mode; Format = format + FailOnIssues = failOnIssues; CollectSourceTickets = false; CollectGithubIssues = false + GithubRepo = None; TicketSection = None; TicketText = None; TicketState = None + TicketSource = None; TicketMatch = None; AllowDuplicate = false + } + let plans = assets |> List.map (fun asset -> planDocument asset.Contract asset.Template (options asset.Target)) + let planningErrors = plans |> List.collect (fun plan -> plan.Report.Errors) + let applyErrors, applied = + if mode = Apply && List.isEmpty planningErrors then + match applyPlans plans with | Ok () -> [], true | Error errors -> errors, false + else [], false + let responsibilityIssues = auditResponsibilities root + let report = { + Mode = if mode = Apply then "apply" else "check-only" + DocumentOrder = assets |> List.map (fun asset -> asset.Target) + Documents = plans |> List.map (fun plan -> plan.Report) + ResponsibilityIssues = responsibilityIssues + Applied = applied + Errors = planningErrors @ applyErrors + } + Console.Out.Write(if format = "json" then JsonSerializer.Serialize(report, jsonOptions) + "\n" else renderMarkdown report) + let issueCount = + report.Documents + |> List.sumBy (fun document -> document.Findings |> List.filter (fun finding -> finding.Severity = "error") |> List.length) + if not (List.isEmpty report.Errors) then 1 + elif failOnIssues && (issueCount > 0 || not (List.isEmpty responsibilityIssues)) then 2 + else 0 + with error -> + Console.Error.WriteLine($"ERROR: {error.Message}") + 1 diff --git a/shared/project-docs/ProjectDocs.fsx b/shared/project-docs/ProjectDocs.fsx new file mode 100644 index 000000000..d5733cb0c --- /dev/null +++ b/shared/project-docs/ProjectDocs.fsx @@ -0,0 +1,663 @@ +module ProjectDocs + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json +open System.Text.RegularExpressions + +type RunMode = + | CheckOnly + | Apply + +type DocumentKind = + | Readme + | Contributing + | Agents + | Roadmap + +type Section = { + Heading: string + Body: string +} + +type ParsedDocument = { + Preamble: string + Sections: Section list +} + +type Alias = { + Canonical: string + Values: string list +} + +type Contract = { + SchemaVersion: int + Kind: DocumentKind + TargetFile: string + RequireTableOfContents: bool + PreservePreamble: bool + AllowAdditionalSections: bool + RequiredSections: string list + SectionOrder: string list + RequiredSubsections: Map<string, string list> + SectionAliases: Alias list + SubsectionAliases: Alias list + AllowedStatuses: string list + StatusAliases: Alias list +} + +type Finding = { + Id: string + Severity: string + Message: string +} + +type Fix = { + Id: string + Message: string +} + +type DocumentReport = { + Document: string + Path: string + Mode: string + Findings: Finding list + Fixes: Fix list + Changed: bool + Errors: string list +} + +type DocumentPlan = { + Report: DocumentReport + TargetPath: string + Original: string option + Rendered: string +} + +type CliOptions = { + ProjectRoot: string + TargetPath: string option + RunMode: RunMode + Format: string + FailOnIssues: bool + CollectSourceTickets: bool + CollectGithubIssues: bool + GithubRepo: string option + TicketSection: string option + TicketText: string option + TicketState: string option + TicketSource: string option + TicketMatch: string option + AllowDuplicate: bool +} + +let private normalizeNewlines (text: string) = + text.Replace("\r\n", "\n").Replace("\r", "\n") + +let private normalizedBody (text: string) = + normalizeNewlines text + |> fun value -> value.Trim('\n') + +let private canonicalText (text: string) = + normalizeNewlines text + |> fun value -> value.TrimEnd() + |> fun value -> value + "\n" + +let private headingRegex level = + Regex($"^#{{{level}}}\\s+(.+?)\\s*$", RegexOptions.Compiled) + +let private splitAtHeadings level (text: string) = + let lines = normalizeNewlines text |> fun value -> value.Split('\n') + let regex = headingRegex level + let mutable inFence = false + let mutable preamble = ResizeArray<string>() + let sections = ResizeArray<Section>() + let mutable currentHeading: string option = None + let mutable currentBody = ResizeArray<string>() + + let flush () = + match currentHeading with + | Some heading -> + sections.Add({ Heading = heading; Body = String.Join("\n", currentBody) |> normalizedBody }) + | None -> preamble <- ResizeArray<string>(currentBody) + currentBody <- ResizeArray<string>() + + for line in lines do + if line.TrimStart().StartsWith("```") || line.TrimStart().StartsWith("~~~") then + inFence <- not inFence + + let matched = if inFence then Match.Empty else regex.Match(line) + if matched.Success then + flush () + currentHeading <- Some(matched.Groups[1].Value.Trim()) + else + currentBody.Add(line) + + flush () + normalizedBody (String.Join("\n", preamble)), List.ofSeq sections + +let parseDocument text = + let preamble, sections = splitAtHeadings 2 text + { Preamble = preamble; Sections = sections } + +let private parseSubsections body = + let intro, sections = splitAtHeadings 3 body + intro, sections + +let private slugify (heading: string) = + let lowered = heading.Trim().ToLowerInvariant() + Regex.Replace(lowered, "[^a-z0-9\\s-]", "") + |> fun value -> Regex.Replace(value, "[\\s-]+", "-") + |> fun value -> value.Trim('-') + +let private sectionMap sections = + sections + |> List.map (fun section -> section.Heading, section) + |> Map.ofList + +let private aliasMap aliases = + aliases + |> List.collect (fun alias -> alias.Values |> List.map (fun value -> value, alias.Canonical)) + |> Map.ofList + +let private parseKind value = + match value with + | "readme" -> Readme + | "contributing" -> Contributing + | "agents" -> Agents + | "roadmap" -> Roadmap + | unsupported -> failwith $"Unsupported managed document kind: {unsupported}" + +let private stringList (element: JsonElement) = + element.EnumerateArray() + |> Seq.map (fun item -> item.GetString() |> Option.ofObj |> Option.defaultValue "") + |> Seq.toList + +let private aliases (root: JsonElement) (propertyName: string) = + match root.TryGetProperty(propertyName) with + | true, value -> + value.EnumerateObject() + |> Seq.map (fun property -> { Canonical = property.Name; Values = stringList property.Value }) + |> Seq.toList + | false, _ -> [] + +let loadContract path = + use document = JsonDocument.Parse(File.ReadAllText(path)) + let root = document.RootElement + let requiredSubsections = + match root.TryGetProperty("requiredSubsections") with + | true, value -> + value.EnumerateObject() + |> Seq.map (fun property -> property.Name, stringList property.Value) + |> Map.ofSeq + | false, _ -> Map.empty + + let optionalList (propertyName: string) = + match root.TryGetProperty(propertyName) with + | true, value -> stringList value + | false, _ -> [] + + { + SchemaVersion = root.GetProperty("schemaVersion").GetInt32() + Kind = root.GetProperty("document").GetString() |> parseKind + TargetFile = root.GetProperty("targetFile").GetString() + RequireTableOfContents = root.GetProperty("requireTableOfContents").GetBoolean() + PreservePreamble = root.GetProperty("preservePreamble").GetBoolean() + AllowAdditionalSections = root.GetProperty("allowAdditionalSections").GetBoolean() + RequiredSections = stringList (root.GetProperty("requiredSections")) + SectionOrder = stringList (root.GetProperty("sectionOrder")) + RequiredSubsections = requiredSubsections + SectionAliases = aliases root "sectionAliases" + SubsectionAliases = aliases root "subsectionAliases" + AllowedStatuses = optionalList "allowedStatuses" + StatusAliases = aliases root "statusAliases" + } + +let private sectionAliasLookup contract = aliasMap contract.SectionAliases + +let private subsectionAliasLookup contract = aliasMap contract.SubsectionAliases + +let private canonicalizeHeading lookup heading = + lookup |> Map.tryFind heading |> Option.defaultValue heading + +let private canonicalizeSections contract sections = + let lookup = sectionAliasLookup contract + sections + |> List.map (fun section -> { section with Heading = canonicalizeHeading lookup section.Heading }) + +let private milestoneRegex = Regex("^Milestone\\s+(\\d+)\\s*:\\s*(.+?)\\s*$", RegexOptions.Compiled) + +let private isMilestone (heading: string) = milestoneRegex.IsMatch(heading) + +let private requiredSubsectionsFor contract sectionHeading = + match contract.RequiredSubsections |> Map.tryFind sectionHeading with + | Some required -> Some required + | None when isMilestone sectionHeading -> contract.RequiredSubsections |> Map.tryFind "__MILESTONE__" + | None -> None + +let private renderSubsections contract sectionHeading existingBody templateBody = + match requiredSubsectionsFor contract sectionHeading with + | None -> existingBody, [] + | Some required -> + let intro, existing = parseSubsections existingBody + let _, templates = parseSubsections templateBody + let lookup = subsectionAliasLookup contract + let normalizedExisting = + existing + |> List.map (fun section -> { section with Heading = canonicalizeHeading lookup section.Heading }) + let existingMap = sectionMap normalizedExisting + let templateMap = sectionMap templates + let fixes = ResizeArray<Fix>() + let ordered = + required + |> List.map (fun heading -> + match existingMap |> Map.tryFind heading with + | Some section -> section + | None -> + fixes.Add({ Id = "add-subsection"; Message = $"Added missing subsection '{sectionHeading} > {heading}'." }) + if isMilestone sectionHeading then { Heading = heading; Body = "" } + else + templateMap + |> Map.tryFind heading + |> Option.defaultValue { Heading = heading; Body = "TBD" }) + let extras = normalizedExisting |> List.filter (fun section -> not (List.contains section.Heading required)) + let rendered = + [ if not (String.IsNullOrWhiteSpace intro) then yield normalizedBody intro + for section in ordered @ extras do + yield $"### {section.Heading}\n\n{normalizedBody section.Body}" ] + |> String.concat "\n\n" + rendered, List.ofSeq fixes + +let private milestoneNumber (heading: string) = + let matched = milestoneRegex.Match(heading) + if matched.Success then Int32.Parse(matched.Groups[1].Value) else Int32.MaxValue + +let private topLevelOrder contract sections = + let byHeading = sectionMap sections + let milestones = sections |> List.filter (fun section -> isMilestone section.Heading) |> List.sortBy (fun section -> milestoneNumber section.Heading) + let required = Set.ofList contract.RequiredSections + let aliases = sectionAliasLookup contract |> Map.toSeq |> Seq.map fst |> Set.ofSeq + let extras = + sections + |> List.filter (fun section -> + section.Heading <> "Table of Contents" + && not (required.Contains section.Heading) + && not (aliases.Contains section.Heading) + && not (isMilestone section.Heading)) + contract.SectionOrder + |> List.collect (fun heading -> + if heading = "__MILESTONES__" then milestones + else byHeading |> Map.tryFind heading |> Option.toList) + |> fun ordered -> if contract.AllowAdditionalSections then ordered @ extras else ordered + +let private buildToc sections = + sections + |> List.filter (fun section -> section.Heading <> "Table of Contents") + |> List.map (fun section -> $"- [{section.Heading}](#{slugify section.Heading})") + |> String.concat "\n" + +let private textOutsideFences (text: string) = + let mutable inFence = false + normalizeNewlines text + |> fun value -> value.Split('\n') + |> Array.choose (fun line -> + if line.TrimStart().StartsWith("```") || line.TrimStart().StartsWith("~~~") then + inFence <- not inFence + None + elif inFence then None + else Some line) + |> String.concat "\n" + +let private containsManagedPlaceholder body = + let lines = textOutsideFences body |> fun value -> value.Split('\n') + lines + |> Array.exists (fun line -> + let value = line.Trim() + value = "TBD" + || value.StartsWith("Explain ") + || value.StartsWith("Describe ") + || value.StartsWith("Summarize ") + || value.StartsWith("State any ") + || value.StartsWith("Record ") + || value.StartsWith("Add the first ") + || value.StartsWith("Replace this ")) + +let private canonicalStatus contract (value: string) = + contract.AllowedStatuses + |> List.tryFind (fun allowed -> String.Equals(allowed, value.Trim(), StringComparison.OrdinalIgnoreCase)) + |> Option.orElseWith (fun () -> + contract.StatusAliases + |> List.tryPick (fun alias -> + if alias.Values |> List.exists (fun candidate -> String.Equals(candidate, value.Trim(), StringComparison.OrdinalIgnoreCase)) then Some alias.Canonical else None)) + +let private normalizeMilestoneStatus contract body = + let intro, children = parseSubsections body + let fixes = ResizeArray<Fix>() + let normalized = + children + |> List.map (fun child -> + if child.Heading <> "Status" then child + else + match canonicalStatus contract child.Body with + | Some status when status <> child.Body.Trim() -> + fixes.Add({ Id = "normalize-milestone-status"; Message = $"Normalized milestone status '{child.Body.Trim()}' to '{status}'." }) + { child with Body = status } + | _ -> child) + let rendered = + [ if not (String.IsNullOrWhiteSpace intro) then yield normalizedBody intro + for child in normalized do yield $"### {child.Heading}\n\n{normalizedBody child.Body}" ] + |> String.concat "\n\n" + rendered, List.ofSeq fixes + +let private milestoneStatus body = + let _, children = parseSubsections body + children |> List.tryFind (fun child -> child.Heading = "Status") |> Option.map (fun child -> child.Body.Trim()) |> Option.defaultValue "Planned" + +let private audit contract (document: ParsedDocument) = + let findings = ResizeArray<Finding>() + let normalizedSections = canonicalizeSections contract document.Sections + let headings = normalizedSections |> List.map (fun section -> section.Heading) + + for required in contract.RequiredSections do + if not (List.contains required headings) then + findings.Add({ Id = "missing-section"; Severity = "error"; Message = $"Missing required section '{required}'." }) + + if contract.RequireTableOfContents && not (List.contains "Table of Contents" headings) then + findings.Add({ Id = "missing-table-of-contents"; Severity = "error"; Message = "Missing required Table of Contents." }) + + if contract.RequireTableOfContents then + match normalizedSections |> List.tryFind (fun section -> section.Heading = "Table of Contents") with + | Some toc -> + let expected = normalizedSections |> List.filter (fun section -> section.Heading <> "Table of Contents") |> buildToc + if normalizedBody toc.Body <> normalizedBody expected then + findings.Add({ Id = "stale-table-of-contents"; Severity = "error"; Message = "Table of Contents does not match the canonical top-level heading order." }) + | None -> () + + for section in normalizedSections do + match requiredSubsectionsFor contract section.Heading with + | None -> () + | Some requiredChildren -> + let _, children = parseSubsections section.Body + let lookup = subsectionAliasLookup contract + let childHeadings = children |> List.map (fun child -> canonicalizeHeading lookup child.Heading) + for child in requiredChildren do + if not (List.contains child childHeadings) then + findings.Add({ Id = "missing-subsection"; Severity = "error"; Message = $"Missing required subsection '{section.Heading} > {child}'." }) + + for section in normalizedSections do + if containsManagedPlaceholder section.Body then + findings.Add({ Id = "placeholder-content"; Severity = "warning"; Message = $"Section '{section.Heading}' contains managed placeholder content." }) + + if contract.Kind = Roadmap then + for section in normalizedSections |> List.filter (fun value -> isMilestone value.Heading) do + let _, children = parseSubsections section.Body + match children |> List.tryFind (fun child -> child.Heading = "Status") with + | Some status when canonicalStatus contract status.Body |> Option.isNone -> + findings.Add({ Id = "invalid-milestone-status"; Severity = "error"; Message = $"{section.Heading} has unsupported status '{status.Body.Trim()}'." }) + | _ -> () + + List.ofSeq findings + +let private renderDocument preamble sections = + [ if not (String.IsNullOrWhiteSpace preamble) then yield normalizedBody preamble + for section in sections do + yield $"## {section.Heading}\n\n{normalizedBody section.Body}" ] + |> String.concat "\n\n" + |> canonicalText + +let private normalizeDocument contract template current = + let templateSections = template.Sections |> canonicalizeSections contract + let currentSections = current.Sections |> canonicalizeSections contract + let templateMap = sectionMap templateSections + let currentMap = sectionMap currentSections + let fixes = ResizeArray<Fix>() + + let materialized = + contract.RequiredSections + |> List.map (fun heading -> + let templateSection = templateMap |> Map.tryFind heading |> Option.defaultValue { Heading = heading; Body = "TBD" } + let existing = + match currentMap |> Map.tryFind heading with + | Some section -> section + | None -> + fixes.Add({ Id = "add-section"; Message = $"Added missing section '{heading}'." }) + templateSection + let body, subsectionFixes = renderSubsections contract heading existing.Body templateSection.Body + fixes.AddRange(subsectionFixes) + { existing with Body = body }) + + let milestones = currentSections |> List.filter (fun section -> isMilestone section.Heading) + let milestoneTemplate = templateSections |> List.tryFind (fun section -> isMilestone section.Heading) + let normalizedMilestones = + milestones + |> List.map (fun milestone -> + match milestoneTemplate with + | None -> milestone + | Some templateMilestone -> + let body, subsectionFixes = renderSubsections contract milestone.Heading milestone.Body templateMilestone.Body + fixes.AddRange(subsectionFixes) + let statusBody, statusFixes = normalizeMilestoneStatus contract body + fixes.AddRange(statusFixes) + { milestone with Body = statusBody }) + + let extras = + currentSections + |> List.filter (fun section -> + section.Heading <> "Table of Contents" + && not (List.contains section.Heading contract.RequiredSections) + && not (isMilestone section.Heading)) + + let materializedWithProgress = + if contract.Kind <> Roadmap then materialized + else + materialized + |> List.map (fun section -> + if section.Heading <> "Milestone Progress" then section + else + let progress = + normalizedMilestones + |> List.map (fun milestone -> $"- {milestone.Heading} - {milestoneStatus milestone.Body}") + |> String.concat "\n" + if normalizedBody section.Body <> normalizedBody progress then + fixes.Add({ Id = "refresh-milestone-progress"; Message = "Regenerated Milestone Progress from canonical milestone headings and statuses." }) + { section with Body = progress }) + let allWithoutToc = materializedWithProgress @ normalizedMilestones @ extras |> topLevelOrder contract + let withToc = + if contract.RequireTableOfContents then + { Heading = "Table of Contents"; Body = buildToc allWithoutToc } :: allWithoutToc + else allWithoutToc + let preamble = + if contract.PreservePreamble && not (String.IsNullOrWhiteSpace current.Preamble) then current.Preamble + else template.Preamble + renderDocument preamble withToc, List.ofSeq fixes + +let private resolveInside (root: string) (requested: string option) (fallback: string) = + let rootPath = Path.GetFullPath(root) + let candidate = + requested + |> Option.map (fun path -> if Path.IsPathRooted(path) then path else Path.Combine(rootPath, path)) + |> Option.defaultValue (Path.Combine(rootPath, fallback)) + |> Path.GetFullPath + let prefix = rootPath.TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar + if candidate <> rootPath && not (candidate.StartsWith(prefix, StringComparison.Ordinal)) then + failwith $"Target path must remain inside project root: {candidate}" + candidate + +let private atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let planDocument contractPath templatePath options = + try + let root = Path.GetFullPath(options.ProjectRoot) + if not (Directory.Exists(root)) then failwith $"Project root does not exist: {root}" + let contract = loadContract contractPath + if contract.SchemaVersion <> 1 then failwith $"Unsupported document contract schema: {contract.SchemaVersion}" + let target = resolveInside root options.TargetPath contract.TargetFile + let template = File.ReadAllText(templatePath) |> parseDocument + let currentText = if File.Exists(target) then File.ReadAllText(target) else File.ReadAllText(templatePath) + let current = parseDocument currentText + let beforeFindings = audit contract current + let rendered, fixes = normalizeDocument contract template current + let changed = not (File.Exists(target)) || canonicalText currentText <> rendered + let finalDocument = if options.RunMode = Apply then parseDocument rendered else current + let finalFindings = audit contract finalDocument + let report = { + Document = contract.TargetFile + Path = Path.GetRelativePath(root, target) + Mode = if options.RunMode = Apply then "apply" else "check-only" + Findings = if options.RunMode = Apply then finalFindings else beforeFindings + Fixes = if options.RunMode = Apply then fixes else [] + Changed = options.RunMode = Apply && changed + Errors = [] + } + { + Report = report + TargetPath = target + Original = if File.Exists(target) then Some currentText else None + Rendered = rendered + } + with error -> + let target = options.TargetPath |> Option.defaultValue "" + { + Report = { + Document = Path.GetFileName(target) + Path = target + Mode = if options.RunMode = Apply then "apply" else "check-only" + Findings = [] + Fixes = [] + Changed = false + Errors = [ error.Message ] + } + TargetPath = target + Original = None + Rendered = "" + } + +let applyPlans plans = + let errors = plans |> List.collect (fun plan -> plan.Report.Errors) + if not (List.isEmpty errors) then Error errors + else + let changed = plans |> List.filter (fun plan -> plan.Report.Changed) + let completed = ResizeArray<DocumentPlan>() + try + for plan in changed do + atomicWrite plan.TargetPath plan.Rendered + completed.Add(plan) + Ok () + with error -> + for plan in Seq.rev completed do + match plan.Original with + | Some content -> atomicWrite plan.TargetPath content + | None when File.Exists(plan.TargetPath) -> File.Delete(plan.TargetPath) + | None -> () + Error [ $"Documentation apply failed and completed writes were rolled back: {error.Message}" ] + +let runDocument contractPath templatePath options = + let plan = planDocument contractPath templatePath options + if options.RunMode = Apply then + match applyPlans [ plan ] with + | Ok () -> plan.Report + | Error errors -> { plan.Report with Changed = false; Errors = errors } + else plan.Report + +let private jsonOptions = + let options = JsonSerializerOptions(WriteIndented = true) + options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + options + +let reportJson report = JsonSerializer.Serialize(report, jsonOptions) + "\n" + +let reportMarkdown report = + let lines = ResizeArray<string>() + lines.Add($"# {report.Document} maintenance report") + lines.Add("") + lines.Add($"- Mode: `{report.Mode}`") + lines.Add($"- Changed: `{report.Changed.ToString().ToLowerInvariant()}`") + lines.Add("") + lines.Add("## Findings") + lines.Add("") + if List.isEmpty report.Findings then lines.Add("- None.") + else for finding in report.Findings do lines.Add($"- `{finding.Severity}` `{finding.Id}`: {finding.Message}") + lines.Add("") + lines.Add("## Fixes") + lines.Add("") + if List.isEmpty report.Fixes then lines.Add("- None.") + else for fix in report.Fixes do lines.Add($"- `{fix.Id}`: {fix.Message}") + lines.Add("") + lines.Add("## Errors") + lines.Add("") + if List.isEmpty report.Errors then lines.Add("- None.") + else for error in report.Errors do lines.Add($"- {error}") + String.Join("\n", lines) + "\n" + +let parseCli defaultTarget argv = + let mutable root = "." + let mutable target: string option = None + let mutable mode = CheckOnly + let mutable format = "markdown" + let mutable failOnIssues = false + let mutable collectSource = false + let mutable collectGithub = false + let mutable githubRepo: string option = None + let mutable ticketSection: string option = None + let mutable ticketText: string option = None + let mutable ticketState: string option = None + let mutable ticketSource: string option = None + let mutable ticketMatch: string option = None + let mutable allowDuplicate = false + let args = List.ofArray argv + let rec loop remaining = + match remaining with + | [] -> () + | "--project-root" :: value :: tail -> root <- value; loop tail + | "--target-path" :: value :: tail -> target <- Some value; loop tail + | "--run-mode" :: "check-only" :: tail -> mode <- CheckOnly; loop tail + | "--run-mode" :: "apply" :: tail -> mode <- Apply; loop tail + | "--format" :: value :: tail -> format <- value; loop tail + | "--fail-on-issues" :: tail -> failOnIssues <- true; loop tail + | "--collect-source-tickets" :: tail -> collectSource <- true; loop tail + | "--collect-github-issues" :: tail -> collectGithub <- true; loop tail + | "--github-repo" :: value :: tail -> githubRepo <- Some value; loop tail + | "--ticket-section" :: value :: tail -> ticketSection <- Some value; loop tail + | "--ticket-text" :: value :: tail -> ticketText <- Some value; loop tail + | "--ticket-state" :: value :: tail -> ticketState <- Some value; loop tail + | "--ticket-source" :: value :: tail -> ticketSource <- Some value; loop tail + | "--ticket-match" :: value :: tail -> ticketMatch <- Some value; loop tail + | "--allow-duplicate" :: tail -> allowDuplicate <- true; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop args + { + ProjectRoot = root + TargetPath = target |> Option.orElse (Some defaultTarget) + RunMode = mode + Format = format + FailOnIssues = failOnIssues + CollectSourceTickets = collectSource + CollectGithubIssues = collectGithub + GithubRepo = githubRepo + TicketSection = ticketSection + TicketText = ticketText + TicketState = ticketState + TicketSource = ticketSource + TicketMatch = ticketMatch + AllowDuplicate = allowDuplicate + } + +let execute contractPath templatePath defaultTarget argv = + try + let options = parseCli defaultTarget argv + let report = runDocument contractPath templatePath options + let output = if options.Format = "json" then reportJson report else reportMarkdown report + Console.Out.Write(output) + if not (List.isEmpty report.Errors) then 1 + elif options.FailOnIssues && not (List.isEmpty report.Findings) then 2 + else 0 + with error -> + Console.Error.WriteLine($"ERROR: {error.Message}") + 1 diff --git a/skills/maintain-project-agents/SKILL.md b/skills/maintain-project-agents/SKILL.md index acdb4b9d4..01bc0baaa 100644 --- a/skills/maintain-project-agents/SKILL.md +++ b/skills/maintain-project-agents/SKILL.md @@ -1,106 +1,55 @@ --- name: maintain-project-agents -description: Maintain project-local AGENTS.md files with deterministic audit and bounded apply modes. Use for durable repository guidance, grounded commands, review expectations, safety boundaries, normalization, or targeted fixes. +description: Maintain AGENTS.md as the agent-policy member of the canonical four-document repository suite. --- # Maintain Project Agents -Maintain project-local `AGENTS.md` files through one deterministic AGENTS workflow. +## Purpose -This skill is the default baseline path for `AGENTS.md` maintenance across most repositories. Reach for a narrower plugin only when the target repo has a specialized shape that deserves its own maintainer contract, such as a skills-export or plugin-export repository. +Keep project-local `AGENTS.md` compact, durable, grounded, and specific while +README, CONTRIBUTING, AGENTS, and ROADMAP are maintained together. -## Inputs +## Commands -- Required: `--project-root <path>` -- Required: `--run-mode <check-only|apply>` -- Optional: `--agents-path <path>` -- Optional: `--config <path>` +The complete documentation command surface is: -## Workflow +```text +just docs-check +just docs-apply +``` -1. Validate the project root and resolve the target `AGENTS.md`. -2. Load the canonical AGENTS schema from `config/agents-customization.template.yaml`, then merge any explicit override or project-local customization file. -3. In `check-only`, audit the required AGENTS sections, required subsection structure, command formatting, workflow routing guidance, and safety boundaries. -4. In `apply`, keep edits bounded to the target `AGENTS.md` while creating a missing file from the bundled template and normalizing the document to the canonical structure. -5. Re-run the same audit to confirm post-fix status. +Both commands process all four documents. Do not create per-file recipes or +document direct `.fsx` entrypoints. -## Canonical Base Contract +## Managed Contract -The source of truth for the base AGENTS contract lives in: +- `assets/document.contract.json` fixes structure, ordering, and aliases. +- `assets/AGENTS.template.md` supplies deterministic bootstrap scaffolding. +- No project-local structural customization is supported. +- Existing grounded policy and allowed additional sections are preserved. -- `config/agents-customization.template.yaml` -- `assets/AGENTS.template.md` - -The base contract requires: - -- a top-level title and short preamble -- canonical top-level sections for repo scope, working rules, commands, review and delivery, safety boundaries, and local overrides -- required subsection structure for those sections where specific guidance needs to be easy to scan and maintain - -## Writing Expectations - -- Keep the file compact, practical, and repo-specific. -- Keep the whole AGENTS file near 250 lines or less by default. Treat 300 lines as a soft ceiling that should trigger consolidation, trimming, or moving non-agent material to README, CONTRIBUTING, ROADMAP, or maintainer docs. -- Keep most top-level sections near 40 lines or less and most subsections near 20 lines or less. Prefer durable rules, routing, and commands over long explanations or historical context. -- `Repository Scope > Where To Look First` should route Codex toward the few highest-value files or directories, not try to summarize the whole repo. -- `Commands` should prefer fenced code blocks with language info strings for setup and validation commands. -- `Review and Delivery` should explain what good handoff looks like and what “done” means in this repo, including grounded verification and nearby updates when they matter. -- `Safety Boundaries` should stay concrete, high-signal, and easy to scan. -- `Local Overrides` should briefly explain whether more specific AGENTS files or fallback instruction files exist below this root, and make clear that closer guidance refines this root file later in the instruction chain. -- Keep AGENTS focused on durable agent-facing rules. Product explanation belongs in `README.md`; contributor workflow belongs in `CONTRIBUTING.md`; milestone, backlog, and small-ticket planning belong in `ROADMAP.md`; detailed architecture or release procedures belong in linked maintainer docs when they would bloat this file. - -## Alignment With Official Codex Guidance - -The base contract is shaped to match the official Codex `AGENTS.md` guidance: +## AGENTS Ownership -- keep repo-local guidance small and practical -- encode durable repo rules, commands, review expectations, and constraints -- add routing guidance when Codex reads too broadly -- update `AGENTS.md` when repeated mistakes or recurring review feedback reveal missing guidance -- acknowledge that more specific nested instruction files can refine the root guidance +AGENTS owns repository scope, source-of-truth routing, change boundaries, +commands, review and delivery rules, safety boundaries, and local overrides. +Product prose belongs in README, contributor workflow in CONTRIBUTING, and +planning in ROADMAP. -## Codex Subagent Fit +## Deterministic Workflow -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for bounded read-heavy checks before the main workflow edits or reports: auditing command accuracy, comparing repo instructions against nearby docs, checking safety boundaries, or reading nested guidance files in separate directories. - -Keep `apply` edits in the main thread because this skill owns one target `AGENTS.md` file and needs one coherent policy voice. If a target `AGENTS.md` mentions subagents, make the wording match OpenAI's current Codex rule: subagents need an explicit trigger, are best for bounded parallel discovery, tests, triage, and summarization, and may be called for by narrower plugin guidance that tells the agent to ask and receive permission before delegation. - -## Codex Hooks Fit - -When a target `AGENTS.md` mentions OpenAI Codex Hooks, keep the wording narrow and operational. Hooks are lifecycle scripts loaded from `hooks.json` or inline `[hooks]` config; they are enabled by default and can be disabled with `features.hooks = false`. Project-local hooks load only from trusted `.codex/` layers. - -Use hooks guidance in `AGENTS.md` only when the repo actually owns hook behavior or wants to warn contributors about repo-local Codex runtime checks. Name the event, matcher, script location, and user-visible effect. Do not present hooks as a replacement for `AGENTS.md`, approval policy, tests, or ordinary validation commands. - -## Output Contract - -- Return Markdown plus JSON with: - - `run_context` - - `schema_contract` - - `schema_violations` - - `workflow_drift_issues` - - `validation_drift_issues` - - `boundary_and_safety_issues` - - `fixes_applied` - - `post_fix_status` - - `errors` -- If there are no issues and no errors, output exactly `No findings.` +Use `just docs-check` for the full no-write audit and `just docs-apply` for the +atomic four-document apply. The coordinator validates every proposed document +before it writes any of them and rolls back completed replacements on failure. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent commands, toolchains, packaging surfaces, or project policy that are not grounded in the repo. -- Never edit files other than the target `AGENTS.md`. -- Preserve intentional repo-specific policy when it is already coherent and grounded. -- Treat `AGENTS.md` as maintainer and agent guidance, not as public README content. -- Treat this skill as a hard-enforced base template. Downstream plugins may specialize the schema, but the base skill should not do repo-profile inference. +- Never maintain AGENTS separately from the full document suite. +- Never invent commands, toolchains, packaging surfaces, or policy. +- Never add structural customization or alternate document modes. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `references/section-schema.md` -- `references/output-contract.md` -- `references/fix-policies.md` -- `references/style-rules.md` -- `references/agents-customization.md` -- `references/agents-config-schema.md` -- `references/project-agents-maintenance-automation-prompts.md` +- `assets/document.contract.json` +- `assets/AGENTS.template.md` diff --git a/skills/maintain-project-agents/assets/document.contract.json b/skills/maintain-project-agents/assets/document.contract.json new file mode 100644 index 000000000..c16e8ed64 --- /dev/null +++ b/skills/maintain-project-agents/assets/document.contract.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "document": "agents", + "targetFile": "AGENTS.md", + "requireTableOfContents": false, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Repository Scope", "Working Rules", "Commands", "Review and Delivery", "Safety Boundaries", "Local Overrides"], + "sectionOrder": ["Repository Scope", "Working Rules", "Commands", "Review and Delivery", "Safety Boundaries", "Local Overrides"], + "requiredSubsections": { + "Repository Scope": ["What This File Covers", "Where To Look First"], + "Working Rules": ["Change Scope", "Source of Truth", "Communication and Escalation"], + "Commands": ["Setup", "Validation", "Optional Project Commands"], + "Review and Delivery": ["Review Expectations", "Definition of Done"], + "Safety Boundaries": ["Never Do", "Ask Before"] + }, + "sectionAliases": { + "Repository Scope": ["Repository Expectations"], + "Working Rules": ["Standards and Guidance"], + "Review and Delivery": ["Review"], + "Safety Boundaries": ["Safety and Boundaries"] + }, + "subsectionAliases": { + "What This File Covers": ["Purpose"], + "Where To Look First": ["Priority Files"], + "Change Scope": ["Scope"], + "Source of Truth": ["Truth Sources"], + "Communication and Escalation": ["Escalation"], + "Optional Project Commands": ["Project Commands"], + "Review Expectations": ["PR Expectations"], + "Definition of Done": ["Done"], + "Never Do": ["Never"], + "Ask Before": ["Approval Gates"] + } +} diff --git a/skills/maintain-project-agents/config/agents-customization.template.yaml b/skills/maintain-project-agents/config/agents-customization.template.yaml deleted file mode 100644 index 9bc27189e..000000000 --- a/skills/maintain-project-agents/config/agents-customization.template.yaml +++ /dev/null @@ -1,102 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - requiredSections: - - Repository Scope - - Working Rules - - Commands - - Review and Delivery - - Safety Boundaries - - Local Overrides - sectionOrder: - - Repository Scope - - Working Rules - - Commands - - Review and Delivery - - Safety Boundaries - - Local Overrides - requiredSubsections: - Repository Scope: - - What This File Covers - - Where To Look First - Working Rules: - - Change Scope - - Source of Truth - - Communication and Escalation - Commands: - - Setup - - Validation - - Optional Project Commands - Review and Delivery: - - Review Expectations - - Definition of Done - Safety Boundaries: - - Never Do - - Ask Before - sectionAliases: - Repository Scope: - - Repository Expectations - Working Rules: - - Standards and Guidance - Commands: - - Validation - Review and Delivery: - - Review - Safety Boundaries: - - Safety and Boundaries - subsectionAliases: - Repository Scope/What This File Covers: - - Purpose - Repository Scope/Where To Look First: - - Priority Files - Working Rules/Change Scope: - - Scope - Working Rules/Source of Truth: - - Truth Sources - Working Rules/Communication and Escalation: - - Escalation - Commands/Optional Project Commands: - - Project Commands - Review and Delivery/Review Expectations: - - PR Expectations - Review and Delivery/Definition of Done: - - Done - Safety Boundaries/Never Do: - - Never - Safety Boundaries/Ask Before: - - Approval Gates - sectionTemplates: - Local Overrides: | - Explain whether this repository uses more specific AGENTS files or fallback instruction files in subdirectories, and make clear that deeper guidance refines this root file when work happens there. If there are no deeper overrides, say that plainly. - subsectionTemplates: - Repository Scope/What This File Covers: | - Explain what this root-level AGENTS file governs for the repository. - Repository Scope/Where To Look First: | - Point to the few highest-value docs, directories, or files Codex should check first before it starts reading broadly. - Working Rules/Change Scope: | - Explain how to keep work bounded and what kinds of scope expansion should be surfaced explicitly. - Working Rules/Source of Truth: | - Explain which files, docs, or project surfaces Codex should trust first when there is ambiguity. - Working Rules/Communication and Escalation: | - Explain when Codex should stop, surface tradeoffs, or ask before widening scope, especially when the next step has non-obvious consequences. - Commands/Setup: | - ```bash - # Replace this with the grounded setup or sync command for the repository. - ``` - Commands/Validation: | - ```bash - # Replace this with the grounded validation command for the repository. - ``` - Commands/Optional Project Commands: | - State any other important repo-specific commands here, or say plainly that there are no additional project commands worth calling out. - Review and Delivery/Review Expectations: | - Explain what Codex should include or check before handing work back for review. - Review and Delivery/Definition of Done: | - Explain what must be true before work should be considered complete in this repository, including grounded verification and any nearby docs or tests that should be updated. - Safety Boundaries/Never Do: | - List the highest-signal actions Codex must not take in this repository. - Safety Boundaries/Ask Before: | - List the decisions or changes that require explicit approval first. diff --git a/skills/maintain-project-agents/references/agents-config-schema.md b/skills/maintain-project-agents/references/agents-config-schema.md deleted file mode 100644 index b10502672..000000000 --- a/skills/maintain-project-agents/references/agents-config-schema.md +++ /dev/null @@ -1,28 +0,0 @@ -# AGENTS Config Schema - -The AGENTS configuration file is YAML and uses this top-level shape: - -- `schemaVersion` -- `isCustomized` -- `profile` -- `settings` - -The `settings` map supports: - -- `preservePreamble` -- `allowAdditionalSections` -- `requiredSections` -- `sectionOrder` -- `requiredSubsections` -- `sectionAliases` -- `subsectionAliases` -- `sectionTemplates` -- `subsectionTemplates` - -Practical rules: - -- `requiredSections` defines the canonical top-level sections, excluding the title and preamble. -- `sectionOrder` defines the enforced top-level ordering. -- `requiredSubsections` defines required `###` headings under specific `##` sections. -- `sectionAliases` and `subsectionAliases` allow bounded migration from older heading names into canonical names. -- `sectionTemplates` and `subsectionTemplates` provide the base scaffolding used during apply mode when content is missing. diff --git a/skills/maintain-project-agents/references/agents-customization.md b/skills/maintain-project-agents/references/agents-customization.md deleted file mode 100644 index 72fbe5fa2..000000000 --- a/skills/maintain-project-agents/references/agents-customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# AGENTS Customization - -The base AGENTS contract is defined in `../config/agents-customization.template.yaml`. - -Downstream specializations may customize: - -- canonical section order -- required sections -- required subsection structure -- section and subsection aliases -- section and subsection scaffold text - -The base skill always requires: - -- a top-level title and short repo-local preamble -- deterministic normalization to the configured schema - -Use a project-local `config/agents-customization.yaml` or `--config <path>` override when a downstream plugin needs a narrower or expanded AGENTS structure. diff --git a/skills/maintain-project-agents/references/fix-policies.md b/skills/maintain-project-agents/references/fix-policies.md deleted file mode 100644 index af0530250..000000000 --- a/skills/maintain-project-agents/references/fix-policies.md +++ /dev/null @@ -1,8 +0,0 @@ -# Fix Policies - -- Keep edits bounded to the target `AGENTS.md`. -- Preserve existing section bodies whenever they already satisfy the schema. -- Create a missing `AGENTS.md` from the bundled template. -- Normalize canonical headings and subsection headings to the configured names and order. -- Add missing sections or subsections from the configured templates instead of inventing repo-fiction. -- Do not synthesize commands, toolchains, workflow policy, or review rules that are not grounded in the repository. diff --git a/skills/maintain-project-agents/references/output-contract.md b/skills/maintain-project-agents/references/output-contract.md deleted file mode 100644 index 37e0252b1..000000000 --- a/skills/maintain-project-agents/references/output-contract.md +++ /dev/null @@ -1,17 +0,0 @@ -# Output Contract - -Return Markdown plus JSON with: - -- `run_context` -- `schema_contract` -- `schema_violations` -- `workflow_drift_issues` -- `validation_drift_issues` -- `boundary_and_safety_issues` -- `fixes_applied` -- `post_fix_status` -- `errors` - -Clean-run rule: - -- Output exactly `No findings.` only when there are no remaining issues and no errors. diff --git a/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md b/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md deleted file mode 100644 index 3b49de687..000000000 --- a/skills/maintain-project-agents/references/project-agents-maintenance-automation-prompts.md +++ /dev/null @@ -1,18 +0,0 @@ -# Automation Prompts - -Use these prompts when validating or applying the skill through automation. - -## Check-only - -- Audit `AGENTS.md` for the canonical section order. -- Confirm the required subsection structure exists under the canonical sections. -- Flag placeholder content, weak routing guidance, malformed command blocks, and thin safety boundaries. -- Confirm setup and validation guidance uses grounded command examples with code-fence info strings when commands are present. - -## Apply - -- Create a missing `AGENTS.md` from the bundled template. -- Normalize `AGENTS.md` to the canonical section order. -- Preserve existing AGENTS guidance where it already matches the schema. -- Add missing sections or subsections from the configured templates only. -- Keep all edits bounded to `AGENTS.md`. diff --git a/skills/maintain-project-agents/references/section-schema.md b/skills/maintain-project-agents/references/section-schema.md deleted file mode 100644 index 32e9f3c02..000000000 --- a/skills/maintain-project-agents/references/section-schema.md +++ /dev/null @@ -1,45 +0,0 @@ -# Section Schema - -The canonical base `AGENTS.md` structure is defined by: - -- `../config/agents-customization.template.yaml` -- `../assets/AGENTS.template.md` - -Base top-level shape: - -1. top-level title -2. short repo-local preamble -3. `## Repository Scope` -4. `## Working Rules` -5. `## Commands` -6. `## Review and Delivery` -7. `## Safety Boundaries` -8. `## Local Overrides` - -Required subsection shape: - -- `Repository Scope` - - `What This File Covers` - - `Where To Look First` -- `Working Rules` - - `Change Scope` - - `Source of Truth` - - `Communication and Escalation` -- `Commands` - - `Setup` - - `Validation` - - `Optional Project Commands` -- `Review and Delivery` - - `Review Expectations` - - `Definition of Done` -- `Safety Boundaries` - - `Never Do` - - `Ask Before` - -Schema expectations: - -- Use `##` headings for top-level sections. -- Use `###` headings for required subsections. -- Preserve additional repo-specific sections when present, but keep canonical sections in canonical order. -- Keep the root AGENTS file compact and practical, in line with official Codex guidance. -- Treat `Local Overrides` as the place to explain whether deeper AGENTS files or fallback instruction files refine the root guidance. diff --git a/skills/maintain-project-agents/references/style-rules.md b/skills/maintain-project-agents/references/style-rules.md deleted file mode 100644 index 81595dcba..000000000 --- a/skills/maintain-project-agents/references/style-rules.md +++ /dev/null @@ -1,11 +0,0 @@ -# Style Rules - -- Keep AGENTS guidance direct, technical, durable, and repo-grounded. -- Prefer concise paragraphs and short bullet lists over sprawling policy prose. -- Keep the whole AGENTS file near 250 lines by default, with 300 lines as a soft ceiling for consolidation. -- Keep most top-level sections near 40 lines or less and most subsections near 20 lines or less. -- Treat source-of-truth rules, command guidance, and workflow boundaries as first-class AGENTS content. -- Use explicit section labels for commands, review expectations, and safety boundaries so maintainers and agents can scan quickly. -- Keep the root AGENTS file compact and practical, aligned with official Codex guidance for repo-local instructions. -- Treat routing guidance as a scan aid: point to the few highest-value files or directories first instead of trying to summarize the entire repo. -- Keep product overview, contributor workflow, backlog planning, and long maintainer procedures in their canonical docs instead of bloating AGENTS. diff --git a/skills/maintain-project-agents/scripts/maintain_project_agents.py b/skills/maintain-project-agents/scripts/maintain_project_agents.py deleted file mode 100644 index 2264bcbf5..000000000 --- a/skills/maintain-project-agents/scripts/maintain_project_agents.py +++ /dev/null @@ -1,773 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and apply bounded AGENTS.md maintenance from a hard-enforced schema.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -SHELL_FENCE_RE = re.compile(r"```([^\n`]*)\n(.*?)```", re.DOTALL) -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] - - -@dataclass -class Issue: - issue_id: str - category: str - severity: str - file: str - evidence: str - recommended_fix: str - auto_fixable: bool - fixed: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_id": self.issue_id, - "category": self.category, - "severity": self.severity, - "file": self.file, - "evidence": self.evidence, - "recommended_fix": self.recommended_fix, - "auto_fixable": self.auto_fixable, - "fixed": self.fixed, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded AGENTS.md maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--agents-path", help="Optional AGENTS path override") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional AGENTS config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when unresolved issues remain") - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - sections.append((match.group(1).strip(), text[start:end].strip("\n"))) - return preamble, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - subsections.append((match.group(1).strip(), body[start:end].strip("\n"))) - return preamble, subsections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, preserve_preamble: bool) -> str: - lines = [line.rstrip() for line in preamble.splitlines()] - title_present = any(line.startswith("# ") for line in lines) - summary: Optional[str] = None - extras: List[str] = [] - - for idx, line in enumerate(lines): - if line.startswith("# "): - for follow in lines[idx + 1 :]: - if follow.strip(): - summary = follow.strip() - break - break - - for line in lines: - if line.startswith("# "): - continue - if summary is None and line.strip(): - summary = line.strip() - continue - extras.append(line) - - normalized_title = "# AGENTS.md" if not title_present else next(line for line in lines if line.startswith("# ")) - normalized_summary = ( - summary - or "Use this file for durable repo-local guidance that Codex should follow before changing code, docs, or project workflow surfaces in this repository." - ) - output = [normalized_title, "", normalized_summary] - if preserve_preamble: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - output.extend(["", *extra_lines]) - return "\n".join(output).strip() - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "agents-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "agents-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - values = settings.get("requiredSections", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def canonical_order(settings: Dict[str, Any]) -> List[str]: - values = settings.get("sectionOrder", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def required_subsections(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("requiredSubsections", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def subsection_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("subsectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("subsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def section_alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - reverse: Dict[str, str] = {} - for canonical, aliases in section_aliases(settings).items(): - for alias in aliases: - reverse[alias] = canonical - return reverse - - -def subsection_alias_lookup(settings: Dict[str, Any]) -> Dict[Tuple[str, str], str]: - reverse: Dict[Tuple[str, str], str] = {} - for canonical_path, aliases in subsection_aliases(settings).items(): - if "/" not in canonical_path: - continue - parent, canonical_name = canonical_path.split("/", 1) - for alias in aliases: - reverse[(parent, alias)] = canonical_name - return reverse - - -def render_template_bootstrap() -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "AGENTS.template.md" - return normalize_whitespace(read_text(template_path)) - - -def render_section_body(heading: str, existing_body: str, settings: Dict[str, Any]) -> str: - required_children = required_subsections(settings).get(heading, []) - section_template_map = section_templates(settings) - subsection_template_map = subsection_templates(settings) - subsection_alias_map = subsection_alias_lookup(settings) - - if not required_children: - return existing_body.strip() or section_template_map.get(heading, "") - - preamble, subsections = split_subsections(existing_body) - subsection_lookup: Dict[str, str] = {} - extra_subsections: List[Tuple[str, str]] = [] - for name, body in subsections: - canonical_name = subsection_alias_map.get((heading, name), name) - if canonical_name in required_children and canonical_name not in subsection_lookup: - subsection_lookup[canonical_name] = body - else: - extra_subsections.append((name, body)) - - lines: List[str] = [] - if preamble.strip(): - lines.extend([preamble.strip(), ""]) - - for idx, child in enumerate(required_children): - body = subsection_lookup.get(child, "").strip() or subsection_template_map.get(f"{heading}/{child}", "") - lines.extend([f"### {child}", "", body.strip()]) - if idx < len(required_children) - 1 or extra_subsections: - lines.append("") - - for idx, (name, body) in enumerate(extra_subsections): - lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extra_subsections) - 1: - lines.append("") - - rendered = "\n".join(lines).strip() - return rendered or section_template_map.get(heading, "") - - -def validate_schema( - agents_path: Path, - agents_text: str, - config: Dict[str, Any], -) -> Tuple[List[Issue], List[Issue], List[Issue], List[Issue]]: - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - subsection_map = required_subsections(settings) - section_alias_map = section_alias_lookup(settings) - subsection_alias_map = subsection_alias_lookup(settings) - - schema_issues: List[Issue] = [] - workflow_issues: List[Issue] = [] - validation_issues: List[Issue] = [] - boundary_issues: List[Issue] = [] - - preamble, sections = split_sections(agents_text) - lookup = section_map(sections) - - if not any(line.startswith("# ") for line in preamble.splitlines()): - schema_issues.append( - Issue( - issue_id="missing-title", - category="schema", - severity="high", - file=str(agents_path), - evidence="AGENTS.md is missing a top-level '# AGENTS.md' title.", - recommended_fix="Add a clear AGENTS.md title at the top of the file.", - auto_fixable=True, - ) - ) - - if len([line for line in preamble.splitlines() if line.strip()]) < 2: - schema_issues.append( - Issue( - issue_id="missing-summary", - category="schema", - severity="medium", - file=str(agents_path), - evidence="AGENTS.md is missing a short repo-local preamble beneath the title.", - recommended_fix="Add a short preamble explaining what this AGENTS file governs.", - auto_fixable=True, - ) - ) - - observed = [heading for heading, _body in sections] - positions = {heading: idx for idx, heading in enumerate(observed)} - for heading in required: - if heading in lookup: - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in lookup), - None, - ) - if alias_found: - schema_issues.append( - Issue( - issue_id=f"non-canonical-heading-{heading.lower().replace(' ', '-')}", - category="schema", - severity="medium", - file=str(agents_path), - evidence=f"AGENTS.md uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - recommended_fix=f"Rename '## {alias_found}' to '## {heading}'.", - auto_fixable=True, - ) - ) - else: - schema_issues.append( - Issue( - issue_id=f"missing-section-{heading.lower().replace(' ', '-')}", - category="schema", - severity="high", - file=str(agents_path), - evidence=f"AGENTS.md is missing required section '## {heading}'.", - recommended_fix=f"Add the required '## {heading}' section.", - auto_fixable=True, - ) - ) - - order_positions: List[int] = [] - for heading in order: - if heading in positions: - order_positions.append(positions[heading]) - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in positions), - None, - ) - if alias_found: - order_positions.append(positions[alias_found]) - if order_positions and order_positions != sorted(order_positions): - schema_issues.append( - Issue( - issue_id="canonical-section-order", - category="schema", - severity="medium", - file=str(agents_path), - evidence="Canonical AGENTS sections are not in the configured order.", - recommended_fix="Normalize the top-level sections into canonical order.", - auto_fixable=True, - ) - ) - - for parent, children in subsection_map.items(): - body = lookup.get(parent, "") - if not body: - alias_parent = next( - (alias for alias, canonical in section_alias_map.items() if canonical == parent and alias in lookup), - None, - ) - body = lookup.get(alias_parent, "") if alias_parent else "" - if not body: - continue - _sub_preamble, subsections = split_subsections(body) - found = { - subsection_alias_map.get((parent, name), name): subsection_body - for name, subsection_body in subsections - } - for child in children: - if child not in found: - schema_issues.append( - Issue( - issue_id=f"missing-subsection-{parent.lower().replace(' ', '-')}-{child.lower().replace(' ', '-')}", - category="schema", - severity="high", - file=str(agents_path), - evidence=f"Section '## {parent}' is missing required subsection '### {child}'.", - recommended_fix=f"Add the required subsection '### {child}' under '## {parent}'.", - auto_fixable=True, - ) - ) - - if any(pattern.search(agents_text) for pattern in PLACEHOLDER_PATTERNS): - workflow_issues.append( - Issue( - issue_id="placeholder-content", - category="workflow-drift", - severity="medium", - file=str(agents_path), - evidence="AGENTS.md still contains TODO, TBD, or angle-bracket placeholder content.", - recommended_fix="Replace placeholder text with grounded repository guidance.", - auto_fixable=False, - ) - ) - - commands_body = lookup.get("Commands", "") - _cmd_preamble, cmd_subsections = split_subsections(commands_body) - cmd_lookup = {subsection_alias_map.get(("Commands", name), name): body for name, body in cmd_subsections} - for child in ("Setup", "Validation"): - body = cmd_lookup.get(child, "").strip() - if not body: - continue - fences = list(SHELL_FENCE_RE.finditer(body)) - if not fences: - validation_issues.append( - Issue( - issue_id=f"missing-command-block-{child.lower()}", - category="validation-drift", - severity="medium", - file=str(agents_path), - evidence=f"Commands > {child} should include grounded command examples, preferably in fenced code blocks.", - recommended_fix=f"Add a fenced code block with grounded {child.lower()} commands.", - auto_fixable=False, - ) - ) - continue - for match in fences: - info = match.group(1).strip() - block = match.group(2).strip() - if not info: - validation_issues.append( - Issue( - issue_id=f"missing-code-fence-info-string-{child.lower()}-{match.start()}", - category="validation-drift", - severity="low", - file=str(agents_path), - evidence=f"Commands > {child} uses a fenced code block without a language info string.", - recommended_fix="Use fenced code blocks with an info string such as ```bash for command examples.", - auto_fixable=False, - ) - ) - if not block: - validation_issues.append( - Issue( - issue_id=f"empty-command-block-{child.lower()}-{match.start()}", - category="validation-drift", - severity="medium", - file=str(agents_path), - evidence=f"Commands > {child} contains an empty fenced code block.", - recommended_fix="Remove the empty block or replace it with grounded commands.", - auto_fixable=True, - ) - ) - if any(pattern.search(block) for pattern in PLACEHOLDER_PATTERNS): - validation_issues.append( - Issue( - issue_id=f"placeholder-command-block-{child.lower()}-{match.start()}", - category="validation-drift", - severity="high", - file=str(agents_path), - evidence=f"Commands > {child} contains a placeholder command block.", - recommended_fix="Replace the placeholder command block with grounded commands or prose.", - auto_fixable=False, - ) - ) - - where_to_look_body = lookup.get("Repository Scope", "") - _scope_preamble, scope_subsections = split_subsections(where_to_look_body) - scope_lookup = {subsection_alias_map.get(("Repository Scope", name), name): body for name, body in scope_subsections} - if scope_lookup.get("Where To Look First", "").strip() and len(scope_lookup["Where To Look First"].split()) < 6: - workflow_issues.append( - Issue( - issue_id="thin-where-to-look-first", - category="workflow-drift", - severity="medium", - file=str(agents_path), - evidence="Repository Scope > Where To Look First is too thin to route agent reading behavior.", - recommended_fix="Point to the most important docs, directories, or files agents should check first.", - auto_fixable=False, - ) - ) - - safety_body = lookup.get("Safety Boundaries", "") - _safety_preamble, safety_subsections = split_subsections(safety_body) - safety_lookup = {subsection_alias_map.get(("Safety Boundaries", name), name): body for name, body in safety_subsections} - for child in ("Never Do", "Ask Before"): - body = safety_lookup.get(child, "").strip() - if body and len(body.split()) < 6: - boundary_issues.append( - Issue( - issue_id=f"thin-safety-guidance-{child.lower().replace(' ', '-')}", - category="boundary-and-safety", - severity="medium", - file=str(agents_path), - evidence=f"Safety Boundaries > {child} is too thin to act as a useful guardrail.", - recommended_fix=f"Expand '{child}' with concrete, repo-grounded boundaries.", - auto_fixable=False, - ) - ) - - return schema_issues, workflow_issues, validation_issues, boundary_issues - - -def apply_fixes( - agents_path: Path, - agents_text: str, - config: Dict[str, Any], -) -> Tuple[str, List[Dict[str, str]]]: - if not agents_text.strip(): - bootstrap = render_template_bootstrap() - write_text(agents_path, bootstrap) - return ( - bootstrap, - [ - { - "action": "create-agents-from-template", - "file": str(agents_path), - "reason": "Created a missing AGENTS.md from the bundled canonical template.", - } - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - preserve_preamble = bool(settings.get("preservePreamble", True)) - allow_additional = bool(settings.get("allowAdditionalSections", True)) - section_alias_map = section_alias_lookup(settings) - - preamble, sections = split_sections(agents_text) - normalized_preamble = normalize_preamble(preamble, preserve_preamble) - - canonical_lookup: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - for heading, body in sections: - canonical_heading = section_alias_map.get(heading, heading) - if canonical_heading in order or canonical_heading in required: - canonical_lookup[canonical_heading] = body - elif allow_additional: - extra_sections.append((heading, body)) - - rendered_sections: List[Tuple[str, str]] = [] - for heading in order: - body = render_section_body(heading, canonical_lookup.get(heading, ""), settings).strip() - rendered_sections.append((heading, body)) - if allow_additional: - rendered_sections.extend(extra_sections) - - parts = [normalized_preamble] - for heading, body in rendered_sections: - parts.extend(["", f"## {heading}", "", body.strip()]) - document = "\n".join(parts).strip() + "\n" - return normalize_whitespace(document), [ - { - "action": "normalize-agents-structure", - "file": str(agents_path), - "reason": "Normalized AGENTS.md to the canonical template-backed section schema.", - } - ] - - -def format_report(report: Dict[str, Any]) -> str: - total_issues = ( - len(report["schema_violations"]) - + len(report["workflow_drift_issues"]) - + len(report["validation_drift_issues"]) - + len(report["boundary_and_safety_issues"]) - ) - if total_issues == 0 and not report["errors"]: - return "No findings." - - lines = [ - "# AGENTS.md Maintenance Report", - "", - f"- Target: `{report['run_context']['agents_path']}`", - f"- Mode: `{report['run_context']['run_mode']}`", - f"- Config: `{report['schema_contract']['config_path']}`", - ] - for key, title in ( - ("schema_violations", "Schema Violations"), - ("workflow_drift_issues", "Workflow Drift Issues"), - ("validation_drift_issues", "Validation Drift Issues"), - ("boundary_and_safety_issues", "Boundary And Safety Issues"), - ("fixes_applied", "Fixes Applied"), - ("errors", "Errors"), - ): - items = report[key] - if not items: - continue - lines.extend(["", f"## {title}"]) - for item in items: - evidence = item.get("evidence") or item.get("reason") or item.get("message") - lines.append(f"- {item.get('issue_id', item.get('action', 'item'))}: {evidence}") - return "\n".join(lines).strip() + "\n" - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - if not project_root.is_dir(): - raise ValueError(f"Project root does not exist or is not a directory: {project_root}") - - agents_path = Path(args.agents_path).expanduser().resolve() if args.agents_path else project_root / "AGENTS.md" - config = load_config(project_root, args.config) - errors: List[str] = [] - fixes_applied: List[Dict[str, str]] = [] - existing_text = read_text(agents_path) if agents_path.is_file() else "" - - if args.run_mode == "apply": - new_text, applied = apply_fixes(agents_path, existing_text, config) - if not agents_path.parent.exists(): - agents_path.parent.mkdir(parents=True, exist_ok=True) - if normalize_whitespace(existing_text) != new_text: - write_text(agents_path, new_text) - fixes_applied.extend(applied) - existing_text = new_text - - if existing_text: - schema_issues, workflow_issues, validation_issues, boundary_issues = validate_schema( - agents_path, existing_text, config - ) - else: - schema_issues = [ - Issue( - issue_id="missing-agents-file", - category="schema", - severity="high", - file=str(agents_path), - evidence="AGENTS.md does not exist.", - recommended_fix="Create the canonical AGENTS.md file from the bundled template.", - auto_fixable=True, - ) - ] - workflow_issues = [] - validation_issues = [] - boundary_issues = [] - - report = { - "run_context": { - "project_root": str(project_root), - "agents_path": str(agents_path), - "run_mode": args.run_mode, - "generated_at": datetime.now(timezone.utc).isoformat(), - }, - "schema_contract": { - "config_path": config.get("configPath"), - "default_config_path": config.get("defaultConfigPath"), - "required_sections": required_sections(config_settings(config)), - "section_order": canonical_order(config_settings(config)), - "required_subsections": required_subsections(config_settings(config)), - }, - "schema_violations": [issue.to_dict() for issue in schema_issues], - "workflow_drift_issues": [issue.to_dict() for issue in workflow_issues], - "validation_drift_issues": [issue.to_dict() for issue in validation_issues], - "boundary_and_safety_issues": [issue.to_dict() for issue in boundary_issues], - "fixes_applied": fixes_applied, - "post_fix_status": { - "remaining_issue_count": len(schema_issues) + len(workflow_issues) + len(validation_issues) + len(boundary_issues), - "is_clean": not schema_issues and not workflow_issues and not validation_issues and not boundary_issues and not errors, - }, - "errors": errors, - } - markdown = format_report(report) - return report, markdown - - -def main() -> int: - args = parse_args() - try: - report, markdown = run_maintenance(args) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - if args.json_out: - Path(args.json_out).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - if args.md_out: - Path(args.md_out).write_text(markdown, encoding="utf-8") - if args.print_json: - print(json.dumps(report, indent=2)) - if args.print_md: - print(markdown, end="") - - has_issues = ( - bool(report["schema_violations"]) - or bool(report["workflow_drift_issues"]) - or bool(report["validation_drift_issues"]) - or bool(report["boundary_and_safety_issues"]) - or bool(report["errors"]) - ) - if args.fail_on_issues and has_issues: - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/maintain-project-contributing/SKILL.md b/skills/maintain-project-contributing/SKILL.md index 4c196b211..c845b196a 100644 --- a/skills/maintain-project-contributing/SKILL.md +++ b/skills/maintain-project-contributing/SKILL.md @@ -1,91 +1,59 @@ --- name: maintain-project-contributing -description: Maintain canonical CONTRIBUTING.md files with deterministic audit and bounded apply modes. Use for contributor workflow, local setup, development expectations, review handoff, communication guidance, normalization, or targeted fixes. +description: Maintain CONTRIBUTING.md as the contributor-facing member of the canonical four-document repository suite. --- # Maintain Project Contributing -Maintain canonical `CONTRIBUTING.md` files through one deterministic contribution-guide workflow. +## Purpose -This skill is the default baseline path for `CONTRIBUTING.md` maintenance across most repositories. Reach for a narrower plugin only when the target repo has a specialized shape that deserves its own maintainer contract, such as a skills-export or plugin-export repository. +Keep `CONTRIBUTING.md` focused on human contributor setup, workflow, +verification, review, communication, and contribution terms while all four +canonical repository documents move together. -## Inputs +## Commands -- Required: `--project-root <path>` -- Required: `--run-mode <check-only|apply>` -- Optional: `--contributing-path <path>` -- Optional: `--config <path>` +The only documentation commands are: -## Workflow +```text +just docs-check +just docs-apply +``` -1. Validate the project root and resolve the target `CONTRIBUTING.md`. -2. Load the canonical contributing-guide schema from `config/contributing-customization.template.yaml`, then merge any explicit override or project-local customization file. -3. In `check-only`, audit the required section schema, required subsection schema, required table of contents, placeholder content, and verification-command formatting. -4. In `apply`, keep edits bounded to the target `CONTRIBUTING.md` while creating a missing file from the bundled template and normalizing the document to the canonical structure. -5. Re-run the same audit to confirm post-fix status. +Both always process README, CONTRIBUTING, AGENTS, and ROADMAP. There are no +per-file recipes and no supported direct script commands. -## Canonical Base Contract +## Managed Contract -The source of truth for the base contributing-guide contract lives in: +- `assets/document.contract.json` fixes the canonical structure and aliases. +- `assets/CONTRIBUTING.template.md` supplies deterministic bootstrap content. +- Repositories cannot customize headings, order, aliases, or fix policy. +- Healthy existing prose and allowed additional sections remain preserved. -- `config/contributing-customization.template.yaml` -- `assets/CONTRIBUTING.template.md` - -The base contract requires: - -- a top-level title and short summary -- a required `## Table of Contents` -- canonical top-level sections for overview, workflow, setup, development expectations, PR expectations, communication, and contribution terms -- required subsection structure for `Overview`, `Contribution Workflow`, `Local Setup`, and `Development Expectations` - -## Writing Expectations +## CONTRIBUTING Ownership -- Keep the whole CONTRIBUTING guide near 300 lines or less by default. Treat 350 lines as a soft ceiling that should trigger consolidation into shorter sections or links to maintainer docs. -- Keep most top-level sections near 45 lines or less and most subsections near 25 lines or less. Prefer one clear rule plus a link to canonical detail over repeated process narration. -- `Overview > Who This Guide Is For` should stay short and plainly explain who this guide serves. -- `Overview > Before You Start` should call out the most important prerequisites before someone begins work. -- `Contribution Workflow` should describe how contributors choose work, make changes, and ask for review without drifting into repo history or product overview prose. -- `Local Setup > Runtime Config` should be explicit about config files, env vars, secrets, and local services. -- `Local Setup > Runtime Behavior` should explain what needs to be running locally and how contributors can tell the project is actually working. -- `Development Expectations > Accessibility Expectations` should keep the contributor contract short, point contributors back to `ACCESSIBILITY.md`, and make accessibility part of normal change quality for relevant work. -- `Development Expectations > Verification` should prefer fenced code blocks with language info strings when commands help contributors validate changes. -- `Communication` should stay practical and concise, focused on how contributors surface uncertainty or larger-scope questions. -- Keep CONTRIBUTING, README, AGENTS, and ACCESSIBILITY responsibilities distinct. Contributor workflow lives here; product overview belongs in `README.md`; durable agent rules belong in `AGENTS.md`; detailed accessibility standards belong in `ACCESSIBILITY.md`. +CONTRIBUTING owns who the guide serves, prerequisites, choosing work, making +changes, asking for review, runtime setup, development expectations, pull +request expectations, communication, and contribution terms. Product overview +belongs in README, durable agent policy in AGENTS, and planning in ROADMAP. -## Codex Subagent Fit +Placeholder checks ignore fenced examples, including generic DCO sign-off +examples. They apply only to prose that matches managed scaffold language. -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy contribution-guide discovery before the main workflow edits or reports: checking setup commands, comparing PR expectations against repo policy, reading workflow docs, or verifying contributor-facing tool requirements. +## Deterministic Workflow -Keep `apply` edits in the main thread because this skill owns one target `CONTRIBUTING.md` file and needs one coherent contributor contract. Ask workers for concise evidence and file references, not replacement guide prose. - -## Output Contract - -- Return Markdown plus JSON with: - - `run_context` - - `schema_contract` - - `schema_violations` - - `command_integrity_issues` - - `content_quality_issues` - - `fixes_applied` - - `post_fix_status` - - `errors` -- If there are no issues and no errors, output exactly `No findings.` +Use `just docs-check` for a no-write audit and `just docs-apply` for the atomic +four-document normalization transaction. Apply must be byte-idempotent. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent commands, setup steps, environment variables, branch rules, review policies, or contribution terms that are not grounded in the repo. -- Never edit files other than the target `CONTRIBUTING.md`. -- Keep `CONTRIBUTING.md` as the canonical contribution-guide filename for this skill. -- Treat this skill as a hard-enforced base template. Downstream plugins may specialize the schema, but the base skill should not do repo-profile inference. +- Never maintain CONTRIBUTING separately from the full document suite. +- Never invent environment variables, services, commands, branch policy, + review policy, or legal terms. +- Never expose project-local schema customization. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `references/section-schema.md` -- `references/output-contract.md` -- `references/fix-policies.md` -- `references/style-rules.md` -- `references/contributing-customization.md` -- `references/contributing-config-schema.md` -- `references/project-contributing-maintenance-automation-prompts.md` +- `assets/document.contract.json` +- `assets/CONTRIBUTING.template.md` diff --git a/skills/maintain-project-contributing/assets/document.contract.json b/skills/maintain-project-contributing/assets/document.contract.json new file mode 100644 index 000000000..dbc65d4d6 --- /dev/null +++ b/skills/maintain-project-contributing/assets/document.contract.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "document": "contributing", + "targetFile": "CONTRIBUTING.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Overview", "Contribution Workflow", "Local Setup", "Development Expectations", "Pull Request Expectations", "Communication", "License and Contribution Terms"], + "sectionOrder": ["Overview", "Contribution Workflow", "Local Setup", "Development Expectations", "Pull Request Expectations", "Communication", "License and Contribution Terms"], + "requiredSubsections": { + "Overview": ["Who This Guide Is For", "Before You Start"], + "Contribution Workflow": ["Choosing Work", "Making Changes", "Asking For Review"], + "Local Setup": ["Runtime Config", "Runtime Behavior"], + "Development Expectations": ["Naming Conventions", "Accessibility Expectations", "Verification"] + }, + "sectionAliases": { + "Development Expectations": ["Development"], + "License and Contribution Terms": ["Contribution Terms", "License"] + }, + "subsectionAliases": { + "Who This Guide Is For": ["Audience"], + "Before You Start": ["Prerequisites"], + "Choosing Work": ["Picking Work"], + "Making Changes": ["Implementation Workflow"], + "Asking For Review": ["Requesting Review"], + "Naming Conventions": ["Naming"], + "Accessibility Expectations": ["Accessibility", "A11y Expectations"], + "Verification": ["Validation"] + } +} diff --git a/skills/maintain-project-contributing/config/contributing-customization.template.yaml b/skills/maintain-project-contributing/config/contributing-customization.template.yaml deleted file mode 100644 index 2724e5c63..000000000 --- a/skills/maintain-project-contributing/config/contributing-customization.template.yaml +++ /dev/null @@ -1,97 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - requiredSections: - - Overview - - Contribution Workflow - - Local Setup - - Development Expectations - - Pull Request Expectations - - Communication - - License and Contribution Terms - sectionOrder: - - Overview - - Contribution Workflow - - Local Setup - - Development Expectations - - Pull Request Expectations - - Communication - - License and Contribution Terms - requiredSubsections: - Overview: - - Who This Guide Is For - - Before You Start - Contribution Workflow: - - Choosing Work - - Making Changes - - Asking For Review - Local Setup: - - Runtime Config - - Runtime Behavior - Development Expectations: - - Naming Conventions - - Accessibility Expectations - - Verification - sectionAliases: - Development Expectations: - - Development - License and Contribution Terms: - - Contribution Terms - - License - subsectionAliases: - Overview/Who This Guide Is For: - - Audience - Overview/Before You Start: - - Prerequisites - Contribution Workflow/Choosing Work: - - Picking Work - Contribution Workflow/Making Changes: - - Implementation Workflow - Contribution Workflow/Asking For Review: - - Requesting Review - Development Expectations/Naming Conventions: - - Naming - Development Expectations/Accessibility Expectations: - - Accessibility - - A11y Expectations - Development Expectations/Verification: - - Validation - sectionTemplates: - Pull Request Expectations: | - Summarize what changed, why it changed, and what reviewers should pay attention to first. - Communication: | - Explain how contributors should raise questions, flag risky scope changes, or ask for clarification before work drifts. - License and Contribution Terms: | - Refer contributors to the project license and note any practical contribution terms or sign-off requirements when they exist. - subsectionTemplates: - Overview/Who This Guide Is For: | - Explain who should use this guide and what kinds of contributions it is meant to support. - Overview/Before You Start: | - Call out the most important prerequisites before someone begins work, such as reading nearby docs, checking open work, or understanding repo constraints. - Contribution Workflow/Choosing Work: | - Explain how contributors should choose or confirm work before they begin. - Contribution Workflow/Making Changes: | - Explain the normal path for making changes in this repository, including how to keep work bounded and coherent. - Contribution Workflow/Asking For Review: | - Explain when a change is ready for review and what contributors should double-check first. - Local Setup/Runtime Config: | - Document the concrete local configuration contributors need, including files, secrets, environment variables, or local services. - Local Setup/Runtime Behavior: | - Explain what needs to be running locally and how contributors can tell the project is actually working. - Development Expectations/Naming Conventions: | - Describe the terminology, casing, and naming patterns contributors should match when extending the project. - Development Expectations/Accessibility Expectations: | - Contributors must keep changes aligned with the project's accessibility contract in [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). - - If a change affects UI semantics, input behavior, focus flow, labels, announcements, motion, contrast, zoom behavior, content structure, or assistive-technology compatibility, verify the affected surface against the documented accessibility standards before asking for review. - - If a change introduces a new accessibility limitation, exception, or remediation plan, update `ACCESSIBILITY.md` in the same pass unless maintainers have explicitly agreed on a different tracking path. - Development Expectations/Verification: | - ```bash - # Replace this with the grounded validation commands for the project. - ``` - - Prefer grounded validation commands with fenced code blocks and language info strings when examples help. diff --git a/skills/maintain-project-contributing/references/contributing-config-schema.md b/skills/maintain-project-contributing/references/contributing-config-schema.md deleted file mode 100644 index e5bea0fdf..000000000 --- a/skills/maintain-project-contributing/references/contributing-config-schema.md +++ /dev/null @@ -1,28 +0,0 @@ -# Contributing Config Schema - -The contributing-guide configuration file is YAML and uses this top-level shape: - -- `schemaVersion` -- `isCustomized` -- `profile` -- `settings` - -The `settings` map supports: - -- `preservePreamble` -- `allowAdditionalSections` -- `requiredSections` -- `sectionOrder` -- `requiredSubsections` -- `sectionAliases` -- `subsectionAliases` -- `sectionTemplates` -- `subsectionTemplates` - -Practical rules: - -- `requiredSections` defines the canonical top-level sections, excluding the title, summary, and `Table of Contents`. -- `sectionOrder` defines the enforced top-level ordering. -- `requiredSubsections` defines required `###` headings under specific `##` sections. -- `sectionAliases` and `subsectionAliases` allow bounded migration from older heading names into canonical names. -- `sectionTemplates` and `subsectionTemplates` provide the base scaffolding used during apply mode when content is missing. diff --git a/skills/maintain-project-contributing/references/contributing-customization.md b/skills/maintain-project-contributing/references/contributing-customization.md deleted file mode 100644 index f565e0987..000000000 --- a/skills/maintain-project-contributing/references/contributing-customization.md +++ /dev/null @@ -1,19 +0,0 @@ -# Contributing Customization - -The base contributing-guide contract is defined in `../config/contributing-customization.template.yaml`. - -Downstream specializations may customize: - -- canonical section order -- required sections -- required subsection structure -- section and subsection aliases -- section and subsection scaffold text - -The base skill always requires: - -- a top-level title and short summary -- a `Table of Contents` -- deterministic normalization to the configured schema - -Use a project-local `config/contributing-customization.yaml` or `--config <path>` override when a downstream plugin needs a narrower or expanded contributing guide structure. diff --git a/skills/maintain-project-contributing/references/fix-policies.md b/skills/maintain-project-contributing/references/fix-policies.md deleted file mode 100644 index 630d4de75..000000000 --- a/skills/maintain-project-contributing/references/fix-policies.md +++ /dev/null @@ -1,8 +0,0 @@ -# Fix Policies - -- Keep edits bounded to the target `CONTRIBUTING.md`. -- Preserve existing section bodies whenever they already satisfy the schema. -- Create a missing `CONTRIBUTING.md` from the bundled template. -- Normalize canonical headings and subsection headings to the configured names and order. -- Add missing sections or subsections from the configured templates instead of inventing repo-fiction. -- Do not synthesize environment variable names, service names, branch naming schemes, review policies, or legal terms. diff --git a/skills/maintain-project-contributing/references/output-contract.md b/skills/maintain-project-contributing/references/output-contract.md deleted file mode 100644 index 5716d410b..000000000 --- a/skills/maintain-project-contributing/references/output-contract.md +++ /dev/null @@ -1,16 +0,0 @@ -# Output Contract - -Return Markdown plus JSON with: - -- `run_context` -- `schema_contract` -- `schema_violations` -- `command_integrity_issues` -- `content_quality_issues` -- `fixes_applied` -- `post_fix_status` -- `errors` - -Clean-run rule: - -- Output exactly `No findings.` only when there are no remaining issues and no errors. diff --git a/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md b/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md deleted file mode 100644 index bf6abe828..000000000 --- a/skills/maintain-project-contributing/references/project-contributing-maintenance-automation-prompts.md +++ /dev/null @@ -1,18 +0,0 @@ -# Automation Prompts - -Use these prompts when validating or applying the skill through automation. - -## Check-only - -- Audit `CONTRIBUTING.md` for the canonical section order. -- Confirm the required table of contents is present and matches the canonical top-level headings. -- Confirm `Overview`, `Contribution Workflow`, `Local Setup`, and `Development Expectations` contain the required subsections. -- Flag placeholder content and malformed or weak verification command blocks. - -## Apply - -- Create a missing `CONTRIBUTING.md` from the bundled template. -- Normalize `CONTRIBUTING.md` to the canonical section order. -- Preserve existing contributor guidance where it already matches the schema. -- Add missing sections or subsections from the configured templates only. -- Keep all edits bounded to `CONTRIBUTING.md`. diff --git a/skills/maintain-project-contributing/references/section-schema.md b/skills/maintain-project-contributing/references/section-schema.md deleted file mode 100644 index 7f95f1409..000000000 --- a/skills/maintain-project-contributing/references/section-schema.md +++ /dev/null @@ -1,43 +0,0 @@ -# Section Schema - -The canonical base `CONTRIBUTING.md` structure is defined by: - -- `../config/contributing-customization.template.yaml` -- `../assets/CONTRIBUTING.template.md` - -Base top-level shape: - -1. top-level title -2. short contributor-facing summary -3. `## Table of Contents` -4. `## Overview` -5. `## Contribution Workflow` -6. `## Local Setup` -7. `## Development Expectations` -8. `## Pull Request Expectations` -9. `## Communication` -10. `## License and Contribution Terms` - -Required subsection shape: - -- `Overview` - - `Who This Guide Is For` - - `Before You Start` -- `Contribution Workflow` - - `Choosing Work` - - `Making Changes` - - `Asking For Review` -- `Local Setup` - - `Runtime Config` - - `Runtime Behavior` -- `Development Expectations` - - `Naming Conventions` - - `Accessibility Expectations` - - `Verification` - -Schema expectations: - -- Use `##` headings for top-level sections. -- Use `###` headings for required subsections. -- Always include a top-level `Table of Contents`. -- Preserve additional repo-specific sections when present, but keep canonical sections in canonical order. diff --git a/skills/maintain-project-contributing/references/style-rules.md b/skills/maintain-project-contributing/references/style-rules.md deleted file mode 100644 index e17f62c61..000000000 --- a/skills/maintain-project-contributing/references/style-rules.md +++ /dev/null @@ -1,11 +0,0 @@ -# Style Rules - -- Keep contributor guidance direct, concrete, and repo-grounded. -- Favor short explanatory paragraphs plus small bullet lists when they improve scanability. -- Keep the whole CONTRIBUTING guide near 300 lines by default, with 350 lines as a soft ceiling for consolidation. -- Keep most top-level sections near 45 lines or less and most subsections near 25 lines or less. -- Treat `CONTRIBUTING.md` as a contributor workflow guide, not as a product overview or contributor roster. -- Keep `Local Setup` operational and practical, with explicit `Runtime Config` and `Runtime Behavior` subsections. -- Keep `Development Expectations` focused on naming, accessibility expectations, validation, and everyday contribution hygiene. -- Prefer fenced code blocks with language info strings when showing verification commands. -- Keep contributor workflow here and link outward to README, AGENTS, ACCESSIBILITY, or maintainer docs instead of duplicating their content. diff --git a/skills/maintain-project-contributing/scripts/maintain_project_contributing.py b/skills/maintain-project-contributing/scripts/maintain_project_contributing.py deleted file mode 100644 index 183b83372..000000000 --- a/skills/maintain-project-contributing/scripts/maintain_project_contributing.py +++ /dev/null @@ -1,825 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and apply bounded CONTRIBUTING.md maintenance from a hard-enforced schema.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -SHELL_FENCE_RE = re.compile(r"```([^\n`]*)\n(.*?)```", re.DOTALL) -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] - - -@dataclass -class Issue: - issue_id: str - category: str - severity: str - file: str - evidence: str - recommended_fix: str - auto_fixable: bool - fixed: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_id": self.issue_id, - "category": self.category, - "severity": self.severity, - "file": self.file, - "evidence": self.evidence, - "recommended_fix": self.recommended_fix, - "auto_fixable": self.auto_fixable, - "fixed": self.fixed, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded CONTRIBUTING.md maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--contributing-path", help="Optional CONTRIBUTING path override") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional CONTRIBUTING config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when unresolved issues remain") - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def slugify_heading(heading: str) -> str: - slug = heading.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"\s+", "-", slug) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - sections.append((match.group(1).strip(), text[start:end].strip("\n"))) - return preamble, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - subsections.append((match.group(1).strip(), body[start:end].strip("\n"))) - return preamble, subsections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def parse_title_and_summary(preamble: str) -> Tuple[Optional[str], Optional[str], List[str]]: - lines = [line.rstrip() for line in preamble.splitlines()] - title: Optional[str] = None - summary: Optional[str] = None - extras: List[str] = [] - title_index: Optional[int] = None - summary_index: Optional[int] = None - - for idx, line in enumerate(lines): - if line.startswith("# "): - title = line[2:].strip() - title_index = idx - break - - if title_index is None: - return None, None, lines - - for idx in range(title_index + 1, len(lines)): - if lines[idx].strip(): - summary = lines[idx].strip() - summary_index = idx - break - - for idx, line in enumerate(lines): - if idx in {title_index, summary_index}: - continue - extras.append(line) - - return title, summary, extras - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, project_name: str, preserve_preamble: bool) -> str: - title, summary, extras = parse_title_and_summary(preamble) - normalized_title = title or f"Contributing to {project_name}" - normalized_summary = ( - summary - or "Use this guide when preparing changes so the project stays understandable, runnable, and reviewable for the next contributor." - ) - lines = [f"# {normalized_title}", "", normalized_summary] - if preserve_preamble: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - lines.extend(["", *extra_lines]) - return "\n".join(lines).strip() - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "contributing-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "contributing-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - values = settings.get("requiredSections", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def canonical_order(settings: Dict[str, Any]) -> List[str]: - values = settings.get("sectionOrder", []) - return [str(item) for item in values] if isinstance(values, list) else [] - - -def required_subsections(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("requiredSubsections", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def subsection_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("subsectionAliases", {}) - if not isinstance(raw, dict): - return {} - return { - str(key): [str(item) for item in value] - for key, value in raw.items() - if isinstance(value, list) - } - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("subsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def build_toc(headings: Sequence[str]) -> str: - return "\n".join(f"- [{heading}](#{slugify_heading(heading)})" for heading in headings) - - -def toc_entries(body: str) -> List[str]: - entries: List[str] = [] - for line in body.splitlines(): - match = re.match(r"^\s*-\s+\[(.+?)\]\(#(.+?)\)\s*$", line.strip()) - if match: - entries.append(match.group(1).strip()) - return entries - - -def section_alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - reverse: Dict[str, str] = {} - for canonical, aliases in section_aliases(settings).items(): - for alias in aliases: - reverse[alias] = canonical - return reverse - - -def subsection_alias_lookup(settings: Dict[str, Any]) -> Dict[Tuple[str, str], str]: - reverse: Dict[Tuple[str, str], str] = {} - for canonical_path, aliases in subsection_aliases(settings).items(): - if "/" not in canonical_path: - continue - parent, canonical_name = canonical_path.split("/", 1) - for alias in aliases: - reverse[(parent, alias)] = canonical_name - return reverse - - -def render_template_bootstrap(project_root: Path) -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "CONTRIBUTING.template.md" - template = read_text(template_path) - return normalize_whitespace(template.replace("{{PROJECT_NAME}}", project_root.name)) - - -def render_section_body(heading: str, existing_body: str, settings: Dict[str, Any]) -> str: - required_children = required_subsections(settings).get(heading, []) - section_template_map = section_templates(settings) - subsection_template_map = subsection_templates(settings) - subsection_alias_map = subsection_alias_lookup(settings) - - if not required_children: - return existing_body.strip() or section_template_map.get(heading, "") - - preamble, subsections = split_subsections(existing_body) - subsection_lookup: Dict[str, str] = {} - extra_subsections: List[Tuple[str, str]] = [] - for name, body in subsections: - canonical_name = subsection_alias_map.get((heading, name), name) - if canonical_name in required_children and canonical_name not in subsection_lookup: - subsection_lookup[canonical_name] = body - else: - extra_subsections.append((name, body)) - - lines: List[str] = [] - if preamble.strip(): - lines.extend([preamble.strip(), ""]) - - for idx, child in enumerate(required_children): - child_body = subsection_lookup.get(child, "").strip() or subsection_template_map.get(f"{heading}/{child}", "") - lines.extend([f"### {child}", "", child_body.strip()]) - if idx < len(required_children) - 1 or extra_subsections: - lines.append("") - - for idx, (name, body) in enumerate(extra_subsections): - lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extra_subsections) - 1: - lines.append("") - - rendered = "\n".join(lines).strip() - return rendered or section_template_map.get(heading, "") - - -def validate_schema( - contributing_path: Path, - contributing_text: str, - config: Dict[str, Any], -) -> Tuple[List[Issue], List[Issue], List[Issue], List[Tuple[str, str]]]: - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - subsection_map = required_subsections(settings) - section_alias_map = section_alias_lookup(settings) - - schema_issues: List[Issue] = [] - command_issues: List[Issue] = [] - content_issues: List[Issue] = [] - - preamble, sections = split_sections(contributing_text) - lookup = section_map(sections) - title, summary, _extras = parse_title_and_summary(preamble) - - if not title: - schema_issues.append( - Issue( - issue_id="missing-title", - category="schema", - severity="high", - file=str(contributing_path), - evidence="CONTRIBUTING.md is missing a top-level '# Contributing to <project>' title.", - recommended_fix="Add a clear top-level CONTRIBUTING title.", - auto_fixable=True, - ) - ) - if not summary: - schema_issues.append( - Issue( - issue_id="missing-summary", - category="schema", - severity="medium", - file=str(contributing_path), - evidence="CONTRIBUTING.md is missing a short contributor-facing summary beneath the title.", - recommended_fix="Add a short summary sentence beneath the top-level title.", - auto_fixable=True, - ) - ) - - if "Table of Contents" not in lookup: - schema_issues.append( - Issue( - issue_id="missing-table-of-contents", - category="schema", - severity="medium", - file=str(contributing_path), - evidence="CONTRIBUTING.md is missing the required '## Table of Contents' section.", - recommended_fix="Add a table of contents that mirrors the canonical top-level headings.", - auto_fixable=True, - ) - ) - - observed_headings = [heading for heading, _body in sections] - canonical_positions = {heading: idx for idx, heading in enumerate(observed_headings)} - for heading in required: - if heading in lookup: - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in lookup), - None, - ) - if alias_found: - schema_issues.append( - Issue( - issue_id=f"non-canonical-heading-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(contributing_path), - evidence=f"CONTRIBUTING.md uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - recommended_fix=f"Rename '## {alias_found}' to '## {heading}'.", - auto_fixable=True, - ) - ) - else: - schema_issues.append( - Issue( - issue_id=f"missing-section-{slugify_heading(heading)}", - category="schema", - severity="high", - file=str(contributing_path), - evidence=f"CONTRIBUTING.md is missing required section '## {heading}'.", - recommended_fix=f"Add the required '## {heading}' section.", - auto_fixable=True, - ) - ) - - order_positions: List[int] = [] - for heading in order: - if heading in canonical_positions: - order_positions.append(canonical_positions[heading]) - continue - alias_found = next( - (alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in canonical_positions), - None, - ) - if alias_found: - order_positions.append(canonical_positions[alias_found]) - if order_positions and order_positions != sorted(order_positions): - schema_issues.append( - Issue( - issue_id="canonical-section-order", - category="schema", - severity="medium", - file=str(contributing_path), - evidence="Canonical CONTRIBUTING sections are not in the configured order.", - recommended_fix="Normalize the top-level sections into canonical order.", - auto_fixable=True, - ) - ) - - subsection_alias_map = subsection_alias_lookup(settings) - for parent, children in subsection_map.items(): - body = lookup.get(parent, "") - if not body: - alias_parent = next( - (alias for alias, canonical in section_alias_map.items() if canonical == parent and alias in lookup), - None, - ) - body = lookup.get(alias_parent, "") if alias_parent else "" - if not body: - continue - _preamble, subsections = split_subsections(body) - found = { - subsection_alias_map.get((parent, name), name): subsection_body - for name, subsection_body in subsections - } - for child in children: - if child not in found: - schema_issues.append( - Issue( - issue_id=f"missing-subsection-{slugify_heading(parent)}-{slugify_heading(child)}", - category="schema", - severity="high", - file=str(contributing_path), - evidence=f"Section '## {parent}' is missing required subsection '### {child}'.", - recommended_fix=f"Add the required subsection '### {child}' under '## {parent}'.", - auto_fixable=True, - ) - ) - - if "Table of Contents" in lookup: - expected_toc = [heading for heading in order if heading in required or heading in lookup] - actual_toc = toc_entries(lookup["Table of Contents"]) - if actual_toc != expected_toc: - schema_issues.append( - Issue( - issue_id="stale-table-of-contents", - category="schema", - severity="low", - file=str(contributing_path), - evidence="Table of contents entries do not match the canonical top-level section headings in order.", - recommended_fix="Regenerate the table of contents from the canonical section list.", - auto_fixable=True, - ) - ) - - for heading in required: - required_body = lookup.get(heading) - if not required_body: - continue - if not required_body.strip(): - schema_issues.append( - Issue( - issue_id=f"empty-section-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(contributing_path), - evidence=f"Section '## {heading}' is present but empty.", - recommended_fix=f"Add grounded content to '## {heading}'.", - auto_fixable=True, - ) - ) - if any(pattern.search(required_body) for pattern in PLACEHOLDER_PATTERNS): - content_issues.append( - Issue( - issue_id=f"placeholder-content-{slugify_heading(heading)}", - category="content-quality", - severity="medium", - file=str(contributing_path), - evidence=f"Section '## {heading}' contains placeholder-style content.", - recommended_fix="Replace placeholder content with repo-grounded contributor guidance.", - auto_fixable=False, - ) - ) - - verification_body = lookup.get("Development Expectations", "") - _preamble, dev_subsections = split_subsections(verification_body) - verification_lookup = {name: body for name, body in dev_subsections} - verification_text = verification_lookup.get("Verification", "").strip() - if verification_text: - shell_blocks = list(SHELL_FENCE_RE.finditer(verification_text)) - if shell_blocks: - for match in shell_blocks: - info = match.group(1).strip() - block = match.group(2).strip() - if not info: - command_issues.append( - Issue( - issue_id=f"missing-code-fence-info-string-{match.start()}", - category="command-integrity", - severity="low", - file=str(contributing_path), - evidence="Verification uses a fenced code block without a language info string.", - recommended_fix="Use fenced code blocks with an info string such as ```bash for verification commands.", - auto_fixable=False, - ) - ) - if not block: - command_issues.append( - Issue( - issue_id=f"empty-shell-block-{match.start()}", - category="command-integrity", - severity="medium", - file=str(contributing_path), - evidence="Verification contains an empty fenced code block.", - recommended_fix="Remove the empty block or replace it with grounded validation commands.", - auto_fixable=True, - ) - ) - if any(pattern.search(block) for pattern in PLACEHOLDER_PATTERNS): - command_issues.append( - Issue( - issue_id=f"placeholder-command-block-{match.start()}", - category="command-integrity", - severity="high", - file=str(contributing_path), - evidence="Verification contains a placeholder command block.", - recommended_fix="Replace the placeholder command block with grounded validation commands or prose.", - auto_fixable=False, - ) - ) - elif len(verification_text.split()) < 6: - content_issues.append( - Issue( - issue_id="thin-verification-guidance", - category="content-quality", - severity="medium", - file=str(contributing_path), - evidence="Development Expectations > Verification is too thin to help contributors validate changes.", - recommended_fix="Add grounded validation guidance, preferably with fenced code blocks and language info strings.", - auto_fixable=False, - ) - ) - - return schema_issues, command_issues, content_issues, sections - - -def apply_fixes( - project_root: Path, - contributing_path: Path, - contributing_text: str, - config: Dict[str, Any], -) -> Tuple[str, List[Dict[str, str]]]: - if not contributing_text.strip(): - bootstrap = render_template_bootstrap(project_root) - write_text(contributing_path, bootstrap) - return ( - bootstrap, - [ - { - "action": "create-contributing-from-template", - "file": str(contributing_path), - "reason": "Created a missing CONTRIBUTING.md from the bundled canonical template.", - } - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - preserve_preamble = bool(settings.get("preservePreamble", True)) - allow_additional = bool(settings.get("allowAdditionalSections", True)) - section_alias_map = section_alias_lookup(settings) - - preamble, sections = split_sections(contributing_text) - normalized_preamble = normalize_preamble(preamble, project_root.name, preserve_preamble) - - canonical_lookup: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - for heading, body in sections: - if heading == "Table of Contents": - continue - canonical_heading = section_alias_map.get(heading, heading) - if canonical_heading in order or canonical_heading in required: - canonical_lookup[canonical_heading] = body - elif allow_additional: - extra_sections.append((heading, body)) - - canonical_sections: List[Tuple[str, str]] = [] - for heading in order: - body = render_section_body(heading, canonical_lookup.get(heading, ""), settings).strip() - canonical_sections.append((heading, body)) - - headings_for_toc = [heading for heading, _body in canonical_sections] - if allow_additional: - headings_for_toc.extend(heading for heading, _body in extra_sections) - rendered_sections = [("Table of Contents", build_toc(headings_for_toc).strip()), *canonical_sections] - if allow_additional: - rendered_sections.extend(extra_sections) - - parts = [normalized_preamble] - for heading, body in rendered_sections: - parts.extend(["", f"## {heading}", "", body.strip()]) - document = "\n".join(parts).strip() + "\n" - return normalize_whitespace(document), [ - { - "action": "normalize-contributing-structure", - "file": str(contributing_path), - "reason": "Normalized CONTRIBUTING.md to the canonical template-backed section schema.", - } - ] - - -def format_report(report: Dict[str, Any]) -> str: - total_issues = ( - len(report["schema_violations"]) - + len(report["command_integrity_issues"]) - + len(report["content_quality_issues"]) - ) - if total_issues == 0 and not report["errors"]: - return "No findings." - - lines = [ - "# CONTRIBUTING.md Maintenance Report", - "", - f"- Target: `{report['run_context']['contributing_path']}`", - f"- Mode: `{report['run_context']['run_mode']}`", - f"- Config: `{report['schema_contract']['config_path']}`", - ] - - for key, title in ( - ("schema_violations", "Schema Violations"), - ("command_integrity_issues", "Command Integrity Issues"), - ("content_quality_issues", "Content Quality Issues"), - ("fixes_applied", "Fixes Applied"), - ("errors", "Errors"), - ): - items = report[key] - if not items: - continue - lines.extend(["", f"## {title}"]) - for item in items: - evidence = item.get("evidence") or item.get("reason") or item.get("message") - lines.append(f"- {item.get('issue_id', item.get('action', 'item'))}: {evidence}") - - return "\n".join(lines).strip() + "\n" - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - if not project_root.is_dir(): - raise ValueError(f"Project root does not exist or is not a directory: {project_root}") - - contributing_path = ( - Path(args.contributing_path).expanduser().resolve() - if args.contributing_path - else project_root / "CONTRIBUTING.md" - ) - config = load_config(project_root, args.config) - - errors: List[str] = [] - fixes_applied: List[Dict[str, str]] = [] - existing_text = read_text(contributing_path) if contributing_path.is_file() else "" - - if args.run_mode == "apply": - new_text, applied = apply_fixes(project_root, contributing_path, existing_text, config) - if not contributing_path.parent.exists(): - contributing_path.parent.mkdir(parents=True, exist_ok=True) - if normalize_whitespace(existing_text) != new_text: - write_text(contributing_path, new_text) - fixes_applied.extend(applied) - existing_text = new_text - - if existing_text: - schema_issues, command_issues, content_issues, _sections = validate_schema( - contributing_path, existing_text, config - ) - else: - schema_issues = [ - Issue( - issue_id="missing-contributing-file", - category="schema", - severity="high", - file=str(contributing_path), - evidence="CONTRIBUTING.md does not exist.", - recommended_fix="Create the canonical CONTRIBUTING.md file from the bundled template.", - auto_fixable=True, - ) - ] - command_issues = [] - content_issues = [] - - report = { - "run_context": { - "project_root": str(project_root), - "contributing_path": str(contributing_path), - "run_mode": args.run_mode, - "generated_at": datetime.now(timezone.utc).isoformat(), - }, - "schema_contract": { - "config_path": config.get("configPath"), - "default_config_path": config.get("defaultConfigPath"), - "required_table_of_contents": True, - "required_sections": required_sections(config_settings(config)), - "section_order": canonical_order(config_settings(config)), - "required_subsections": required_subsections(config_settings(config)), - }, - "schema_violations": [issue.to_dict() for issue in schema_issues], - "command_integrity_issues": [issue.to_dict() for issue in command_issues], - "content_quality_issues": [issue.to_dict() for issue in content_issues], - "fixes_applied": fixes_applied, - "post_fix_status": { - "remaining_issue_count": len(schema_issues) + len(command_issues) + len(content_issues), - "is_clean": not schema_issues and not command_issues and not content_issues and not errors, - }, - "errors": errors, - } - markdown = format_report(report) - return report, markdown - - -def main() -> int: - args = parse_args() - try: - report, markdown = run_maintenance(args) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - if args.json_out: - Path(args.json_out).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - if args.md_out: - Path(args.md_out).write_text(markdown, encoding="utf-8") - if args.print_json: - print(json.dumps(report, indent=2)) - if args.print_md: - print(markdown, end="") - - has_issues = ( - bool(report["schema_violations"]) - or bool(report["command_integrity_issues"]) - or bool(report["content_quality_issues"]) - or bool(report["errors"]) - ) - if args.fail_on_issues and has_issues: - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/maintain-project-readme/SKILL.md b/skills/maintain-project-readme/SKILL.md index ffecfd8fb..82167806c 100644 --- a/skills/maintain-project-readme/SKILL.md +++ b/skills/maintain-project-readme/SKILL.md @@ -1,96 +1,62 @@ --- name: maintain-project-readme -description: Maintain README.md files against a canonical base schema with deterministic check-only and bounded apply modes. Use when a repository needs baseline structure, normalization, or targeted fixes without weakening downstream customization. +description: Maintain README.md as the product-facing member of the canonical four-document repository suite. --- # Maintain Project README -Maintain `README.md` files through one deterministic base-template workflow. +## Purpose -This skill is the primary layer for README maintenance. It defines the canonical shared README contract that downstream language-, framework-, stack-, or repository-specific customization can adapt through explicit extension, instead of ad hoc structure drift. +Keep `README.md` product-focused while the repository's README, CONTRIBUTING, +AGENTS, and ROADMAP documents are checked or applied as one deterministic unit. -## Inputs +## Commands -- Required: `--project-root <path>` -- Required: `--run-mode <check-only|apply>` -- Optional: `--readme-path <path>` -- Optional: `--config <path>` +There are exactly two documentation commands: -## Workflow +```text +just docs-check +just docs-apply +``` -1. Validate the project root and resolve the target `README.md`. -2. Load the canonical README schema from the built-in template config, then merge any explicit customization override. -3. In `check-only`, audit title and summary requirements, top-level section names and order, required subsection names, the required table of contents, and placeholder-style content. -4. In `apply`, keep edits bounded to the target `README.md` while normalizing the README into the configured canonical structure. -5. Preserve preamble material such as badges, callouts, screenshots, and extra intro prose before the first H2 while normalizing the structural contract around it. -6. When bootstrapping a missing `README.md`, ask the user for text for `Overview > Status`, `Overview > What This Project Is`, and `Overview > Motivation` before writing those subsections. -7. Use the bundled README template when bootstrapping a missing `README.md` or when a downstream workflow needs a canonical starter document; if the user has not provided text for any Overview subsection, leave that subsection body exactly `TBD`. -8. Re-run the same audit to confirm post-fix status. -9. For skills, plugin, or hybrid repositories, keep the same hard-enforced schema while grounding install, discovery, packaging, and maintainer wording in the real repo surface instead of inventing ordinary-app sections that are not actually shipped. +Both commands always process all four canonical documents in this order: +README, CONTRIBUTING, AGENTS, ROADMAP. Never expose or recommend a per-file +documentation command or direct `.fsx` invocation. -## Writing Expectations +## Managed Contract -- `README.md` is product-focused: write it for end users, evaluators, integrators, and their agents who need to understand what the project is, whether it fits, how to try it, and where the shipped surface lives. -- Contributor, maintainer, release, validation, branch, review, and local development procedures belong in `CONTRIBUTING.md` or a linked maintainer document. In `README.md`, keep only the shortest useful pointer to that contributor path. -- Keep the whole README near 250 lines or less by default. Treat 300 lines as a soft ceiling that should trigger consolidation unless the user explicitly wants a long-form README. -- Keep most generated or agent-edited top-level sections near 40 lines or less. Split or hoist content only when it clarifies ownership; otherwise trim repetition and link to the canonical owner. -- The user-authored `Overview` subsections may be longer when the user supplies that text. Do not shorten `Overview > Status`, `Overview > What This Project Is`, or `Overview > Motivation` unless the user explicitly asks. -- `Overview > Status`, `Overview > What This Project Is`, and `Overview > Motivation` must be written by the user in the user's own words, never by the agent. -- If one of those Overview subsections already contains text, leave that text intact and untouched unless the user explicitly provides replacement text for that exact subsection. -- If one of those Overview subsections is empty or missing, set the subsection body to exactly `TBD`; for new README files, ask the user for text to place there before falling back to `TBD`. -- `Quick Start` should stay human-focused, short, concise, and end-user friendly, or explicitly say the project is still too early for a real quick start and direct curious readers to `Development`. -- `Usage` should stay human-focused, concise, and informative. Prefer fenced code blocks with info strings when examples help. -- `Development` should stay short and reader-oriented. Prefer a direct link to `CONTRIBUTING.md` for setup, workflow, validation, review, and maintainer commands instead of duplicating those procedures in the README. -- `Repo Structure` should be a small directory tree or outline diagram, not a prose section. -- Keep README, CONTRIBUTING, ROADMAP, and AGENTS responsibilities distinct. Product summary and end-user fit belong here; contribution workflow belongs in `CONTRIBUTING.md`; backlog and small-ticket planning belong in `ROADMAP.md` by default; agent-facing maintainer rules belong in `AGENTS.md`. +- `assets/document.contract.json` is the fixed structural contract. +- `assets/README.template.md` is the bootstrap and missing-content asset. +- The contract is versioned with the skill and is not project-customizable. +- Existing prose and allowed additional sections are preserved; canonical + headings, aliases, ordering, and fix policy cannot be overridden. -## Codex Subagent Fit +## README Ownership -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy README discovery before the main workflow edits or reports: checking source docs, inventorying commands, inspecting sibling package metadata, or comparing README claims against one upstream source per worker. +README owns product identity, current status, quick start, usage, repository +shape, release-note discovery, and license discovery. Contributor workflow, +agent policy, release procedure, and roadmap tickets belong to their canonical +owners and should be linked rather than duplicated. -Keep `apply` edits in the main thread because this skill has one target file and a hard-enforced schema. Ask subagents to return concise evidence and file references, not replacement README prose. +Preserve existing user-authored Overview prose. Missing Overview content uses +the exact managed `TBD` scaffold and is reported without inventing claims. -## Canonical Base Contract +## Deterministic Workflow -The authoritative default shared README structure lives in: - -- `config/readme-customization.template.yaml` -- `assets/README.template.md` - -Treat those two files as the source of truth for the canonical base schema and the canonical bootstrap document. Downstream plugins may extend that structure through preamble and appendices, but this base skill treats the required table of contents plus the configured section block as hard-enforced. - -## Output Contract - -- Return Markdown plus JSON with: - - `run_context` - - `customization_state` - - `schema_contract` - - `schema_violations` - - `content_quality_issues` - - `fixes_applied` - - `post_fix_status` - - `errors` -- If there are no issues and no errors, output exactly `No findings.` +1. Run `just docs-check` for a no-write four-document audit. +2. Run `just docs-apply` when structural normalization is requested. +3. The coordinator plans all four outputs before writing any file. +4. Apply uses atomic replacements and rolls back completed writes on failure. +5. A second apply must be byte-identical and produce no change. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent commands, setup steps, or product claims that are not grounded in the repo. -- Never edit files other than the target `README.md`. -- Never move contributor or maintainer procedures into `README.md` when `CONTRIBUTING.md` or a maintainer doc is the correct owner. -- Keep the README schema hard-enforced against the configured contract instead of inferring structure from repo profile heuristics. -- Do not relax the configured schema just because the repository is a plugin, skills, or hybrid repo. Use explicit extension via preamble or appendices when the repo genuinely needs an additional structure or section. +- Never edit or check README in isolation from the full document suite. +- Never invent product claims, commands, guarantees, or support promises. +- Never add project-local schema or fix-policy customization. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `config/readme-customization.template.yaml` +- `assets/document.contract.json` - `assets/README.template.md` -- `references/section-schema.md` -- `references/readme-customization.md` -- `references/readme-config-schema.md` -- `references/output-contract.md` -- `references/fix-policies.md` -- `references/style-rules.md` -- `references/verification-checklist.md` -- `references/project-readme-maintenance-automation-prompts.md` diff --git a/skills/maintain-project-readme/assets/document.contract.json b/skills/maintain-project-readme/assets/document.contract.json new file mode 100644 index 000000000..f7579eb92 --- /dev/null +++ b/skills/maintain-project-readme/assets/document.contract.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "document": "readme", + "targetFile": "README.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Overview", "Quick Start", "Usage", "Development", "Repo Structure", "Release Notes", "License"], + "sectionOrder": ["Overview", "Quick Start", "Usage", "Development", "Repo Structure", "Release Notes", "License"], + "requiredSubsections": { + "Overview": ["Status", "What This Project Is", "Motivation"] + }, + "sectionAliases": { + "Quick Start": ["Getting Started", "Installation"], + "Usage": ["Examples"] + }, + "subsectionAliases": {} +} diff --git a/skills/maintain-project-readme/config/readme-customization.template.yaml b/skills/maintain-project-readme/config/readme-customization.template.yaml deleted file mode 100644 index c82c959e6..000000000 --- a/skills/maintain-project-readme/config/readme-customization.template.yaml +++ /dev/null @@ -1,71 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - requiredSections: - - Overview - - Quick Start - - Usage - - Development - - Repo Structure - - Release Notes - - License - sectionOrder: - - Overview - - Quick Start - - Usage - - Development - - Repo Structure - - Release Notes - - License - requiredSubsections: - Overview: - - Status - - What This Project Is - - Motivation - sectionAliases: - Quick Start: - - Getting Started - - Installation - Usage: - - Examples - sectionTemplates: - Overview: | - ### Status - - TBD - - ### What This Project Is - - TBD - - ### Motivation - - TBD - Quick Start: | - Give a human-friendly quick start for trying or using the project. If the project is still too early for a real quick start, say that plainly and direct curious readers to the Development section for contributor documentation. - Usage: | - Keep this section concise and human-focused. Prefer fenced code blocks with language info strings when examples help explain normal usage. - Development: | - For setup, local workflow, validation, and contribution expectations, see [CONTRIBUTING.md](./CONTRIBUTING.md). - Repo Structure: | - ```text - . - ├── path/ - └── path/ - ``` - - Replace this outline with a short directory tree for the important repository surfaces. - Release Notes: | - Summarize how releases, version notes, or notable shipped changes are tracked for this project. - License: | - See [LICENSE](./LICENSE). - subsectionTemplates: - Overview/Status: | - TBD - Overview/What This Project Is: | - TBD - Overview/Motivation: | - TBD diff --git a/skills/maintain-project-readme/references/fix-policies.md b/skills/maintain-project-readme/references/fix-policies.md deleted file mode 100644 index ffcc94ba1..000000000 --- a/skills/maintain-project-readme/references/fix-policies.md +++ /dev/null @@ -1,26 +0,0 @@ -# Fix Policies - -## Allowed Automatic Fixes - -- add missing canonical top-level sections from the configured schema -- add missing required subsections inside existing canonical sections -- normalize top-level section ordering into the configured canonical order -- migrate configured alias headings into canonical heading names -- add or refresh the required H2-only table of contents -- replace a missing title/summary block with grounded repo-neutral wording -- fill empty required sections or subsections with readable neutral scaffolding - -## Disallowed Automatic Fixes - -- invent quick-start, setup, workflow, validation, deploy, or release commands -- invent audience claims, performance claims, guarantees, or support promises -- rewrite healthy prose just to make it sound more generated -- edit files other than the target `README.md` - -## Review Bias - -- prefer hard structural normalization over soft structural hints -- prefer preserving good existing prose within a section while normalizing the surrounding schema -- prefer alias migration over deleting useful content -- preserve preamble material before the first H2 when it remains coherent -- report placeholder-style content instead of pretending the repo provides facts that are not visible diff --git a/skills/maintain-project-readme/references/output-contract.md b/skills/maintain-project-readme/references/output-contract.md deleted file mode 100644 index fb3c1ed9e..000000000 --- a/skills/maintain-project-readme/references/output-contract.md +++ /dev/null @@ -1,29 +0,0 @@ -# Output Contract - -## Markdown Sections - -1. Run Context -2. Customization State -3. Schema Contract -4. Schema Violations -5. Content Quality Issues -6. Fixes Applied -7. Post-Fix Status -8. Errors - -## JSON Top-Level Keys - -- `run_context` -- `customization_state` -- `schema_contract` -- `schema_violations` -- `content_quality_issues` -- `fixes_applied` -- `post_fix_status` -- `errors` - -## Exit Policy - -- Print exactly `No findings.` when there are no issues and no errors. -- Exit `0` for successful runs unless `--fail-on-issues` is set and unresolved issues remain. -- Exit `1` for fatal runtime errors or incompatible repo-type routing failures. diff --git a/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md b/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md deleted file mode 100644 index f5acc2dbf..000000000 --- a/skills/maintain-project-readme/references/project-readme-maintenance-automation-prompts.md +++ /dev/null @@ -1,86 +0,0 @@ -# Project README Maintenance Automation Prompt Templates - -## Suitability - -- Codex App: `Strong` -- Codex CLI: `Strong` - -## Codex App Automation Prompt Template - -```markdown -Use $maintain-project-readme. - -Scope: -- Project root: <PROJECT_ROOT_ABS_PATH> -- README path override: <README_PATH_OR_NONE> -- README config override: <README_CONFIG_OR_NONE> - -Execution policy: -- Load the canonical README config first. -- Run `check-only` first and summarize customization state, schema contract, schema violations, and content-quality issues. -- If <APPLY_FIXES_TRUE_FALSE> is true, run bounded README fixes and re-check. -- Preserve existing preamble content such as badges, callouts, screenshots, and intro prose before the first H2. -- Treat the configured README structure as hard-enforced. -- Treat `Status`, `What This Project Is`, and `Motivation` as user-authored Overview subsections that should never be written or replaced with invented claims. -- For an existing README, leave text in those Overview subsections intact; for empty or missing Overview subsection bodies, use exactly `TBD`. -- For a new README, ask the user for text for those Overview subsections before falling back to `TBD`. -- Keep `Quick Start` and `Usage` short, succinct, human-focused and end-user friendly; prefer fenced code blocks with info strings in `Usage` when examples help. -- Never invent commands, setup steps, or unsupported product claims. -- Never edit files other than the target `README.md`. -- Confirm with the user before a commit or push. - -Output contract: -- Return Markdown summary and JSON-ready fields for: - run_context, customization_state, schema_contract, schema_violations, - content_quality_issues, fixes_applied, post_fix_status, errors. -- Write reports to: - - <REPORT_MD_PATH> - - <REPORT_JSON_PATH> - -No-findings handling: -- If there are no issues and no errors, output exactly `No findings.`. -``` - -## Codex CLI Automation Prompt Template - -### Variant A: Audit-only - -```markdown -Use $maintain-project-readme. - -Audit the project README under <PROJECT_ROOT_ABS_PATH>. -If needed, use README override path <README_PATH_OR_NONE>. -If needed, use README config override <README_CONFIG_OR_NONE>. -Load the canonical README config first, then run `check-only`. -Report customization state, schema contract, schema violations, and content-quality issues. -Write outputs to <REPORT_MD_PATH> and <REPORT_JSON_PATH>. -If there are no issues and no errors, output exactly `No findings.`. -``` - -### Variant B: Audit + bounded fixes - -```markdown -Use $maintain-project-readme. - -Audit the project README under <PROJECT_ROOT_ABS_PATH>. -If needed, use README override path <README_PATH_OR_NONE>. -If needed, use README config override <README_CONFIG_OR_NONE>. -Load the canonical README config first, then run `check-only`, then bounded README fixes, then re-check. -Preserve badges, callouts, screenshots, and extra intro prose before the first H2. -Treat the configured structure as hard-enforced. -Treat `Status`, `What This Project Is`, and `Motivation` as user-authored Overview subsections that should never be written or replaced with invented claims. -For existing READMEs, leave text in those Overview subsections intact; for empty or missing Overview subsection bodies, use exactly `TBD`. -For new READMEs, ask the user for text for those Overview subsections before falling back to `TBD`. -Keep `Quick Start` and `Usage` human-focused and end-user friendly; prefer fenced code blocks with info strings in `Usage` when examples help. -Do not invent commands or edit files other than the target `README.md`. -Write outputs to <REPORT_MD_PATH> and <REPORT_JSON_PATH>. -``` - -## Placeholders - -- `<PROJECT_ROOT_ABS_PATH>` -- `<README_PATH_OR_NONE>` -- `<README_CONFIG_OR_NONE>` -- `<APPLY_FIXES_TRUE_FALSE>` -- `<REPORT_MD_PATH>` -- `<REPORT_JSON_PATH>` diff --git a/skills/maintain-project-readme/references/readme-config-schema.md b/skills/maintain-project-readme/references/readme-config-schema.md deleted file mode 100644 index 4a9226c3d..000000000 --- a/skills/maintain-project-readme/references/readme-config-schema.md +++ /dev/null @@ -1,32 +0,0 @@ -# README Configuration Schema - -Persistent README customization for `maintain-project-readme` is defined in: - -- Template defaults: `config/readme-customization.template.yaml` -- User or downstream overrides: explicit `--config <path>` or project-local `config/readme-customization.yaml` - -## Top-level fields - -- `schemaVersion`: integer schema version (`1`) -- `isCustomized`: `true` when the loaded config is an override rather than only the built-in template -- `profile`: short profile label such as `base`, `python-library`, or `typescript-service` -- `settings`: README schema behavior controls - -## `settings` fields - -- `preservePreamble`: boolean -- `allowAdditionalSections`: boolean -- `requiredSections`: ordered list of exact canonical H2 headings -- `sectionOrder`: ordered list of exact canonical H2 headings used for normalization -- `requiredSubsections`: map of H2 heading to ordered list of exact canonical H3 headings -- `sectionAliases`: map of canonical H2 heading to alias heading list used for migration -- `sectionTemplates`: map of H2 heading to neutral scaffolding text -- `subsectionTemplates`: map of `Parent/Child` to neutral scaffolding text - -## Runtime Behavior - -- The merged config is authoritative for both `check-only` and `apply`. -- `requiredSections` and `sectionOrder` should describe the same canonical block. -- Alias headings are migration hints only and must not remain in canonical output after apply. -- The base contract treats `Table of Contents` as required unconditionally. -- Unknown keys should be tolerated but ignored unless a downstream plugin explicitly documents them. diff --git a/skills/maintain-project-readme/references/readme-customization.md b/skills/maintain-project-readme/references/readme-customization.md deleted file mode 100644 index 34f1f0bd3..000000000 --- a/skills/maintain-project-readme/references/readme-customization.md +++ /dev/null @@ -1,44 +0,0 @@ -# README Customization Guide - -## Why Customization Exists - -`maintain-project-readme` is the general template layer for ordinary project READMEs. The base schema is intentionally strict, but downstream plugins can adapt it through explicit config instead of forking the workflow into unrelated variants. - -## Canonical Base Defaults - -The built-in template config in `config/readme-customization.template.yaml` defines: - -- title plus one-line summary -- always-required H2-only table of contents -- canonical top-level sections -- required `Overview` subsections -- a short `Development` handoff to contributor documentation, usually `CONTRIBUTING.md` - -## Supported Customization Knobs - -- `profile` - - Human-readable label for the active schema profile. -- `settings.requiredSections` - - Exact canonical top-level sections that must exist. -- `settings.sectionOrder` - - Exact canonical top-level order used for normalization. -- `settings.requiredSubsections` - - Exact required `###` subsections keyed by parent H2 section. -- `settings.sectionAliases` - - Migration hints from non-canonical headings to canonical output headings. -- `settings.sectionTemplates` - - Neutral scaffolding text for missing required sections. -- `settings.subsectionTemplates` - - Neutral scaffolding text for missing required subsections. -- `settings.allowAdditionalSections` - - Whether repo-specific extra sections are preserved after the canonical block. -- `settings.preservePreamble` - - Whether content before the first H2 is preserved during apply mode. - -## Customization Policy - -- Downstream plugins may add sections, subsections, alias mappings, and scaffolding. -- Downstream plugins may reorder canonical sections. -- Downstream plugins should preserve the table of contents as part of the shared base structure. -- The configured structure remains authoritative once loaded. -- Apply mode should normalize into the configured schema, not negotiate with existing README drift. diff --git a/skills/maintain-project-readme/references/section-schema.md b/skills/maintain-project-readme/references/section-schema.md deleted file mode 100644 index 362a20f4e..000000000 --- a/skills/maintain-project-readme/references/section-schema.md +++ /dev/null @@ -1,29 +0,0 @@ -# Section Schema - -## Canonical Base README Structure - -The canonical base README structure is defined in `config/readme-customization.template.yaml`. - -## Hard-Enforced Rules - -- Top-level canonical sections use exact `##` heading names from the configured schema. -- Required subsections use exact `###` heading names from the configured schema. -- Canonical sections appear in canonical order. -- `Overview` owns the canonical `Status`, `What This Project Is`, and `Motivation` subsections. -- `Development` is a short handoff to contributor documentation, usually `CONTRIBUTING.md`; setup, workflow, validation, release, branch, and review procedures do not belong in the base README contract. -- `Table of Contents` is always required in the base workflow. -- Additional repo-specific sections may exist, but they follow the canonical block unless a customization override defines a different order. -- `Table of Contents` is generated from H2 headings only and should use the canonical heading names that appear in the README. -- The summary line directly beneath the title is part of the schema contract, not optional polish. - -## Alias Policy - -- Alias headings may be used as migration hints during apply mode. -- Alias headings are not canonical output. -- If a README uses an alias such as `Getting Started` where the canonical schema expects `Quick Start`, the audit should report the non-canonical heading and apply mode should migrate it to the configured canonical heading name. - -## Downstream Customization - -- Downstream plugins may add, remove, or reorder sections through the customization config. -- Downstream plugins may add required subsections and alias mappings. -- Even when customized, the configured schema remains hard-enforced for both `check-only` and `apply`. diff --git a/skills/maintain-project-readme/references/style-rules.md b/skills/maintain-project-readme/references/style-rules.md deleted file mode 100644 index c55787bc0..000000000 --- a/skills/maintain-project-readme/references/style-rules.md +++ /dev/null @@ -1,16 +0,0 @@ -# Style Rules - -- Keep README prose direct, practical, and grounded in the repo. -- Prefer short explanatory paragraphs over marketing language. -- Keep the whole README near 250 lines by default, with 300 lines as a soft ceiling for consolidation. -- Keep most generated or agent-edited top-level sections near 40 lines or less. -- Treat the title plus one-line summary as a stable, repeatable intro block. -- Use exact configured heading names instead of near-synonyms in final output. -- Treat `Status`, `What This Project Is`, and `Motivation` as user-authored sections rather than generated claims. -- Leave existing text in those Overview subsections intact unless the user explicitly supplies replacement text. -- Use exactly `TBD` for any empty or missing Overview subsection body, and ask the user for those subsection texts before bootstrapping a new README. -- Keep `Quick Start` and `Usage` human-focused and end-user friendly. -- Prefer fenced code blocks with info strings in `Usage` when concrete examples help. -- Keep README content distinct from `CONTRIBUTING.md`, `ROADMAP.md`, and `AGENTS.md`; small-ticket planning belongs in `ROADMAP.md` by default. -- Keep generated scaffolding readable enough for a maintainer to refine quickly. -- Preserve useful repo-specific prose when it already fits the configured section contract. diff --git a/skills/maintain-project-readme/references/verification-checklist.md b/skills/maintain-project-readme/references/verification-checklist.md deleted file mode 100644 index 26920b775..000000000 --- a/skills/maintain-project-readme/references/verification-checklist.md +++ /dev/null @@ -1,13 +0,0 @@ -# Verification Checklist - -- The README has a title and one-line summary. -- The README has a table of contents. -- The configured canonical top-level sections all exist. -- The configured required subsections all exist beneath the correct parent section, including `Overview > Status`, `Overview > What This Project Is`, and `Overview > Motivation` in the base schema. -- The base `Development` section stays a short handoff to contributor documentation instead of duplicating setup, workflow, validation, branch, review, release, or maintainer procedure. -- Canonical sections appear in canonical order. -- Alias headings are migrated or reported as non-canonical. -- The table of contents lists the actual H2 headings in order. -- Placeholder-style content is reported instead of silently accepted. -- `apply` mode changes only the target `README.md`. -- Clean runs emit exactly `No findings.` diff --git a/skills/maintain-project-readme/scripts/maintain_project_readme.py b/skills/maintain-project-readme/scripts/maintain_project_readme.py deleted file mode 100644 index fef06ca3d..000000000 --- a/skills/maintain-project-readme/scripts/maintain_project_readme.py +++ /dev/null @@ -1,885 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and apply bounded README maintenance from a hard-enforced schema.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] -CONTRIBUTOR_PROCEDURE_HEADINGS = { - "Setup", - "Workflow", - "Validation", - "Local Setup", - "Development Workflow", - "Release Workflow", - "Review Workflow", - "Maintainer Workflow", -} -USER_AUTHORED_OVERVIEW_SUBSECTIONS = { - "Status", - "What This Project Is", - "Motivation", -} - - -@dataclass -class Issue: - issue_id: str - category: str - severity: str - file: str - evidence: str - recommended_fix: str - auto_fixable: bool - fixed: bool = False - - def to_dict(self) -> Dict[str, Any]: - return { - "issue_id": self.issue_id, - "category": self.category, - "severity": self.severity, - "file": self.file, - "evidence": self.evidence, - "recommended_fix": self.recommended_fix, - "auto_fixable": self.auto_fixable, - "fixed": self.fixed, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded README maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--readme-path", help="Optional README path override") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional README config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when unresolved issues remain") - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def slugify_heading(heading: str) -> str: - slug = heading.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"\s+", "-", slug) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - heading = match.group(1).strip() - body = text[start:end].strip("\n") - sections.append((heading, body)) - return preamble, sections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def parse_title_and_summary(preamble: str) -> Tuple[Optional[str], Optional[str], List[str]]: - lines = [line.rstrip() for line in preamble.splitlines()] - title: Optional[str] = None - summary: Optional[str] = None - extras: List[str] = [] - - if not lines: - return None, None, extras - - title_index: Optional[int] = None - for idx, line in enumerate(lines): - if line.startswith("# "): - title = line[2:].strip() - title_index = idx - break - - if title_index is None: - return None, None, lines - - summary_index: Optional[int] = None - for idx in range(title_index + 1, len(lines)): - if lines[idx].strip(): - summary = lines[idx].strip() - summary_index = idx - break - - for idx, line in enumerate(lines): - if idx == title_index or idx == summary_index: - continue - extras.append(line) - - return title, summary, extras - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, repo_name: str, preserve_preamble: bool) -> str: - title, summary, extras = parse_title_and_summary(preamble) - normalized_title = title or repo_name - normalized_summary = summary or f"Project documentation for {repo_name}." - - lines = [f"# {normalized_title}", "", normalized_summary] - if preserve_preamble: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - lines.extend(["", *extra_lines]) - return "\n".join(lines).strip() - - -def render_template_bootstrap(project_root: Path) -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "README.template.md" - template = read_text(template_path) - rendered = template.replace("{{PROJECT_NAME}}", project_root.name) - rendered = rendered.replace("{{ONE_LINE_SUMMARY}}", f"Project documentation for {project_root.name}.") - return normalize_whitespace(rendered) - - -def is_skills_or_plugin_repo(project_root: Path) -> bool: - if (project_root / ".codex-plugin" / "plugin.json").is_file(): - return True - skills_dir = project_root / "skills" - if skills_dir.is_dir(): - for skill_file in skills_dir.glob("*/SKILL.md"): - if skill_file.is_file(): - return True - return False - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "readme-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "readme-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - sections = settings.get("requiredSections", []) - return [str(item) for item in sections] if isinstance(sections, list) else [] - - -def canonical_order(settings: Dict[str, Any]) -> List[str]: - order = settings.get("sectionOrder", []) - return [str(item) for item in order] if isinstance(order, list) else [] - - -def required_subsections(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("requiredSubsections", {}) - if not isinstance(raw, dict): - return {} - normalized: Dict[str, List[str]] = {} - for key, value in raw.items(): - if isinstance(value, list): - normalized[str(key)] = [str(item) for item in value] - return normalized - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - normalized: Dict[str, List[str]] = {} - for key, value in raw.items(): - if isinstance(value, list): - normalized[str(key)] = [str(item) for item in value] - return normalized - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("subsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def collect_subsection_headings(body: str) -> List[str]: - return [heading.strip() for heading in H3_RE.findall(body)] - - -def build_toc(headings: Sequence[str]) -> str: - return "\n".join(f"- [{heading}](#{slugify_heading(heading)})" for heading in headings) - - -def toc_entries(body: str) -> List[str]: - entries: List[str] = [] - for line in body.splitlines(): - match = re.match(r"^\s*-\s+\[(.+?)\]\(#(.+?)\)\s*$", line.strip()) - if match: - entries.append(match.group(1).strip()) - return entries - - -def contains_placeholder_content(heading: str, body: str) -> bool: - if heading != "Overview": - return any(pattern.search(body) for pattern in PLACEHOLDER_PATTERNS) - - preamble, subsections = split_subsections(body) - bodies_to_check: List[str] = [preamble] - for subsection, subsection_body in subsections: - if subsection in USER_AUTHORED_OVERVIEW_SUBSECTIONS and subsection_body.strip() == "TBD": - continue - bodies_to_check.append(subsection_body) - return any(pattern.search("\n".join(bodies_to_check)) for pattern in PLACEHOLDER_PATTERNS) - - -def alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - aliases = section_aliases(settings) - reverse: Dict[str, str] = {} - for canonical, names in aliases.items(): - for alias in names: - reverse[alias] = canonical - return reverse - - -def validate_schema( - readme_path: Path, - readme_text: str, - config: Dict[str, Any], -) -> Tuple[List[Issue], List[Issue], List[Tuple[str, str]]]: - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - subsection_map = required_subsections(settings) - alias_map = alias_lookup(settings) - - schema_issues: List[Issue] = [] - content_issues: List[Issue] = [] - preamble, sections = split_sections(readme_text) - lookup = section_map(sections) - title, summary, _extras = parse_title_and_summary(preamble) - - if not title: - schema_issues.append( - Issue( - issue_id="missing-title", - category="schema", - severity="high", - file=str(readme_path), - evidence="README is missing a top-level '# <project-name>' heading.", - recommended_fix="Add a top-level title before the canonical section block.", - auto_fixable=True, - ) - ) - if not summary: - schema_issues.append( - Issue( - issue_id="missing-summary", - category="schema", - severity="high", - file=str(readme_path), - evidence="README is missing a one-line summary directly beneath the title.", - recommended_fix="Add a concise one-line summary directly beneath the title.", - auto_fixable=True, - ) - ) - - if "Table of Contents" not in lookup: - schema_issues.append( - Issue( - issue_id="missing-table-of-contents", - category="schema", - severity="medium", - file=str(readme_path), - evidence="README is missing the required '## Table of Contents' section.", - recommended_fix="Add an H2-only table of contents that mirrors the canonical top-level headings.", - auto_fixable=True, - ) - ) - current_positions: Dict[str, int] = {heading: idx for idx, (heading, _body) in enumerate(sections)} - for heading in required: - if heading not in lookup: - alias_found = next((alias for alias, canonical in alias_map.items() if canonical == heading and alias in lookup), None) - if alias_found: - schema_issues.append( - Issue( - issue_id=f"non-canonical-heading-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(readme_path), - evidence=f"README uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - recommended_fix=f"Rename '## {alias_found}' to '## {heading}'.", - auto_fixable=True, - ) - ) - else: - schema_issues.append( - Issue( - issue_id=f"missing-section-{slugify_heading(heading)}", - category="schema", - severity="high", - file=str(readme_path), - evidence=f"README is missing required section '## {heading}'.", - recommended_fix=f"Add the required '## {heading}' section.", - auto_fixable=True, - ) - ) - - order_positions: List[int] = [] - for heading in order: - if heading in current_positions: - order_positions.append(current_positions[heading]) - else: - alias_found = next((alias for alias, canonical in alias_map.items() if canonical == heading and alias in current_positions), None) - if alias_found: - order_positions.append(current_positions[alias_found]) - if order_positions and order_positions != sorted(order_positions): - schema_issues.append( - Issue( - issue_id="canonical-section-order", - category="schema", - severity="medium", - file=str(readme_path), - evidence="Canonical README sections are not in the configured order.", - recommended_fix="Normalize the top-level sections into canonical order.", - auto_fixable=True, - ) - ) - - for parent, children in subsection_map.items(): - body = lookup.get(parent, "") - if not body: - continue - found_children = collect_subsection_headings(body) - for child in children: - if child not in found_children: - schema_issues.append( - Issue( - issue_id=f"missing-subsection-{slugify_heading(parent)}-{slugify_heading(child)}", - category="schema", - severity="high", - file=str(readme_path), - evidence=f"Section '## {parent}' is missing required subsection '### {child}'.", - recommended_fix=f"Add the required subsection '### {child}' under '## {parent}'.", - auto_fixable=True, - ) - ) - - if parent == "Overview": - preamble, subsections = split_subsections(body) - subsection_lookup = {name: subsection_body for name, subsection_body in subsections} - status_body = subsection_lookup.get("Status", "").strip() - if status_body: - status_lines = [line for line in status_body.splitlines() if line.strip()] - if len(status_lines) > 2 or len(status_body) > 220: - content_issues.append( - Issue( - issue_id="status-section-too-long", - category="content-quality", - severity="low", - file=str(readme_path), - evidence="Section 'Overview > Status' should stay very short and plain.", - recommended_fix="Reduce the Status subsection to a brief statement about maturity, availability, or inactivity.", - auto_fixable=False, - ) - ) - - if "Table of Contents" in lookup: - expected_toc = [heading for heading, _body in sections if heading != "Table of Contents"] - actual_toc = toc_entries(lookup["Table of Contents"]) - if actual_toc != expected_toc: - schema_issues.append( - Issue( - issue_id="stale-table-of-contents", - category="schema", - severity="low", - file=str(readme_path), - evidence="Table of contents entries do not match the canonical top-level section headings in order.", - recommended_fix="Regenerate the H2-only table of contents from the canonical section list.", - auto_fixable=True, - ) - ) - - for heading in required: - required_body = lookup.get(heading) - if not required_body: - continue - if contains_placeholder_content(heading, required_body): - content_issues.append( - Issue( - issue_id=f"placeholder-content-{slugify_heading(heading)}", - category="content-quality", - severity="medium", - file=str(readme_path), - evidence=f"Section '## {heading}' contains placeholder-style content.", - recommended_fix="Replace placeholder content with repo-grounded wording.", - auto_fixable=False, - ) - ) - if not required_body.strip(): - schema_issues.append( - Issue( - issue_id=f"empty-section-{slugify_heading(heading)}", - category="schema", - severity="medium", - file=str(readme_path), - evidence=f"Section '## {heading}' is present but empty.", - recommended_fix=f"Add grounded content to '## {heading}'.", - auto_fixable=True, - ) - ) - - if heading == "Repo Structure" and "```text" not in required_body: - content_issues.append( - Issue( - issue_id="repo-structure-missing-tree-outline", - category="content-quality", - severity="medium", - file=str(readme_path), - evidence="Section '## Repo Structure' should contain a short directory tree or outline diagram.", - recommended_fix="Replace the Repo Structure prose with a short fenced `text` directory tree or outline.", - auto_fixable=False, - ) - ) - if heading == "Development" and not required_subsections(settings).get("Development"): - procedure_headings = [ - subsection for subsection in collect_subsection_headings(required_body) if subsection in CONTRIBUTOR_PROCEDURE_HEADINGS - ] - if procedure_headings: - content_issues.append( - Issue( - issue_id="readme-development-contains-contributor-procedure", - category="content-quality", - severity="medium", - file=str(readme_path), - evidence=( - "Section '## Development' contains contributor-procedure subsections: " - + ", ".join(f"'### {heading}'" for heading in procedure_headings) - + "." - ), - recommended_fix=( - "Move setup, workflow, validation, release, branch, and review procedures to " - "`CONTRIBUTING.md` or a maintainer document, and keep README.md to a short pointer." - ), - auto_fixable=False, - ) - ) - - return schema_issues, content_issues, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - heading = match.group(1).strip() - subsection_body = body[start:end].strip("\n") - subsections.append((heading, subsection_body)) - return preamble, subsections - - -def render_section_body( - heading: str, - existing_body: str, - settings: Dict[str, Any], -) -> str: - required_children = required_subsections(settings).get(heading, []) - section_template_map = section_templates(settings) - subsection_template_map = subsection_templates(settings) - - if not required_children: - return existing_body.strip() or section_template_map.get(heading, "") - - preamble, subsections = split_subsections(existing_body) - subsection_lookup = {name: body for name, body in subsections} - ordered_lines: List[str] = [] - if preamble.strip(): - ordered_lines.extend([preamble.strip(), ""]) - - for idx, child in enumerate(required_children): - child_body = subsection_lookup.get(child, "").strip() - if not child_body: - child_body = subsection_template_map.get(f"{heading}/{child}", "") - ordered_lines.extend([f"### {child}", "", child_body.strip()]) - if idx < len(required_children) - 1: - ordered_lines.append("") - - used_children = set(required_children) - extras = [(name, body) for name, body in subsections if name not in used_children] - if extras: - ordered_lines.append("") - for idx, (name, body) in enumerate(extras): - ordered_lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extras) - 1: - ordered_lines.append("") - - rendered = "\n".join(line for line in ordered_lines if line is not None).strip() - return rendered or section_template_map.get(heading, "") - - -def apply_fixes(project_root: Path, readme_path: Path, readme_text: str, config: Dict[str, Any]) -> Tuple[str, List[Dict[str, str]]]: - if not readme_text.strip(): - bootstrap = render_template_bootstrap(project_root) - write_text(readme_path, bootstrap) - return ( - bootstrap, - [ - { - "action": "create-readme-from-template", - "file": str(readme_path), - "reason": "Created a missing README.md from the bundled canonical README template.", - } - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = canonical_order(settings) - alias_map = alias_lookup(settings) - preserve_preamble = bool(settings.get("preservePreamble", True)) - allow_additional = bool(settings.get("allowAdditionalSections", True)) - - preamble, sections = split_sections(readme_text) - repo_name = project_root.name - normalized_preamble = normalize_preamble(preamble, repo_name, preserve_preamble) - existing_lookup = section_map(sections) - - canonical_lookup: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - used_aliases: set[str] = set() - - for heading, body in sections: - if heading == "Table of Contents": - continue - if heading in order or heading in required: - canonical_lookup[heading] = body - continue - if heading in alias_map: - canonical_lookup[alias_map[heading]] = body - used_aliases.add(heading) - continue - if allow_additional: - extra_sections.append((heading, body)) - - canonical_sections: List[Tuple[str, str]] = [] - for heading in order: - existing_body = canonical_lookup.get(heading, existing_lookup.get(heading, "")) - body = render_section_body(heading, existing_body, settings).strip() - canonical_sections.append((heading, body)) - - top_level_for_toc = [heading for heading, _body in canonical_sections] - if allow_additional: - top_level_for_toc.extend(heading for heading, _body in extra_sections) - rendered_lines = [normalized_preamble.strip()] - rendered_lines.extend(["", "## Table of Contents", "", build_toc(top_level_for_toc)]) - - for heading, body in canonical_sections: - rendered_lines.extend(["", f"## {heading}", "", body.strip()]) - - if allow_additional: - for heading, body in extra_sections: - rendered_lines.extend(["", f"## {heading}", "", body.strip()]) - - updated = normalize_whitespace("\n".join(rendered_lines)) - actions: List[Dict[str, str]] = [] - if updated != normalize_whitespace(readme_text): - write_text(readme_path, updated) - actions.append( - { - "action": "normalize-readme-schema", - "file": str(readme_path), - "reason": "Normalized the README into the configured canonical structure and preserved allowed preamble content.", - } - ) - if used_aliases: - actions.append( - { - "action": "migrate-alias-headings", - "file": str(readme_path), - "reason": f"Migrated alias headings into canonical heading names: {', '.join(sorted(used_aliases))}.", - } - ) - return updated, actions - - -def markdown_report(report: Dict[str, Any]) -> str: - lines = [ - "# Maintain Project README Report", - "", - "## Run Context", - "", - f"- Project root: `{report['run_context']['project_root']}`", - f"- README path: `{report['run_context']['readme_path']}`", - f"- Run mode: `{report['run_context']['run_mode']}`", - f"- Timestamp: `{report['run_context']['timestamp_utc']}`", - "", - "## Customization State", - "", - f"- Config path: `{report['customization_state'].get('config_path', 'none')}`", - f"- Default config path: `{report['customization_state'].get('default_config_path', 'none')}`", - f"- Profile: `{report['customization_state'].get('profile', 'base')}`", - f"- Customized: `{report['customization_state'].get('is_customized', False)}`", - "", - "## Schema Contract", - "", - f"- Required sections: `{', '.join(report['schema_contract'].get('required_sections', []))}`", - f"- Canonical order: `{', '.join(report['schema_contract'].get('section_order', []))}`", - "", - "## Schema Violations", - "", - ] - if report["schema_violations"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}`: {issue['evidence']}" - for issue in report["schema_violations"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Content Quality Issues", ""]) - if report["content_quality_issues"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}`: {issue['evidence']}" - for issue in report["content_quality_issues"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Fixes Applied", ""]) - if report["fixes_applied"]: - lines.extend(f"- `{action['action']}`: {action['reason']}" for action in report["fixes_applied"]) - else: - lines.append("- None.") - - lines.extend(["", "## Post-Fix Status", ""]) - if report["post_fix_status"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}`: {issue['evidence']}" - for issue in report["post_fix_status"] - ) - else: - lines.append("- Clean.") - - lines.extend(["", "## Errors", ""]) - if report["errors"]: - lines.extend(f"- {error}" for error in report["errors"]) - else: - lines.append("- None.") - - return "\n".join(lines).rstrip() + "\n" - - -def unresolved_issues(report: Dict[str, Any]) -> List[Dict[str, Any]]: - items: List[Dict[str, Any]] = [] - for key in ["schema_violations", "content_quality_issues", "post_fix_status"]: - items.extend(report[key]) - return items - - -def schema_contract(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config_settings(config) - return { - "required_sections": required_sections(settings), - "section_order": canonical_order(settings), - "required_subsections": required_subsections(settings), - } - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - readme_path = Path(args.readme_path).expanduser().resolve() if args.readme_path else project_root / "README.md" - - report: Dict[str, Any] = { - "run_context": { - "project_root": str(project_root), - "readme_path": str(readme_path), - "run_mode": args.run_mode, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - }, - "customization_state": {}, - "schema_contract": {}, - "schema_violations": [], - "content_quality_issues": [], - "fixes_applied": [], - "post_fix_status": [], - "errors": [], - } - - if not project_root.is_dir(): - report["errors"].append(f"Project root does not exist or is not a directory: {project_root}") - return report, markdown_report(report) - config = load_config(project_root, args.config) - report["customization_state"] = { - "config_path": config.get("configPath", "none"), - "default_config_path": config.get("defaultConfigPath", "none"), - "profile": config.get("profile", "base"), - "is_customized": bool(config.get("isCustomized", False)), - } - report["schema_contract"] = schema_contract(config) - - if readme_path.is_file(): - readme_text = read_text(readme_path) - schema_issues, content_issues, _sections = validate_schema(readme_path, readme_text, config) - report["schema_violations"] = [issue.to_dict() for issue in schema_issues] - report["content_quality_issues"] = [issue.to_dict() for issue in content_issues] - elif args.run_mode == "apply": - readme_text = "" - else: - readme_text = "" - report["schema_violations"] = [ - Issue( - issue_id="missing-readme-file", - category="schema", - severity="high", - file=str(readme_path), - evidence="README.md does not exist.", - recommended_fix="Create the canonical README.md file from the bundled template.", - auto_fixable=True, - ).to_dict() - ] - - if args.run_mode == "apply" and not report["errors"]: - _updated_text, actions = apply_fixes(project_root, readme_path, readme_text, config) - report["fixes_applied"] = actions - refreshed_text = read_text(readme_path) - post_schema, post_content, _ = validate_schema(readme_path, refreshed_text, config) - report["post_fix_status"] = [issue.to_dict() for issue in [*post_schema, *post_content]] - - md = markdown_report(report) - return report, md - - -def main() -> int: - args = parse_args() - report, md = run_maintenance(args) - payload = json.dumps(report, indent=2, sort_keys=True) + "\n" - - if args.json_out: - write_text(Path(args.json_out), payload) - if args.md_out: - write_text(Path(args.md_out), md) - - if args.print_json: - sys.stdout.write(payload) - elif args.print_md: - sys.stdout.write(md) - else: - if not unresolved_issues(report) and not report["errors"]: - sys.stdout.write("No findings.\n") - else: - sys.stdout.write(md) - - if report["errors"]: - return 1 - if args.fail_on_issues and unresolved_issues(report): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/maintain-project-repo/SKILL.md b/skills/maintain-project-repo/SKILL.md index a10c0a9cf..3eb962fe7 100644 --- a/skills/maintain-project-repo/SKILL.md +++ b/skills/maintain-project-repo/SKILL.md @@ -1,204 +1,140 @@ --- name: maintain-project-repo -description: Install or refresh repository tooling and canonical project docs. Use for repository bootstrap or maintenance, coordinated README/CONTRIBUTING/AGENTS/ROADMAP updates, releases, tags, publication, or branch accounting. +description: Install or refresh deterministic FSX repository maintenance, maintain all four canonical project documents, validate and synchronize repository assets, and operate protected-main releases. license: Apache-2.0 metadata: - semver: 0.3.0 + semver: 1.0.0 --- # Maintain Project Repo ## Purpose -Install or refresh the reusable `maintain-project-repo` toolkit and canonical -project documentation inside a general repository or a canonical Swift product -workspace. Validation, shared-sync work, release steps, README, contributor -guidance, agent guidance, and roadmap planning stay aligned through one -repository lifecycle. Callers select `generic` or `xcode-workspace` explicitly; -this workflow does not classify repository shape. - -## When To Use - -- Use this skill when a Swift or Xcode repo needs one local entrypoint for validation, shared sync work, and releases. -- Use this skill when a repo has GitHub Actions or local shell helpers that should become thin wrappers around repo-owned scripts. -- Use this skill for a coordinated README.md, CONTRIBUTING.md, AGENTS.md, and ROADMAP.md maintenance pass. -- Use this skill when a repo needs a protected-main standard release flow and a submodule-aware release flow. -- Use this skill when the user asks to release or publish a version. -- Use this skill when the user asks to bump and tag a release, create the GitHub release, prepare or merge a protected-main release, or finish release cleanup and branch accounting. -- Use this skill when the user wants a local-first alternative to putting maintainer logic under `.github/scripts/`. -- Do not use this skill to make ordinary questions, investigations, local edits, or documentation maintenance take a full PR, CI, release, tag, and cleanup path. Repository installation, refresh, and documentation maintenance remain local operations unless the user separately requests delivery or release work. -- Do not run or recommend the release choreography unless the user is actually asking to release, publish, merge, tag, open a release PR, or prepare the repo for that protected-main release workflow. -- Do not use this skill for app bootstrap, Swift package bootstrap, or AGENTS-only guidance sync by themselves. -- Recommend `bootstrap-xcode-workspace --operation create --component-kind library` when the repo does not exist yet and package scaffold creation is still the primary task. -- Recommend `bootstrap-xcode-workspace` when the repo does not exist yet and native Apple product bootstrap is still the primary task. -- Recommend `bootstrap-xcode-workspace --operation adopt|align` for every - existing Swift repository before installing the explicit `xcode-workspace` - profile. - -## Single-Path Workflow - -1. Collect the required inputs: - - `repo_root` - - optional `operation` - - optional `skip_github_workflow` - - optional `dry_run` -2. Use the profile explicitly supplied by the owning workflow: - - use `xcode-workspace` for every Swift repository after canonical creation - or adoption: one `.xcworkspace`, one generated root project, and required - `Apps/`, `Packages/`, and `Services/` roots - - use `generic` only for non-Swift repositories or an explicitly general - maintainer surface - - never inspect markers to decide whether the whole repository is Xcode, - SwiftPM, plain, or mixed - - stop if the requested path is not a repository root - - use lowercase `scripts/repo-maintenance/` for every profile -3. Explain the architecture boundary before mutating anything: - - this is a durable building-block change because it creates one repo-owned maintainer surface that bootstrap, sync, validation, CI, and release flows can all share - - it removes the pain of CI-only helper scripts and scattered release glue - - the simpler extension path considered first was leaving helper scripts under `.github/scripts/` and adding more workflow-specific wrappers, but that would keep local and CI behavior drifting apart - - preserve machine-level Git defaults such as Gale's fetch pruning, - fast-forward-only pulls, and tracking-branch rebases; the installer does - not write `git config --local` because generated repositories must remain - portable across contributor machines -4. Run `scripts/run_workflow.py` to normalize the inputs and choose the installer path. -5. Apply the managed `maintain-project-repo` files: - - install or refresh the managed repo-maintenance files under the selected profile's toolkit root - - install or refresh the selected profile's `config/profile.env` - - install or refresh the thin workflow wrapper at `.github/workflows/validate-repo-maintenance.yml` unless disabled - - for `xcode-workspace`, migrate an existing legacy - `Scripts/repo-maintenance/` toolkit root to lowercase - `scripts/repo-maintenance/`; stop if both roots exist separately - - preserve repo-specific scripts or files that are not part of the managed file set -6. Maintain canonical project documentation as part of the same operation: - - `install` and `refresh` run the README, CONTRIBUTING, AGENTS, and ROADMAP owner workflows serially in `apply` mode - - `report-only` and `--dry-run` run the same document workflows in `check-only` mode and never write documentation - - create missing canonical documents from the owner workflow templates - - preserve the responsibility split between product docs, contributor workflow, agent guidance, and roadmap planning - - report cross-document responsibility drift without silently moving content between files - - never offer a skip-docs path: every repository install or refresh owns the corresponding documentation pass -7. Verify the installed `maintain-project-repo` files and documentation result: - - `scripts/repo-maintenance/*.sh` for every profile - - `.github/workflows/validate-repo-maintenance.yml` when workflow installation is enabled - - branch protection, when enabled, requires the GitHub Actions check context `validate`; do not require the display-style string `Validate Repo Maintenance / validate` -8. Hand off GitHub repository settings work: - - use `maintain-github-repository` for repository features, merge methods, - Dependabot, secret scanning, push protection, vulnerability reporting, - sign-off policy, branch protection, and rulesets - - keep settings alignment separate from release choreography -9. Hand off follow-on work cleanly: - - use the selected profile's `validate-all.sh` for local validation - - use the selected profile's `sync-shared.sh` for repo-local shared sync tasks - - use the selected profile's `release.sh --mode standard --operation prepare` from a feature branch or worktree when protected `main` owns the final release line - - for remote CI, review bots, deployment, or GitHub indexing, consume the emitted continuation packet and first reuse the live matching host-native continuation while the gate remains pending and healthy; do not delete/recreate it for an unchanged snapshot. Codex uses a same-thread heartbeat and Hermes uses an updated continuable `cronjob` with `deliver="origin"` and `attach_to_session=true`; pause/delete only when the gate resolves, fails, is cancelled, or changes identity - - on wakeup, run `--operation inspect` first; run `--operation advance` only if the branch, commit, PR, and tag identities still match the continuation packet. Treat every pending status context as a wait state, not permission to merge; failed checks, requested changes, and unresolved comments remain blocking - - use `scripts/repo-maintenance/release.sh --mode submodule` only when the repo is checked out as a submodule and the parent pointer update remains a separate follow-up - - treat SemVer tags with prerelease suffixes such as `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`, or preview-style suffixes as GitHub prereleases; the release script passes `--prerelease` for those tags and rejects existing release objects whose prerelease metadata does not match the tag - - before claiming a release, publish, merge, or cleanup step is done, enumerate every local branch still not contained by the local base branch and account for each one as already preserved elsewhere, intentionally still in progress, newly archived, newly merged, or safe to delete - - verify commit reachability in the exact local repository and remote before saying work is on `main`, merged, recovered, preserved, or safe to clean up - - do not delete local branches, remote branches, worktrees, archive refs, or temporary rescue refs until branch accounting is complete and any non-base history is merged or preserved on an explicit archive ref - -## Inputs - -- `repo_root`: optional absolute or relative path to the repository root; defaults to `.` -- `operation`: `install`, `refresh`, or `report-only` -- `profile`: `generic` or `xcode-workspace` -- `skip_github_workflow`: optional flag to skip `.github/workflows/validate-repo-maintenance.yml` -- `dry_run`: optional flag to report the managed actions without writing files -- Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` - - `repo_root=.` when omitted - - `operation=install` - - `profile=generic` - - GitHub workflow installation is enabled unless explicitly skipped - - documentation mode is derived from the repository operation and cannot be skipped: `apply` for install/refresh and `check-only` for report-only/dry-run - -## Outputs - -- `status` - - `success`: `maintain-project-repo` is installed, refreshed, or reported successfully - - `blocked`: the requested repo root or installer preconditions are invalid - - `failed`: the installer started but did not complete successfully -- `path_type` - - `primary`: the managed installer path completed - - `fallback`: a non-mutating report-only result was returned -- `output` - - resolved repo root - - normalized inputs - - selected profile - - managed file list - - planned or applied actions - - integrated documentation report with document order, owner reports, responsibility issues, fixes, post-fix status, and errors - - one concise next step - -## Guards and Stop Conditions - -- Stop with `blocked` if the repo root does not exist. -- Stop with `blocked` if the repo root is not a directory. -- Stop with `blocked` if the managed target paths are blocked by non-regular files that cannot be updated safely. -- Stop with `blocked` if the requested operation is unsupported. -- Return `failed` when any selected document owner workflow errors after the repository installer starts; report completed actions explicitly so a partial write is never presented as atomic success. - -## Fallbacks and Handoffs - -- `report-only` is the non-mutating fallback path. -- Documentation is a required repository lifecycle surface. Do not add a compatibility switch that refreshes tooling while leaving README.md, CONTRIBUTING.md, AGENTS.md, or ROADMAP.md outside the operation. -- The installer preserves repo-specific extra files under the selected profile's repo-maintenance root, `.github/workflows/`, and adjacent surfaces when they are not part of the managed file set. -- The installer keeps the selected `maintain-project-repo` profile explicit via the selected profile's `config/profile.env`. -- The installer does not write repository-local Git defaults. Its release script - uses explicit `git pull --ff-only` where protected-main safety must not depend - on a caller's global configuration. -- Apple profiles install checked-in `.swiftformat` and `.swiftlint.yml` samples so SwiftFormat owns formatting shape while SwiftLint stays focused on complementary safety and clarity checks. -- The generated workflow's branch-protection check context is `validate`; GitHub exposes the job check run by that context, not by the workflow title plus job name. -- The generated GitHub Actions wrapper uses Node 24-compatible Actions versions, with `actions/checkout@v6.0.2` as the current validated floor. Newer stable official action versions are allowed and often preferred after checking release notes and running the relevant validation. Apple profiles report the runner-selected Xcode with shell commands instead of using the Node 20-based `maxim-lobanov/setup-xcode@v1` action. -- Standard release mode has bounded `prepare`, `inspect`, and `advance` operations. It never watches or polls remote state: it reuses a live matching host-native continuation while its gate is pending and healthy, creates/updates one only after it fires or becomes stale, resumes with `inspect`, and advances only after identity checks still match the packet. Every scheduled interval is at least five minutes. -- GitHub release creation preserves prerelease metadata for SemVer prerelease tags and fails clearly when an existing GitHub release object disagrees with the tag. -- GitHub release creation prefers checked-in `docs/releases/vX.Y.Z.md` notes, then `docs/releases/X.Y.Z.md`; it logs and falls back to GitHub-generated notes only when neither file exists. -- Treat branch accounting as a hard completion gate for release and cleanup work, not as follow-up tidying. If `git branch --no-merged <base>` reports local branches after a merge, account for each branch explicitly before deleting anything or reporting the workflow complete. -- Recommend `bootstrap-xcode-workspace --operation create --component-kind library` or `bootstrap-xcode-workspace` when the repo still needs to be created. -- Recommend `bootstrap-xcode-workspace --operation align` when product guidance alignment is still the missing baseline after `maintain-project-repo` is present. - -## Codex Subagent Fit - -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy repo-maintenance discovery before the main workflow installs, refreshes, or reports: inspecting existing validation scripts, checking CI wrapper shape, reading release docs, or inventorying repo-specific commands in separate directories. - -Keep managed file installation, refresh, and release guidance in the main thread unless the user explicitly requests parallel implementation with disjoint write scopes. Subagents should return concise findings and file references so the main thread can make one coherent decision about the managed toolkit. - -## Codex Hooks Fit - -This skill may document Codex Hooks as an adjacent Codex runtime surface, but it should not install or manage Codex Hooks as part of the current `maintain-project-repo` file set. Keep Codex Hooks distinct from git pre-commit hooks, `scripts/repo-maintenance/hooks/`, validation scripts, and GitHub Actions wrappers. - -When a repo needs Codex Hooks guidance, record that hooks are enabled by default, may be disabled with `features.hooks = false`, may live in `hooks.json` or inline `[hooks]` config, and should name the lifecycle event, matcher, stable script path, and expected effect. Recommend a future dedicated `maintain-project-hooks` workflow when the user wants deterministic hook auditing or scaffolding. - -## Customization - -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- The current customization surface is one policy-only default for release mode preference. Installation shape, profile selection, standard-mode branch release behavior, and managed file selection are explicit workflow behavior, not durable runtime customization. +Install one repo-owned F# script runtime behind a small `just` interface. The +runtime owns repository validation, shared-asset synchronization, canonical +documentation, and bounded protected-main release operations. + +## Required Interface + +Run repository work through `just`. Documentation has exactly two public +commands and both always process README.md, CONTRIBUTING.md, AGENTS.md, and +ROADMAP.md as one transaction: + +```text +just docs-check +just docs-apply +``` + +The remaining managed commands are: + +```text +just repo-validate +just repo-sync +just repo-release-prepare <version> +just repo-release-inspect <version> +just repo-release-advance <version> +``` + +Never add per-document recipes, direct operator-facing script commands, Python +or shell implementations, compatibility wrappers, or alternate documentation +modes. + +## Installation Workflow + +1. Confirm the target is the repository root and select `generic` or + `xcode-workspace` explicitly. +2. Use `scripts/maintain-project-repo.fsx` from this skill for `install`, + `refresh`, or `report-only`. +3. Install the fixed manifest under `scripts/repo-maintenance/`, the managed + Just import, and the GitHub validation workflow. +4. Install or refresh the four documentation contracts and templates. +5. Run the full documentation transaction: apply for install/refresh and check + for report-only. +6. Run the target repository's `just repo-validate` after mutation. + +The installer preserves repo-owned files outside the managed manifest. It does +not infer profiles, accept project-local schemas, or expose skip-docs behavior. + +## Documentation Contract + +The four document-owner skills provide fixed JSON contracts and Markdown +templates. `maintain-project-docs.fsx` loads all four in a fixed order, audits +responsibility boundaries, plans every change before writing, applies writes +atomically, and verifies the result. Apply is idempotent. + +Customization is intentionally narrow: repositories supply their substantive +project content inside the canonical sections. They cannot customize document +names, required headings, aliases, ordering, status vocabulary, normalization, +or fix policy. + +## Managed Layout + +```text +scripts/repo-maintenance/ + maintain-project-docs.fsx + repo-maintenance.fsx + repo-maintenance.just + managed-assets.json + docs/ + validations/ + syncing/ + version-bump.fsx # optional repo-owned release hook +.github/workflows/ + validate-repo-maintenance.yml +``` + +Ordered validation and synchronization hooks are `.fsx` files. The runtime +discovers them lexically and invokes them through `dotnet fsi`. Hook filenames +and arguments are the extension boundary; there is no persistent policy file. + +## Validation and Synchronization + +- `just repo-validate` verifies the managed manifest and Just import, then runs + every root-owned validation hook. +- `just repo-sync` runs every root-owned synchronization hook and then validates. +- CI calls `just repo-validate`; it does not duplicate repository policy. +- End-to-end tests live only at the target repository root. Do not install or + retain nested test suites inside skills or managed directories. + +## Release Workflow + +Use the standard protected-main path only when the user asks to release: + +1. `repo-release-prepare` validates, performs the repo-owned version bump, + checks release notes, commits the release branch, pushes it, and creates or + updates its PR. +2. `repo-release-inspect` checks the saved branch, commit, PR, checks, reviews, + comments, base branch, and tag identities without polling. +3. `repo-release-advance` repeats identity checks, merges only when every gate + passes, updates the owning main worktree, tags, pushes, creates the GitHub + release, and performs branch accounting. + +Prerelease SemVer tags create GitHub prereleases. Checked-in notes under +`docs/releases/` are preferred. Never delete branches, worktrees, refs, or +release state until every unmerged branch is explicitly accounted for. + +## Guards + +- Treat repository-skills 10.0.2 as the migration baseline; do not copy behavior + from an earlier installed version. +- Stop when a managed target is not a regular file or when both legacy and + canonical toolkit roots exist. +- Stop on unsupported profiles, operations, hook extensions, release states, + or document contract violations. +- Do not write repository-local Git defaults. +- Do not add Python, shell, YAML customization, per-file documentation commands, + nested tests, or transitional duplicate paths. ## References -### Workflow References - - `references/document-boundaries.md` - `references/repo-maintenance-layout.md` - `references/release-modes.md` - `references/pre-commit-vs-ci.md` -- `references/trigger-eval.md` - -### Contract References - - `references/automation-prompts.md` - `references/project-docs-maintenance-automation-prompts.md` -- `references/customization-flow.md` - -### Support References - -- `assets/repo-maintenance/` -- `assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml` -### Script Inventory +## Script Inventory -- `scripts/run_workflow.py` -- `scripts/install_maintain_project_repo.py` -- `scripts/maintain_project_docs.py` -- `scripts/customization_config.py` +- `scripts/maintain-project-repo.fsx` +- `scripts/maintain-project-docs.fsx` diff --git a/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml b/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml deleted file mode 100644 index e46c46492..000000000 --- a/skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Validate Repo Maintenance - -# Branch protection should require the Actions check context `validate`. -# GitHub exposes the job check run by this job name, not by the workflow title. - -on: - pull_request: - push: - branches: - - main - -jobs: - validate: - name: validate - runs-on: macos-latest - steps: - # This is a validated floor, not a ceiling; update to newer stable official versions when validated. - - uses: actions/checkout@v6.0.2 - - name: Install Swift repo-maintenance tools - run: brew install swiftformat swiftlint - - name: Run repo-maintenance validation - run: bash scripts/repo-maintenance/validate-all.sh diff --git a/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml b/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml new file mode 100644 index 000000000..5586cf296 --- /dev/null +++ b/skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml @@ -0,0 +1,24 @@ +name: Validate Repo Maintenance + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: validate + runs-on: macos-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-dotnet@v6.0.0 + with: + global-json-file: global.json + - name: Install just + run: brew install just + - name: Validate repository + run: just repo-validate diff --git a/skills/maintain-project-repo/assets/managed-assets.json b/skills/maintain-project-repo/assets/managed-assets.json new file mode 100644 index 000000000..6d42ae3f3 --- /dev/null +++ b/skills/maintain-project-repo/assets/managed-assets.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "files": [ + { "source": "shared/project-docs/ProjectDocs.fsx", "target": "scripts/repo-maintenance/lib/ProjectDocs.fsx" }, + { "source": "shared/project-docs/DocsCoordinator.fsx", "target": "scripts/repo-maintenance/lib/DocsCoordinator.fsx" }, + { "source": "skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx", "target": "scripts/repo-maintenance/maintain-project-docs.fsx" }, + { "source": "skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx", "target": "scripts/repo-maintenance/repo-maintenance.fsx" }, + { "source": "skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just", "target": "scripts/repo-maintenance/repo-maintenance.just" }, + { "source": "skills/maintain-project-readme/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/readme/document.contract.json" }, + { "source": "skills/maintain-project-readme/assets/README.template.md", "target": "scripts/repo-maintenance/docs/readme/README.template.md" }, + { "source": "skills/maintain-project-contributing/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/contributing/document.contract.json" }, + { "source": "skills/maintain-project-contributing/assets/CONTRIBUTING.template.md", "target": "scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md" }, + { "source": "skills/maintain-project-agents/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/agents/document.contract.json" }, + { "source": "skills/maintain-project-agents/assets/AGENTS.template.md", "target": "scripts/repo-maintenance/docs/agents/AGENTS.template.md" }, + { "source": "skills/maintain-project-roadmap/assets/document.contract.json", "target": "scripts/repo-maintenance/docs/roadmap/document.contract.json" }, + { "source": "skills/maintain-project-roadmap/assets/ROADMAP.template.md", "target": "scripts/repo-maintenance/docs/roadmap/ROADMAP.template.md" }, + { "source": "skills/maintain-project-repo/assets/github/validate-repo-maintenance.yml", "target": ".github/workflows/validate-repo-maintenance.yml" }, + { "source": "skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx", "target": "scripts/repo-maintenance/validations/40-xcode-workspace-layout.fsx", "profile": "xcode-workspace" }, + { "source": "skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx", "target": "scripts/repo-maintenance/workspace/validate-components.fsx", "profile": "xcode-workspace" } + ] +} diff --git a/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml b/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml deleted file mode 100644 index 5e055903d..000000000 --- a/skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Validate Repo Maintenance - -# Branch protection should require the Actions check context `validate`. -# GitHub exposes the job check run by this job name, not by the workflow title. - -on: - pull_request: - push: - branches: - - main - -jobs: - validate: - name: validate - runs-on: macos-26 - steps: - # This is a validated floor, not a ceiling; update to newer stable official versions when validated. - - uses: actions/checkout@v6.0.2 - - name: Report selected Xcode - run: xcode-select --print-path - - name: Report Swift toolchain - run: xcrun swift --version - - name: Install Swift repo-maintenance tools - run: brew install swiftformat swiftlint - - name: Run repo-maintenance validation - run: bash scripts/repo-maintenance/validate-all.sh diff --git a/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample b/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample deleted file mode 100755 index 8fc8726c2..000000000 --- a/skills/maintain-project-repo/assets/profiles/apple/repo-maintenance/hooks/pre-commit.sample +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env sh -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -config_file="$repo_root/.swiftformat" -staged_file_list="$(mktemp "${TMPDIR:-/tmp}/swiftformat-staged.XXXXXX")" -trap 'rm -f "$staged_file_list"' EXIT HUP INT TERM - -if ! command -v swiftformat >/dev/null 2>&1; then - echo "SwiftFormat pre-commit hook could not find the \`swiftformat\` CLI on PATH. Install SwiftFormat before committing, or bypass once with --no-verify if you are unblocking an emergency." >&2 - exit 1 -fi - -if [ ! -f "$config_file" ]; then - echo "SwiftFormat pre-commit hook expected a checked-in config at $config_file, but it was missing. Restore the managed .swiftformat file or refresh maintain-project-repo before committing." >&2 - exit 1 -fi - -cd "$repo_root" -git diff --cached --name-only --diff-filter=ACMR -- '*.swift' > "$staged_file_list" - -if [ ! -s "$staged_file_list" ]; then - exit 0 -fi - -echo "Running SwiftFormat on staged Swift sources..." -swiftformat --config "$config_file" --filelist "$staged_file_list" - -while IFS= read -r relative_path; do - [ -n "$relative_path" ] || continue - git add -- "$relative_path" -done < "$staged_file_list" - -echo "Verifying staged Swift sources with SwiftFormat lint..." -swiftformat --lint --config "$config_file" --filelist "$staged_file_list" diff --git a/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx b/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx new file mode 100644 index 000000000..d4ba3ff3c --- /dev/null +++ b/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.fsx @@ -0,0 +1,17 @@ +open System +open System.IO +open System.Text.Json + +let maintenanceRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let exactlyOne pattern = Directory.GetDirectories(repoRoot, pattern, SearchOption.TopDirectoryOnly).Length = 1 +let requiredDirectories = [ "Apps"; "Packages"; "Services" ] + +if not (exactlyOne "*.xcworkspace") then failwith "xcode-workspace profile requires exactly one root .xcworkspace." +if not (exactlyOne "*.xcodeproj") then failwith "xcode-workspace profile requires exactly one root .xcodeproj." +if not (File.Exists(Path.Combine(repoRoot, "project.yml"))) then failwith "xcode-workspace profile requires root project.yml." +for directory in requiredDirectories do + if not (Directory.Exists(Path.Combine(repoRoot, directory))) then failwith $"xcode-workspace profile requires {directory}/." + +printfn "Validated canonical xcode-workspace layout." diff --git a/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh b/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh deleted file mode 100644 index 0b8b8cbbe..000000000 --- a/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -require_exactly_one_workspace() { - workspace_count=$(find "$REPO_ROOT" -maxdepth 1 -type d -name '*.xcworkspace' -print | wc -l | tr -d ' ') - [ "$workspace_count" -eq 1 ] || die "The xcode-workspace profile requires exactly one root .xcworkspace; found $workspace_count." -} - -require_component_roots() { - [ -d "$REPO_ROOT/Apps" ] || die "The xcode-workspace profile requires Apps/ at the repository root." - [ -d "$REPO_ROOT/Packages" ] || die "The xcode-workspace profile requires Packages/ at the repository root." - [ -d "$REPO_ROOT/Services" ] || die "The xcode-workspace profile requires Services/ at the repository root." - - [ -f "$REPO_ROOT/project.yml" ] || die "The xcode-workspace profile requires root project.yml." - project_count=$(find "$REPO_ROOT" -maxdepth 1 -type d -name '*.xcodeproj' -print | wc -l | tr -d ' ') - [ "$project_count" -eq 1 ] || die "The xcode-workspace profile requires exactly one generated root .xcodeproj; found $project_count." - - component_count=$(find "$REPO_ROOT/Apps" -type f \( -name 'target.yml' -o -name 'target.yaml' \) -print; find "$REPO_ROOT/Packages" "$REPO_ROOT/Services" -type f -name 'Package.swift' -print) - [ -n "$component_count" ] || die "The xcode-workspace profile requires at least one component under Apps/, Packages/, or Services/." -} - -require_exactly_one_workspace -require_component_roots -log "Validated xcode-workspace composition: one root workspace and project with Apps/, Packages/, and Services/ component roots." diff --git a/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx b/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx new file mode 100644 index 000000000..8dc0f703f --- /dev/null +++ b/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.fsx @@ -0,0 +1,26 @@ +open System +open System.Diagnostics +open System.IO + +let maintenanceRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let componentRoots = [ "Apps"; "Packages"; "Services" ] + +for container in componentRoots do + let path = Path.Combine(repoRoot, container) + if Directory.Exists(path) then + for component in Directory.GetDirectories(path) |> Array.sort do + let script = Path.Combine(component, "scripts", "repo-maintenance", "repo-maintenance.fsx") + if File.Exists(script) then + let info = ProcessStartInfo("dotnet") + info.WorkingDirectory <- component + info.UseShellExecute <- false + info.ArgumentList.Add("fsi") + info.ArgumentList.Add(script) + info.ArgumentList.Add("validate") + use child = Process.Start(info) + child.WaitForExit() + if child.ExitCode <> 0 then failwith $"Component validation failed: {component}" + +printfn "Validated xcode-workspace components." diff --git a/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh b/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh deleted file mode 100644 index 9fcc54dc8..000000000 --- a/skills/maintain-project-repo/assets/profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$REPO_MAINTENANCE_COMMON_DIR/common.sh" - -run_component_validation() { - component_root=$1 - component_kind=$2 - candidate="$component_root/scripts/repo-maintenance/validate-all.sh" - if [ -f "$candidate" ]; then - log "Validating $component_kind component at $component_root with ${candidate#"$component_root/"}." - sh "$candidate" - return 0 - fi - log "No component-owned repo-maintenance validation found for $component_kind at $component_root; skipping." -} - -find "$REPO_ROOT/Apps" -type f \( -name 'target.yml' -o -name 'target.yaml' \) -print | sort | while IFS= read -r spec; do - run_component_validation "$(dirname -- "$spec")" "app-target" -done - -find "$REPO_ROOT/Packages" -type f -name 'Package.swift' -print | sort | while IFS= read -r manifest; do - run_component_validation "$(dirname -- "$manifest")" "package" -done - -if [ -d "$REPO_ROOT/Services" ]; then - find "$REPO_ROOT/Services" -mindepth 1 -maxdepth 1 -type d -print | sort | while IFS= read -r service; do - run_component_validation "$service" "service" - done -fi diff --git a/skills/maintain-project-repo/assets/repo-maintenance/config/release.env b/skills/maintain-project-repo/assets/repo-maintenance/config/release.env deleted file mode 100644 index a726d51b1..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/config/release.env +++ /dev/null @@ -1,16 +0,0 @@ -# Repo-maintenance release defaults. -REPO_MAINTENANCE_DEFAULT_RELEASE_MODE=standard -REPO_MAINTENANCE_RELEASE_BRANCH=main -REPO_MAINTENANCE_RELEASE_OPERATION=prepare -# Require one check by default. A repository that intentionally has no remote -# checks may explicitly set this to 0; do not infer permission to advance. -REPO_MAINTENANCE_MIN_REQUIRED_CHECKS=1 - -# GitHub can accept branch, tag, PR, check, review, and release mutations before -# those surfaces are immediately readable. The release script performs one -# bounded re-read, emits a continuation packet when it is not ready, and exits. -# Agents first reuse a live matching host-native continuation while its gate is -# pending and healthy; do not delete/recreate it after an unchanged snapshot. -# Create/update only after it fires or becomes stale, no sooner than five -# minutes later, then run --operation inspect before any --operation advance. -# Never add a shell poll loop or a shorter agent recheck interval here. diff --git a/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env b/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env deleted file mode 100644 index c85b14789..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/config/validation.env +++ /dev/null @@ -1,2 +0,0 @@ -# Repo-maintenance validation defaults. -REPO_MAINTENANCE_REQUIRE_AGENTS=true diff --git a/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample b/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample deleted file mode 100755 index 5749ac2ae..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/hooks/pre-commit.sample +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -exec "$repo_root/scripts/repo-maintenance/validate-all.sh" diff --git a/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh b/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh deleted file mode 100755 index 5d740c46f..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env sh -set -eu - -COMMON_DIR="${REPO_MAINTENANCE_COMMON_DIR:-}" - -if [ -z "$COMMON_DIR" ]; then - COMMON_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -fi - -REPO_MAINTENANCE_ROOT=$(CDPATH= cd -- "$COMMON_DIR/.." && pwd) -REPO_ROOT=$(CDPATH= cd -- "$REPO_MAINTENANCE_ROOT/../.." && pwd) -REPO_MAINTENANCE_PROFILE="generic" -REPO_MAINTENANCE_PROFILE_DESCRIPTION="Generic repo-maintenance baseline with no Swift or Xcode specialization." - -log() { - printf '%s\n' "$*" -} - -warn() { - printf 'WARN: %s\n' "$*" >&2 -} - -die() { - printf 'ERROR: %s\n' "$*" >&2 - exit 1 -} - -load_env_file() { - env_file="$1" - [ -f "$env_file" ] || return 0 - set -a - # shellcheck disable=SC1090 - . "$env_file" - set +a -} - -load_profile_env() { - load_env_file "$REPO_MAINTENANCE_ROOT/config/profile.env" -} - -positive_integer_or_default() { - value="$1" - default_value="$2" - - case "$value" in - ''|*[!0-9]*) - printf '%s\n' "$default_value" - ;; - 0) - printf '%s\n' "$default_value" - ;; - *) - printf '%s\n' "$value" - ;; - esac -} - -is_semver_prerelease_tag() { - tag_name="$1" - case "$tag_name" in - v[0-9]*.[0-9]*.[0-9]*-*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -expected_github_prerelease_value() { - tag_name="$1" - if is_semver_prerelease_tag "$tag_name"; then - printf '%s\n' "true" - else - printf '%s\n' "false" - fi -} - -github_release_create_prerelease_flag() { - tag_name="$1" - if is_semver_prerelease_tag "$tag_name"; then - printf '%s\n' "--prerelease" - fi -} - -verify_github_release_prerelease_metadata() { - tag_name="$1" - expected_value="$(expected_github_prerelease_value "$tag_name")" - - actual_value="$(gh release view "$tag_name" --json isPrerelease --jq .isPrerelease 2>/dev/null || true)" - case "$actual_value" in - true|false) - ;; - *) - die "GitHub release $tag_name exists, but its prerelease metadata was not readable. Confirm gh can read release JSON metadata before rerunning release.sh." - ;; - esac - - [ "$actual_value" = "$expected_value" ] || die "GitHub release $tag_name prerelease metadata mismatch: tag implies isPrerelease=$expected_value but GitHub reports isPrerelease=$actual_value. Update the release metadata or delete and recreate the release before rerunning release.sh." -} - -remote_branch_is_visible() { - branch_name="$1" - git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "$branch_name" >/dev/null 2>&1 -} - -remote_tag_is_visible() { - tag_name="$1" - git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1 -} - -github_release_is_visible() { - tag_name="$1" - gh release view "$tag_name" >/dev/null 2>&1 -} - -checked_in_release_notes_file() { - tag_name="$1" - version_name="${tag_name#v}" - - for candidate in \ - "$REPO_ROOT/docs/releases/$tag_name.md" \ - "$REPO_ROOT/docs/releases/$version_name.md"; do - if [ -f "$candidate" ]; then - printf '%s\n' "$candidate" - return 0 - fi - done - - return 1 -} - -create_github_release_from_notes_or_generated() { - tag_name="$1" - prerelease_flag="${2:-}" - - if notes_file="$(checked_in_release_notes_file "$tag_name")"; then - log "Creating GitHub release $tag_name from checked-in notes: $notes_file." - # shellcheck disable=SC2086 - gh release create "$tag_name" --verify-tag --notes-file "$notes_file" $prerelease_flag - return 0 - fi - - log "No checked-in release notes found for $tag_name; using GitHub-generated release notes." - # shellcheck disable=SC2086 - gh release create "$tag_name" --verify-tag --generate-notes $prerelease_flag -} - -ensure_git_repo() { - git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "maintain-project-repo must run inside a git worktree rooted at $REPO_ROOT." -} - -run_dispatch_dir() { - dir="$1" - label="$2" - ran_any="false" - - for script in "$dir"/*.sh; do - [ -e "$script" ] || continue - ran_any="true" - log "Running $label step $(basename "$script")" - sh "$script" - done - - if [ "$ran_any" = "false" ]; then - log "No $label steps are currently defined under $dir." - fi -} diff --git a/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx b/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx new file mode 100644 index 000000000..898b1668a --- /dev/null +++ b/skills/maintain-project-repo/assets/repo-maintenance/maintain-project-docs.fsx @@ -0,0 +1,23 @@ +#!/usr/bin/env -S dotnet fsi +#load "lib/ProjectDocs.fsx" +#load "lib/DocsCoordinator.fsx" + +open System.IO +open DocsCoordinator + +let root = Path.GetFullPath(__SOURCE_DIRECTORY__) +let asset name target folder template = { + Name = name + Target = target + Contract = Path.Combine(root, "docs", folder, "document.contract.json") + Template = Path.Combine(root, "docs", folder, template) +} + +let assets = [ + asset "readme" "README.md" "readme" "README.template.md" + asset "contributing" "CONTRIBUTING.md" "contributing" "CONTRIBUTING.template.md" + asset "agents" "AGENTS.md" "agents" "AGENTS.template.md" + asset "roadmap" "ROADMAP.md" "roadmap" "ROADMAP.template.md" +] + +fsi.CommandLineArgs |> Array.skip 1 |> execute assets |> exit diff --git a/skills/maintain-project-repo/assets/repo-maintenance/release.sh b/skills/maintain-project-repo/assets/repo-maintenance/release.sh deleted file mode 100755 index 3bfed8711..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/release.sh +++ /dev/null @@ -1,499 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" -. "$SELF_DIR/lib/common.sh" - -load_profile_env -load_env_file "$SELF_DIR/config/release.env" - -mode="${REPO_MAINTENANCE_DEFAULT_RELEASE_MODE:-standard}" -release_tag="" -skip_validate="false" -skip_gh_release="false" -skip_version_bump="false" -base_branch="${REPO_MAINTENANCE_RELEASE_BRANCH:-main}" -review_comments_addressed="false" -skip_branch_cleanup="false" -dry_run="false" -operation="${REPO_MAINTENANCE_RELEASE_OPERATION:-prepare}" - -while [ "$#" -gt 0 ]; do - case "$1" in - --mode) - mode="${2:-}" - shift 2 - ;; - --version) - release_tag="${2:-}" - shift 2 - ;; - --skip-validate) - skip_validate="true" - shift - ;; - --skip-gh-release) - skip_gh_release="true" - shift - ;; - --skip-version-bump) - skip_version_bump="true" - shift - ;; - --base-branch) - base_branch="${2:-}" - shift 2 - ;; - --review-comments-addressed) - review_comments_addressed="true" - shift - ;; - --operation) - operation="${2:-}" - shift 2 - ;; - --skip-branch-cleanup) - skip_branch_cleanup="true" - shift - ;; - --dry-run) - dry_run="true" - shift - ;; - -h|--help) - cat <<'USAGE' -Usage: - release.sh --mode standard --version <vX.Y.Z> --operation prepare|inspect|advance [--base-branch main] [--skip-validate] [--skip-version-bump] [--skip-gh-release] [--review-comments-addressed] [--skip-branch-cleanup] [--dry-run] - release.sh --mode submodule --version <vX.Y.Z> [--skip-validate] [--skip-gh-release] [--dry-run] -USAGE - exit 0 - ;; - *) - die "Unknown release argument: $1" - ;; - esac -done - -[ -n "$release_tag" ] || die "Pass --version vX.Y.Z when running the release workflow." - -export REPO_MAINTENANCE_RELEASE_MODE="$mode" -export RELEASE_TAG="$release_tag" -export REPO_MAINTENANCE_SKIP_GH_RELEASE="$skip_gh_release" -export REPO_MAINTENANCE_DRY_RUN="$dry_run" -export REPO_MAINTENANCE_RELEASE_OPERATION="$operation" - -ensure_clean_worktree() { - status_output="$(git -C "$REPO_ROOT" status --porcelain)" - [ -z "$status_output" ] || die "Release workflow requires committed changes and a clean worktree before it can continue." -} - -ensure_gh_cli() { - command -v gh >/dev/null 2>&1 || die "Standard release mode requires the GitHub CLI gh so it can inspect and advance the pull request, merge, and publish the release." -} - -ensure_semver_tag() { - case "$RELEASE_TAG" in - v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) - ;; - *) - die "Release tag must use vX.Y.Z SemVer syntax." - ;; - esac -} - -ensure_operation() { - case "$REPO_MAINTENANCE_RELEASE_OPERATION" in - prepare|inspect|advance) - ;; - *) - die "Release operation must be prepare, inspect, or advance. Long-running remote checks must be resumed by a host-native scheduled continuation, never watched from this script." - ;; - esac -} - -current_branch() { - git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true -} - -ensure_branch_release_context() { - branch_name="$(current_branch)" - [ -n "$branch_name" ] || die "Standard release mode requires a named feature branch or worktree instead of detached HEAD." - [ "$branch_name" != "$base_branch" ] || die "Standard release mode must run from a release branch or worktree, not protected $base_branch." - printf '%s\n' "$branch_name" -} - -run_version_bump() { - release_version="${RELEASE_TAG#v}" - version_bump_script="$SELF_DIR/version-bump.sh" - head_subject="$(git -C "$REPO_ROOT" log -1 --format=%s 2>/dev/null || true)" - - if [ "$skip_version_bump" = "true" ]; then - log "Skipping repo version bump because --skip-version-bump was requested." - return 0 - fi - - if [ "$head_subject" = "release: bump versions for $RELEASE_TAG" ]; then - log "Version bump commit for $RELEASE_TAG is already at HEAD; continuing the release resume path." - return 0 - fi - - [ -x "$version_bump_script" ] || die "Standard release mode expected an executable repo-specific version bump hook at $version_bump_script. Add that hook so the repo's version surfaces move together, or rerun with --skip-version-bump when this release intentionally has no version-bearing files." - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would run $version_bump_script $release_version with RELEASE_TAG=$RELEASE_TAG." - return 0 - fi - - RELEASE_VERSION="$release_version" "$version_bump_script" "$release_version" - - if [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - die "Version bump hook completed without changing files. Update $version_bump_script to edit the repo's version surfaces, or rerun with --skip-version-bump if this release intentionally has no version bump." - fi - - git -C "$REPO_ROOT" add -A - git -C "$REPO_ROOT" commit -m "release: bump versions for $RELEASE_TAG" - log "Committed version bump for $RELEASE_TAG." -} - -create_release_tag() { - head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" - tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" - - if [ -n "$tag_sha" ]; then - tag_commit_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$RELEASE_TAG")" - [ "$tag_commit_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." - log "Tag $RELEASE_TAG already points at HEAD." - return 0 - fi - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would create annotated tag $RELEASE_TAG at HEAD." - return 0 - fi - - git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" - log "Created annotated tag $RELEASE_TAG." -} - -push_release_branch() { - branch_name="$1" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would push branch $branch_name to origin." - return 0 - fi - - git -C "$REPO_ROOT" push -u origin "$branch_name" - log "Pushed branch $branch_name." - remote_branch_is_visible "$branch_name" || return 1 -} - -push_release_tag() { - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would push tag $RELEASE_TAG to origin." - return 0 - fi - - git -C "$REPO_ROOT" push origin "$RELEASE_TAG" - log "Pushed tag $RELEASE_TAG." - remote_tag_is_visible "$RELEASE_TAG" || return 1 -} - -create_or_update_pr() { - branch_name="$1" - PR_NUMBER="" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would create or update a release PR from $branch_name into $base_branch." - PR_NUMBER="DRY-RUN" - return 0 - fi - - body_file="$(mktemp "${TMPDIR:-/tmp}/repo-maintenance-release-pr.XXXXXX")" - trap 'rm -f "$body_file"' EXIT INT TERM - - cat >"$body_file" <<EOF -## Release - -- prepares $RELEASE_TAG from branch \`$branch_name\` -- keeps protected \`$base_branch\` updates behind pull request review and CI -- release tag \`$RELEASE_TAG\` will be created after CI and the review-comment gate pass, so failed or still-discussed release candidates do not get tagged - -## Continuation Gate - -Before merge and tagging, use \`release.sh --operation inspect\` after CI and review contexts have had time to settle. Agents schedule a host-native continuation at least five minutes later; this script never watches or polls remote state. -EOF - - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - if [ -n "$pr_number" ]; then - pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" - gh pr edit "$pr_number" --title "release: prepare $RELEASE_TAG" --body-file "$body_file" >/dev/null - log "Updated existing release PR #$pr_number at $pr_url." - else - gh pr create --base "$base_branch" --head "$branch_name" --title "release: prepare $RELEASE_TAG" --body-file "$body_file" >/dev/null - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - [ -n "$pr_number" ] || die "GitHub CLI did not return a release PR number after creating the pull request." - pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" - log "Created release PR #$pr_number at $pr_url." - PR_NUMBER="$pr_number" - return 0 - fi - - PR_NUMBER="$pr_number" -} - -inspect_pr_gate() { - pr_number="$1" - minimum_check_count="${REPO_MAINTENANCE_MIN_REQUIRED_CHECKS:-1}" - case "$minimum_check_count" in - ''|*[!0-9]*) die "REPO_MAINTENANCE_MIN_REQUIRED_CHECKS must be a non-negative integer; received $minimum_check_count." ;; - esac - if CHECK_STATE="$(gh pr checks "$pr_number" --json name,bucket --jq 'map(.name + ":" + .bucket) | join(",")' 2>/dev/null)"; then - check_readable="true" - elif [ -n "${CHECK_STATE:-}" ]; then - # gh pr checks exits 8 while pending even when it returned valid JSON output. - check_readable="true" - else - CHECK_STATE="unreadable" - check_readable="false" - fi - if CHECK_BUCKETS="$(gh pr checks "$pr_number" --json bucket --jq 'map(.bucket) | join(",")' 2>/dev/null)"; then - : - elif [ -z "${CHECK_BUCKETS:-}" ]; then - check_readable="false" - fi - if check_count="$(gh pr checks "$pr_number" --json bucket --jq 'length' 2>/dev/null)"; then - : - elif [ -z "${check_count:-}" ]; then - check_readable="false" - fi - REVIEW_DECISION="$(gh pr view "$pr_number" --json reviewDecision --jq '.reviewDecision // ""' 2>/dev/null || printf 'UNREADABLE')" - COMMENT_COUNT="$(gh pr view "$pr_number" --json comments,reviews --jq '([.comments[]?, (.reviews[]? | select(.state == "COMMENTED"))] | length)' 2>/dev/null || printf '1')" - if [ "$check_readable" != "true" ] || [ "$REVIEW_DECISION" = "UNREADABLE" ]; then - GATE_PHASE="awaiting-github-state" - elif [ "$check_count" -lt "$minimum_check_count" ]; then - GATE_PHASE="awaiting-github-state" - elif case ",$CHECK_BUCKETS," in *,fail,*|*,cancel,*) true ;; *) false ;; esac; then - GATE_PHASE="failed-checks" - elif case ",$CHECK_BUCKETS," in *,pending,*) true ;; *) false ;; esac; then - GATE_PHASE="awaiting-pr-checks" - elif [ "$REVIEW_DECISION" = "CHANGES_REQUESTED" ]; then - GATE_PHASE="changes-requested" - else - GATE_PHASE="ready-to-advance" - fi - log "PR #$pr_number snapshot: phase=$GATE_PHASE; checks=${CHECK_STATE:-none}; review=${REVIEW_DECISION:-none}; comments=$COMMENT_COUNT." -} - -emit_continuation_packet() { - pr_number="$1" - branch_name="$2" - phase="$3" - resume_operation="inspect" - case "$phase" in - not-started|awaiting-branch-visibility) - resume_operation="prepare" - ;; - esac - repo_name="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || printf 'unknown')" - head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" - printf '%s\n' "{\"schema\":\"repo-maintenance-continuation/v1\",\"operation\":\"standard-release\",\"repository\":\"$repo_name\",\"release_tag\":\"$RELEASE_TAG\",\"branch\":\"$branch_name\",\"head_commit\":\"$head_sha\",\"pr_number\":\"$pr_number\",\"phase\":\"$phase\",\"minimum_delay_minutes\":5,\"resume_command\":\"scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG --operation $resume_operation\",\"advance_command\":\"scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG --operation advance\"}" - log "Before scheduling, reuse a live matching host-native continuation while this gate is pending and healthy; do not delete/recreate it after an unchanged snapshot. Create or update one only after it fires or becomes stale, no sooner than five minutes. On wakeup run inspect first; run advance only if this branch, commit, PR, and tag still match." -} - -check_pr_comments() { - pr_number="$1" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would check PR #$pr_number for comments and requested changes." - return 0 - fi - - review_decision="$REVIEW_DECISION" - comment_count="$COMMENT_COUNT" - - if [ "$review_decision" = "CHANGES_REQUESTED" ]; then - gh pr view "$pr_number" --comments - die "PR #$pr_number has requested changes. Address valid concerns in code, or add out-of-scope concerns to ROADMAP.md, resolve the threads, push, and rerun release.sh." - fi - - if [ "$comment_count" != "0" ] && [ "$review_comments_addressed" != "true" ]; then - gh pr view "$pr_number" --comments - die "PR #$pr_number has review or discussion comments. Address and resolve valid concerns, add out-of-scope concerns to ROADMAP.md, then rerun release.sh with --review-comments-addressed once the comment pass is intentionally complete." - fi - - log "PR #$pr_number has no blocking review state." -} - -merge_pr() { - pr_number="$1" - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would merge PR #$pr_number into $base_branch with a merge commit and delete the remote branch." - return 0 - fi - - gh pr merge "$pr_number" --merge --delete-branch - log "Merged PR #$pr_number into $base_branch." -} - -fast_forward_base_branch() { - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would fast-forward local $base_branch from origin/$base_branch." - return 0 - fi - - git -C "$REPO_ROOT" fetch origin "$base_branch" - if git -C "$REPO_ROOT" switch "$base_branch" 2>/dev/null || git -C "$REPO_ROOT" checkout "$base_branch" 2>/dev/null; then - git -C "$REPO_ROOT" pull --ff-only origin "$base_branch" - log "Fast-forwarded local $base_branch." - else - die "Could not check out local $base_branch, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout, then rerun release.sh so the release tag is created from the reviewed base branch." - fi -} - -create_github_release() { - if [ "$REPO_MAINTENANCE_SKIP_GH_RELEASE" = "true" ]; then - log "Skipping GitHub release creation because --skip-gh-release was requested." - return 0 - fi - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" - log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." - return 0 - fi - - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - verify_github_release_prerelease_metadata "$RELEASE_TAG" - log "GitHub release $RELEASE_TAG already exists." - return 0 - fi - - prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" - create_github_release_from_notes_or_generated "$RELEASE_TAG" "$prerelease_flag" - log "Created GitHub release $RELEASE_TAG." - if ! github_release_is_visible "$RELEASE_TAG"; then - warn "GitHub release $RELEASE_TAG is not readable in this immediate re-read. Schedule a continuation for at least five minutes rather than polling." - return 1 - fi - verify_github_release_prerelease_metadata "$RELEASE_TAG" -} - -cleanup_merged_branches() { - release_branch_name="$1" - - if [ "$skip_branch_cleanup" = "true" ]; then - log "Skipping local merged-branch cleanup because --skip-branch-cleanup was requested." - return 0 - fi - - if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would prune origin and delete local branches already merged into $base_branch, including $release_branch_name when safe." - return 0 - fi - - git -C "$REPO_ROOT" remote prune origin - for merged_branch in $(git -C "$REPO_ROOT" for-each-ref --format='%(refname:short)' --merged "$base_branch" refs/heads); do - case "$merged_branch" in - "$base_branch") - ;; - *) - git -C "$REPO_ROOT" branch -d "$merged_branch" >/dev/null 2>&1 || warn "Could not delete local merged branch $merged_branch; it may be checked out in another worktree." - ;; - esac - done - log "Cleaned up local branches already merged into $base_branch where safe." -} - -run_standard_release() { - ensure_git_repo - ensure_gh_cli - ensure_semver_tag - ensure_operation - - if [ "$REPO_MAINTENANCE_RELEASE_OPERATION" = "inspect" ]; then - branch_name="$(current_branch)" - if [ -z "$branch_name" ]; then - log "Release inspection state: not-started; no named branch is checked out." - return 0 - fi - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - if [ -z "$pr_number" ]; then - emit_continuation_packet "pending" "$branch_name" "not-started" - log "Release inspection state: not-started; no release PR exists for branch $branch_name." - return 0 - fi - inspect_pr_gate "$pr_number" - emit_continuation_packet "$pr_number" "$branch_name" "$GATE_PHASE" - return 0 - fi - - branch_name="$(ensure_branch_release_context)" - ensure_clean_worktree - - if [ "$REPO_MAINTENANCE_RELEASE_OPERATION" = "prepare" ] && [ "$skip_validate" != "true" ]; then - sh "$SELF_DIR/validate-all.sh" - fi - - if [ "$REPO_MAINTENANCE_RELEASE_OPERATION" = "prepare" ]; then - run_version_bump - ensure_clean_worktree - if ! push_release_branch "$branch_name"; then - emit_continuation_packet "pending" "$branch_name" "awaiting-branch-visibility" - return 0 - fi - create_or_update_pr "$branch_name" - pr_number="$PR_NUMBER" - inspect_pr_gate "$pr_number" - emit_continuation_packet "$pr_number" "$branch_name" "$GATE_PHASE" - log "Standard release preparation completed for $RELEASE_TAG." - return 0 - fi - - pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" - [ -n "$pr_number" ] || die "No release PR exists for branch $branch_name into $base_branch. Run --operation prepare first." - inspect_pr_gate "$pr_number" - case "$GATE_PHASE" in - awaiting-github-state|awaiting-pr-checks) - emit_continuation_packet "$pr_number" "$branch_name" "$GATE_PHASE" - return 0 - ;; - failed-checks|changes-requested) - die "Release PR #$pr_number is in $GATE_PHASE. Resolve the remote gate, push any correction, then use --operation inspect after a scheduled continuation." - ;; - esac - check_pr_comments "$pr_number" - merge_pr "$pr_number" - fast_forward_base_branch - create_release_tag - if ! push_release_tag; then - emit_continuation_packet "$pr_number" "$branch_name" "awaiting-tag-visibility" - return 0 - fi - if ! create_github_release; then - emit_continuation_packet "$pr_number" "$branch_name" "awaiting-github-release-visibility" - return 0 - fi - cleanup_merged_branches "$branch_name" - log "Standard release flow completed successfully for $RELEASE_TAG." -} - -if [ "$mode" = "standard" ]; then - run_standard_release - exit 0 -fi - -if [ "$skip_validate" != "true" ]; then - sh "$SELF_DIR/validate-all.sh" -fi - -log "Running repo-maintenance release flow in $REPO_MAINTENANCE_RELEASE_MODE mode for $RELEASE_TAG with the $REPO_MAINTENANCE_PROFILE profile." -run_dispatch_dir "$SELF_DIR/release" "release" - -if [ "$REPO_MAINTENANCE_RELEASE_MODE" = "submodule" ]; then - log "Submodule release finished. Update the parent repository's submodule pointer in a separate follow-up commit." -fi - -log "Repo-maintenance release flow completed successfully." diff --git a/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh b/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh deleted file mode 100755 index 1e6a12e45..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/release/10-preflight.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -ensure_git_repo - -case "${REPO_MAINTENANCE_RELEASE_MODE:-}" in - standard|submodule) - ;; - *) - die "Release mode must be standard or submodule." - ;; -esac - -case "${RELEASE_TAG:-}" in - v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) - ;; - *) - die "Release tag must use vX.Y.Z SemVer syntax." - ;; -esac - -branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true)" -[ -n "$branch_name" ] || die "Release workflow requires a named branch instead of detached HEAD." - -status_output="$(git -C "$REPO_ROOT" status --porcelain)" -[ -z "$status_output" ] || die "Release workflow requires a clean worktree before tagging." - -if [ "${REPO_MAINTENANCE_RELEASE_MODE:-}" = "submodule" ]; then - superproject_root="$(git -C "$REPO_ROOT" rev-parse --show-superproject-working-tree || true)" - [ -n "$superproject_root" ] || die "Submodule release mode requires this repository to be checked out as a git submodule." -fi diff --git a/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh b/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh deleted file mode 100755 index 80e147ba0..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/release/20-tag-release.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" -tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" - -if [ -n "$tag_sha" ]; then - [ "$tag_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." - log "Tag $RELEASE_TAG already points at HEAD." - exit 0 -fi - -if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - log "Would create annotated tag $RELEASE_TAG at HEAD." - exit 0 -fi - -git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" -log "Created annotated tag $RELEASE_TAG." diff --git a/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh b/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh deleted file mode 100755 index 148ba4769..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD)" - -if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - log "Would push branch $branch_name and tag $RELEASE_TAG to origin." - exit 0 -fi - -git -C "$REPO_ROOT" push -u origin "$branch_name" -remote_branch_is_visible "$branch_name" || die "Remote branch origin/$branch_name is not visible in this immediate re-read. Do not poll; schedule a host-native continuation for at least five minutes, then re-run the release step." -git -C "$REPO_ROOT" push origin "$RELEASE_TAG" -remote_tag_is_visible "$RELEASE_TAG" || die "Remote tag $RELEASE_TAG is not visible in this immediate re-read. Do not poll; schedule a host-native continuation for at least five minutes, then re-run the release step." -log "Pushed branch $branch_name and tag $RELEASE_TAG." diff --git a/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh b/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh deleted file mode 100755 index e78221fe8..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -if [ "${REPO_MAINTENANCE_SKIP_GH_RELEASE:-false}" = "true" ]; then - log "Skipping GitHub release creation because --skip-gh-release was requested." - exit 0 -fi - -if ! command -v gh >/dev/null 2>&1; then - warn "gh is unavailable, so the release tag was pushed without creating a GitHub release object." - exit 0 -fi - -if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" - log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." - exit 0 -fi - -if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - verify_github_release_prerelease_metadata "$RELEASE_TAG" - log "GitHub release $RELEASE_TAG already exists." - exit 0 -fi - -prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" -create_github_release_from_notes_or_generated "$RELEASE_TAG" "$prerelease_flag" -log "Created GitHub release $RELEASE_TAG." -github_release_is_visible "$RELEASE_TAG" || die "GitHub release $RELEASE_TAG is not readable in this immediate re-read. Do not poll; schedule a host-native continuation for at least five minutes, then re-run the release step." -verify_github_release_prerelease_metadata "$RELEASE_TAG" diff --git a/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx b/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx new file mode 100644 index 000000000..b3052eb27 --- /dev/null +++ b/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.fsx @@ -0,0 +1,281 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type CommandResult = { ExitCode: int; Stdout: string; Stderr: string } + +let maintenanceRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let repoRoot = Path.GetFullPath(Path.Combine(maintenanceRoot, "..", "..")) + +let fail message = raise (InvalidOperationException(message)) + +let runIn cwd executable arguments = + let startInfo = ProcessStartInfo(executable) + startInfo.WorkingDirectory <- cwd + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in arguments do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + { ExitCode = child.ExitCode; Stdout = stdout.Trim(); Stderr = stderr.Trim() } + +let requireSuccess description result = + if result.ExitCode <> 0 then + let detail = if String.IsNullOrWhiteSpace(result.Stderr) then result.Stdout else result.Stderr + fail $"{description} failed in {repoRoot}: {detail}" + result.Stdout + +let run executable arguments = runIn repoRoot executable arguments +let git arguments = run "git" arguments +let gh arguments = run "gh" arguments + +let ensureGitRepo () = + git [ "rev-parse"; "--show-toplevel" ] + |> requireSuccess "Git repository check" + |> Path.GetFullPath + |> fun actual -> if actual <> repoRoot then fail $"Repo-maintenance expected repository root {repoRoot}, but Git resolved {actual}." + +let runFsxDirectory name = + let directory = Path.Combine(maintenanceRoot, name) + if Directory.Exists(directory) then + Directory.GetFiles(directory, "*.fsx") + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + |> Array.iter (fun script -> + let result = runIn repoRoot "dotnet" [ "fsi"; script ] + requireSuccess $"Repo-maintenance {name} step {Path.GetFileName(script)}" result |> ignore + if not (String.IsNullOrWhiteSpace(result.Stdout)) then printfn "%s" result.Stdout) + +let validate () = + ensureGitRepo () + let required = [ + "repo-maintenance.fsx" + "maintain-project-docs.fsx" + "repo-maintenance.just" + "lib/ProjectDocs.fsx" + "lib/DocsCoordinator.fsx" + "config/profile.json" + ] + for relative in required do + let path = Path.Combine(maintenanceRoot, relative) + if not (File.Exists(path)) then fail $"Managed repo-maintenance file is missing: {path}" + let justfile = Path.Combine(repoRoot, "justfile") + if not (File.Exists(justfile)) then fail $"Repository justfile is missing: {justfile}" + let justText = File.ReadAllText(justfile) + if not (justText.Contains("scripts/repo-maintenance/repo-maintenance.just")) then + fail "Repository justfile does not import scripts/repo-maintenance/repo-maintenance.just." + runFsxDirectory "validations" + let profile = JsonDocument.Parse(File.ReadAllText(Path.Combine(maintenanceRoot, "config", "profile.json"))).RootElement.GetProperty("profile").GetString() + if profile = "xcode-workspace" then + let components = Path.Combine(maintenanceRoot, "workspace", "validate-components.fsx") + if not (File.Exists(components)) then fail $"xcode-workspace component validator is missing: {components}" + runIn repoRoot "dotnet" [ "fsi"; components ] |> requireSuccess "xcode-workspace component validation" |> ignore + runIn repoRoot "dotnet" [ "fsi"; Path.Combine(maintenanceRoot, "maintain-project-docs.fsx"); "--project-root"; repoRoot; "--run-mode"; "check-only"; "--format"; "markdown"; "--fail-on-issues" ] + |> requireSuccess "Canonical documentation validation" + |> ignore + printfn "Repo-maintenance validation passed." + +let sync () = + ensureGitRepo () + runFsxDirectory "syncing" + validate () + printfn "Repo-maintenance shared sync and validation passed." + +let cleanWorktree (cwd: string) = + let status = runIn cwd "git" [ "status"; "--porcelain" ] |> requireSuccess "Worktree status" + if not (String.IsNullOrWhiteSpace(status)) then fail $"Release requires a clean worktree: {cwd}" + +let currentBranch (cwd: string) = runIn cwd "git" [ "branch"; "--show-current" ] |> requireSuccess "Current branch" + +let normalizeTag (value: string) = + let tag = if value.StartsWith("v") then value else "v" + value + if not (System.Text.RegularExpressions.Regex.IsMatch(tag, "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")) then + fail $"Release version must use SemVer syntax: {value}" + tag + +let optionValue (name: string) (args: string list) = + args + |> List.tryFindIndex ((=) name) + |> Option.bind (fun index -> args |> List.tryItem (index + 1)) + +let hasFlag (name: string) (args: string list) = List.contains name args + +let ensureReleaseNotes (cwd: string) (tag: string) = + let candidates = [ Path.Combine(cwd, "docs", "releases", tag + ".md"); Path.Combine(cwd, "docs", "releases", tag.TrimStart('v') + ".md") ] + candidates |> List.tryFind File.Exists |> Option.defaultWith (fun () -> fail $"Checked-in release notes are required for {tag} under docs/releases/.") + +let branchVisible (branch: string) (expected: string) = + let output = git [ "ls-remote"; "origin"; $"refs/heads/{branch}" ] |> requireSuccess "Remote branch visibility" + not (String.IsNullOrWhiteSpace(output)) && output.Split([|' '; '\t'|], StringSplitOptions.RemoveEmptyEntries).[0] = expected + +let tagVisible (cwd: string) (tag: string) (expected: string) = + let output = runIn cwd "git" [ "ls-remote"; "origin"; $"refs/tags/{tag}^{{}}" ] |> requireSuccess "Remote tag visibility" + not (String.IsNullOrWhiteSpace(output)) && output.Split([|' '; '\t'|], StringSplitOptions.RemoveEmptyEntries).[0] = expected + +let prNumber (branch: string) = + let output = gh [ "pr"; "list"; "--state"; "all"; "--head"; branch; "--base"; "main"; "--limit"; "1"; "--json"; "number" ] |> requireSuccess "Release PR lookup" + use json = JsonDocument.Parse(output) + if json.RootElement.GetArrayLength() = 0 then None else Some(json.RootElement[0].GetProperty("number").GetInt32()) + +type Gate = { Number: int; Url: string; State: string; Head: string; Sha: string; Phase: string; Comments: int } + +let inspectGate number = + let pr = gh [ "pr"; "view"; string number; "--json"; "url,state,headRefName,headRefOid,reviewDecision,comments,reviews" ] |> requireSuccess "Release PR inspection" + use data = JsonDocument.Parse(pr) + let root = data.RootElement + let checksResult = gh [ "pr"; "checks"; string number; "--json"; "name,bucket" ] + let checks = + if String.IsNullOrWhiteSpace(checksResult.Stdout) then [] + else + use parsed = JsonDocument.Parse(checksResult.Stdout) + parsed.RootElement.EnumerateArray() + |> Seq.map (fun item -> item.GetProperty("name").GetString(), item.GetProperty("bucket").GetString()) + |> Seq.toList + let state = root.GetProperty("state").GetString() + let review = root.GetProperty("reviewDecision").GetString() + let comments = root.GetProperty("comments").GetArrayLength() + (root.GetProperty("reviews").EnumerateArray() |> Seq.filter (fun item -> item.GetProperty("state").GetString() = "COMMENTED") |> Seq.length) + let names = checks |> List.map fst |> Set.ofList + let buckets = checks |> List.map snd |> Set.ofList + let phase = + if state = "MERGED" then "merged" + elif state <> "OPEN" then "closed" + elif List.isEmpty checks || not (names.Contains("validate")) then "awaiting-required-checks" + elif buckets.Contains("fail") || buckets.Contains("cancel") then "failed-checks" + elif buckets.Contains("pending") then "awaiting-pr-checks" + elif review = "CHANGES_REQUESTED" then "changes-requested" + elif comments > 0 then "comments-require-review" + else "ready-to-advance" + { Number = number; Url = root.GetProperty("url").GetString(); State = state; Head = root.GetProperty("headRefName").GetString(); Sha = root.GetProperty("headRefOid").GetString(); Phase = phase; Comments = comments } + +let continuation tag gate = + let repository = gh [ "repo"; "view"; "--json"; "nameWithOwner"; "--jq"; ".nameWithOwner" ] |> requireSuccess "Repository identity" + let payload = {| schema = "repo-maintenance-continuation/v1"; operation = "standard-release"; repository = repository; releaseTag = tag; branch = gate.Head; headCommit = gate.Sha; prNumber = gate.Number; phase = gate.Phase; minimumDelayMinutes = 5; resumeCommand = $"just repo-release-inspect {tag}"; advanceCommand = $"just repo-release-advance {tag}" |} + printfn "%s" (JsonSerializer.Serialize(payload)) + +let findMainWorktree () = + let output = git [ "worktree"; "list"; "--porcelain" ] |> requireSuccess "Worktree inventory" + let mutable path: string option = None + let mutable found: string option = None + for line in output.Split('\n') do + if line.StartsWith("worktree ") then path <- Some(line.Substring(9)) + elif line = "branch refs/heads/main" then found <- path + found |> Option.defaultWith (fun () -> fail "No clean worktree owns local main.") + +let accountBranches (mainRoot: string) (supplied: string list) = + let allowed = Set.ofList [ "preserved"; "in-progress"; "archived"; "merged"; "safe-to-delete" ] + let parsed = + supplied + |> List.map (fun value -> + let parts = value.Split('=', 2) + if parts.Length <> 2 || not (allowed.Contains(parts[1])) then fail $"Invalid branch accounting: {value}" + parts[0], parts[1]) + |> Map.ofList + let branches = + runIn mainRoot "git" [ "branch"; "--no-merged"; "main"; "--format=%(refname:short)" ] + |> requireSuccess "Unmerged branch inventory" + |> fun output -> output.Split('\n', StringSplitOptions.RemoveEmptyEntries) |> Array.filter ((<>) "main") |> Array.toList + let missing = branches |> List.filter (fun branch -> not (parsed.ContainsKey(branch))) + if not (List.isEmpty missing) then + let rendered = String.concat ", " missing + fail $"Branch accounting is incomplete for: {rendered}" + branches |> List.map (fun branch -> branch, parsed[branch]) + +let releasePrepare (tag: string) (args: string list) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + if String.IsNullOrWhiteSpace(branch) || branch = "main" then fail "Release prepare must run from a named feature branch, not main." + ensureReleaseNotes repoRoot tag |> ignore + validate () + let versionScript = Path.Combine(maintenanceRoot, "version-bump.fsx") + if not (hasFlag "--skip-version-bump" args) then + if not (File.Exists(versionScript)) then fail $"Version bump script is required: {versionScript}" + let result = runIn repoRoot "dotnet" [ "fsi"; versionScript; tag.TrimStart('v') ] + requireSuccess "Version bump" result |> ignore + let status = git [ "status"; "--porcelain" ] |> requireSuccess "Version bump status" + if String.IsNullOrWhiteSpace(status) then fail "Version bump completed without changing files." + git [ "add"; "-A" ] |> requireSuccess "Stage version bump" |> ignore + git [ "commit"; "-m"; $"release: bump versions for {tag}" ] |> requireSuccess "Commit version bump" |> ignore + cleanWorktree repoRoot + git [ "push"; "-u"; "origin"; branch ] |> requireSuccess "Push release branch" |> ignore + let head = git [ "rev-parse"; "HEAD" ] |> requireSuccess "Release head" + if not (branchVisible branch head) then + let gate = { Number = 0; Url = ""; State = "OPEN"; Head = branch; Sha = head; Phase = "awaiting-branch-visibility"; Comments = 0 } + continuation tag gate + else + let number = + match prNumber branch with + | Some existing -> existing + | None -> + gh [ "pr"; "create"; "--base"; "main"; "--head"; branch; "--title"; $"release: prepare {tag}"; "--body"; $"Prepare {tag} through the canonical repository-maintenance workflow." ] |> requireSuccess "Create release PR" |> ignore + prNumber branch |> Option.defaultWith (fun () -> fail "GitHub did not return the created release PR.") + let gate = inspectGate number + printfn "PR #%d: %s (%s)" gate.Number gate.Phase gate.Url + continuation tag gate + +let releaseInspect (tag: string) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + let number = prNumber branch |> Option.defaultWith (fun () -> fail $"No release PR exists for {branch}.") + let gate = inspectGate number + printfn "PR #%d: %s (%s)" gate.Number gate.Phase gate.Url + continuation tag gate + +let releaseAdvance (tag: string) (args: string list) = + ensureGitRepo () + cleanWorktree repoRoot + let branch = currentBranch repoRoot + let number = prNumber branch |> Option.defaultWith (fun () -> fail $"No release PR exists for {branch}.") + let gate = inspectGate number + let head = git [ "rev-parse"; "HEAD" ] |> requireSuccess "Release head" + if gate.Head <> branch || gate.Sha <> head then fail "Release PR branch or commit identity changed; inspect before advancing." + if gate.Phase <> "ready-to-advance" && not (gate.Phase = "comments-require-review" && hasFlag "--review-comments-addressed" args) then + continuation tag gate + fail $"Release PR #{number} is not ready to advance: {gate.Phase}." + gh [ "pr"; "merge"; string number; "--merge"; "--delete-branch" ] |> requireSuccess "Merge release PR" |> ignore + let mainRoot = findMainWorktree () + cleanWorktree mainRoot + runIn mainRoot "git" [ "fetch"; "origin"; "main"; "--prune" ] |> requireSuccess "Fetch main" |> ignore + runIn mainRoot "git" [ "pull"; "--ff-only"; "origin"; "main" ] |> requireSuccess "Fast-forward main" |> ignore + let mainHead = runIn mainRoot "git" [ "rev-parse"; "HEAD" ] |> requireSuccess "Reviewed main head" + let accountingValues = + args |> List.mapi (fun index value -> index, value) |> List.choose (fun (index, value) -> if value = "--branch-accounting" then args |> List.tryItem(index + 1) else None) + let accounting = accountBranches mainRoot accountingValues + ensureReleaseNotes mainRoot tag |> ignore + let existingTag = runIn mainRoot "git" [ "rev-parse"; "-q"; "--verify"; $"refs/tags/{tag}" ] + if existingTag.ExitCode <> 0 then runIn mainRoot "git" [ "tag"; "-a"; tag; "-m"; $"Release {tag}" ] |> requireSuccess "Create release tag" |> ignore + runIn mainRoot "git" [ "push"; "origin"; tag ] |> requireSuccess "Push release tag" |> ignore + if not (tagVisible mainRoot tag mainHead) then fail $"Remote tag {tag} is not visible at reviewed main {mainHead}." + let releaseView = runIn mainRoot "gh" [ "release"; "view"; tag; "--json"; "tagName,isPrerelease,url" ] + if releaseView.ExitCode <> 0 then + let notes = ensureReleaseNotes mainRoot tag + let createArgs = [ "release"; "create"; tag; "--verify-tag"; "--title"; tag; "--notes-file"; notes ] @ (if tag.Contains("-") then [ "--prerelease" ] else []) + runIn mainRoot "gh" createArgs |> requireSuccess "Create GitHub release" |> ignore + printfn "Branch accounting:" + if List.isEmpty accounting then printfn "- No local branches remain outside main." + else for branchName, status in accounting do printfn "- %s: %s" branchName status + printfn "Release %s completed from %s." tag mainHead + +let release (operation: string) (args: string list) = + let tag = optionValue "--version" args |> Option.defaultWith (fun () -> fail "Pass --version vX.Y.Z.") |> normalizeTag + match operation with + | "prepare" -> releasePrepare tag args + | "inspect" -> releaseInspect tag + | "advance" -> releaseAdvance tag args + | _ -> fail $"Unsupported release operation: {operation}" + +let main argv = + match List.ofArray argv with + | [ "validate" ] -> validate (); 0 + | [ "sync" ] -> sync (); 0 + | "release" :: operation :: args -> release operation args; 0 + | _ -> fail "Usage: repo-maintenance.fsx validate|sync|release prepare|inspect|advance --version vX.Y.Z" + +try fsi.CommandLineArgs |> Array.skip 1 |> main |> exit +with error -> eprintfn "ERROR: %s" error.Message; exit 1 diff --git a/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just b/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just new file mode 100644 index 000000000..03a71ab34 --- /dev/null +++ b/skills/maintain-project-repo/assets/repo-maintenance/repo-maintenance.just @@ -0,0 +1,20 @@ +docs-check: + dotnet fsi scripts/repo-maintenance/maintain-project-docs.fsx --project-root . --run-mode check-only --format markdown --fail-on-issues + +docs-apply: + dotnet fsi scripts/repo-maintenance/maintain-project-docs.fsx --project-root . --run-mode apply --format markdown --fail-on-issues + +repo-validate: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx validate + +repo-sync: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx sync + +repo-release-prepare version *args: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release prepare --version {{ quote(version) }} {{ args }} + +repo-release-inspect version: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release inspect --version {{ quote(version) }} + +repo-release-advance version *args: + dotnet fsi scripts/repo-maintenance/repo-maintenance.fsx release advance --version {{ quote(version) }} {{ args }} diff --git a/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh b/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh deleted file mode 100755 index 5a00c94aa..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/sync-shared.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" -. "$SELF_DIR/lib/common.sh" - -load_profile_env -ensure_git_repo -log "Running repo-maintenance shared sync from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." -run_dispatch_dir "$SELF_DIR/syncing" "sync" -log "Repo-maintenance shared sync completed successfully." diff --git a/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md b/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md index 66ff612c3..e04abac9f 100644 --- a/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md +++ b/skills/maintain-project-repo/assets/repo-maintenance/syncing/README.md @@ -1,31 +1,5 @@ -# Repo-Maintenance Syncing Steps +# Repository Synchronization Hooks -Small helper surface for deterministic repo-maintenance sync hooks. - -## Overview - -This directory holds repo-specific shell hooks that the shared repo-maintenance sync entrypoint can discover and run. - -### Motivation - -It exists so a repository can keep local sync follow-up steps in one predictable place without forking the shared sync entrypoint itself. - -## Setup - -Add repo-specific executable `.sh` files here only when the repository needs deterministic shared-sync follow-up steps. - -## Usage - -The top-level `scripts/repo-maintenance/sync-shared.sh` entrypoint discovers and runs every `*.sh` file in this directory in lexical order. - -## Development - -Keep each hook small, deterministic, and specific to the owning repository's guidance or packaging sync needs. - -## Verification - -Run the owning repository's shared sync entrypoint and confirm the expected repo-specific hooks execute in lexical order. - -## License - -Covered by the parent repository license. +Place only root-owned `.fsx` synchronization hooks here. `just repo-sync` +discovers them in lexical order, runs every hook, and then validates the full +repository. Keep hooks deterministic and non-interactive. diff --git a/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh b/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh deleted file mode 100755 index fdc434748..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/validate-all.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" -. "$SELF_DIR/lib/common.sh" - -load_profile_env -load_env_file "$SELF_DIR/config/validation.env" -ensure_git_repo -log "Running repo-maintenance validation from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." -run_dispatch_dir "$SELF_DIR/validations" "validation" -if [ "$REPO_MAINTENANCE_PROFILE" = "xcode-workspace" ]; then - "$SELF_DIR/workspace/validate-components.sh" -fi -log "Repo-maintenance validation completed successfully." diff --git a/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh b/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh deleted file mode 100755 index 7103b2036..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/validations/10-toolkit-layout.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -for required in \ - "$REPO_MAINTENANCE_ROOT/validate-all.sh" \ - "$REPO_MAINTENANCE_ROOT/sync-shared.sh" \ - "$REPO_MAINTENANCE_ROOT/release.sh" \ - "$REPO_MAINTENANCE_ROOT/lib/common.sh" \ - "$REPO_MAINTENANCE_ROOT/config/profile.env" -do - [ -f "$required" ] || die "maintain-project-repo is missing the required file $required." -done diff --git a/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh b/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh deleted file mode 100755 index 2f775a7d1..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/validations/20-agents-guidance.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -if [ "${REPO_MAINTENANCE_REQUIRE_AGENTS:-true}" != "true" ]; then - log "Skipping AGENTS.md validation because REPO_MAINTENANCE_REQUIRE_AGENTS is disabled." - exit 0 -fi - -agents_path="$REPO_ROOT/AGENTS.md" -[ -f "$agents_path" ] || die "Expected $agents_path to exist so maintain-project-repo has repo guidance to complement." -[ -s "$agents_path" ] || die "Expected $agents_path to be non-empty." - -for needle in \ - "scripts/repo-maintenance/validate-all.sh" \ - "scripts/repo-maintenance/sync-shared.sh" \ - "scripts/repo-maintenance/release.sh" -do - grep -F "$needle" "$agents_path" >/dev/null 2>&1 || die "Expected $agents_path to mention $needle so the maintainer validation, sync, and release entrypoints stay discoverable." -done diff --git a/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh b/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh deleted file mode 100755 index e6815be15..000000000 --- a/skills/maintain-project-repo/assets/repo-maintenance/validations/30-ci-wrapper.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" -. "$SELF_DIR/../lib/common.sh" - -workflow_path="$REPO_ROOT/.github/workflows/validate-repo-maintenance.yml" - -if [ ! -f "$workflow_path" ]; then - log "Skipping CI wrapper validation because $workflow_path is not present." - exit 0 -fi - -grep -Fq "scripts/repo-maintenance/validate-all.sh" "$workflow_path" || die "Expected $workflow_path to call scripts/repo-maintenance/validate-all.sh." diff --git a/skills/maintain-project-repo/references/automation-prompts.md b/skills/maintain-project-repo/references/automation-prompts.md index 2b095be28..0139fd58d 100644 --- a/skills/maintain-project-repo/references/automation-prompts.md +++ b/skills/maintain-project-repo/references/automation-prompts.md @@ -1,12 +1,13 @@ -# Repo Maintenance Toolkit Automation Prompts +# Repository Maintenance Prompts -- Install `maintain-project-repo` into `<repo_root>`, keep the GitHub workflow wrapper enabled, and create or normalize README.md, CONTRIBUTING.md, AGENTS.md, and ROADMAP.md in the same operation. -- Refresh `maintain-project-repo` in `<repo_root>` without deleting repo-specific custom scripts, then refresh the four canonical project documents through their owner workflows. -- Report what `maintain-project-repo` would install and which documentation findings remain in `<repo_root>` without mutating files. -- Explain when to use `scripts/repo-maintenance/validate-all.sh`, `scripts/repo-maintenance/sync-shared.sh`, and `scripts/repo-maintenance/release.sh`. -- Explain that standard release mode runs from a feature branch or worktree, opens a PR against protected `main`, takes bounded CI/review snapshots through `prepare`, `inspect`, and `advance`, gates on PR comments, merges, fast-forwards local `main`, creates and pushes the tag from that reviewed `main`, creates the GitHub release, accounts for every local branch not contained by `main`, and only then cleans up branches that are proven safe to delete. -- Explain that branch cleanup is gated by commit reachability: do not call work on `main`, merged, recovered, preserved, or safe to clean up until the exact local repository and remote prove it, and do not delete local branches, remote branches, worktrees, archive refs, or temporary rescue refs until any non-base history is merged or explicitly archived. -- Explain that standard release mode uses bounded `prepare`, `inspect`, and `advance` operations. For a remote gate, consume its continuation packet and reuse a live matching host-native wakeup while the gate remains pending and healthy; do not delete/recreate it after an unchanged snapshot. Create/update only after it fires or is stale, and pause/delete it when resolved, failed, cancelled, or identity-drifted. Every scheduled interval is at least five minutes. Run `inspect` first, and run `advance` only when packet identities still match. Codex uses heartbeat; Hermes uses a continuable `cronjob` with `deliver="origin"` and `attach_to_session=true`. -- Explain that every pending status context is a wait state, not a clean merge signal. Codex should wake, inspect checks, reviews, and comments, address valid findings, and merge only after every required check and the review/comment gate are clear. -- Explain that GitHub release creation uses checked-in `docs/releases/vX.Y.Z.md` notes first, then `docs/releases/X.Y.Z.md`; it logs a clear fallback to GitHub-generated notes only when neither file exists. -- Explain that protected branches should require the GitHub Actions check context `validate` for the managed repo-maintenance workflow. +- Install or refresh the fixed FSX repository-maintenance assets, then run the + complete documentation transaction. +- Report the managed-file and four-document result without mutation. +- Run `just repo-validate` for local or CI validation. +- Run `just repo-sync` for all deterministic shared-asset synchronization. +- Use the three `just repo-release-*` recipes for an explicit protected-main + release and never poll a remote gate. +- Require branch reachability and accounting evidence before cleanup. + +Never request direct script execution, Python, shell, per-file documentation +commands, or project-local schemas. diff --git a/skills/maintain-project-repo/references/customization-flow.md b/skills/maintain-project-repo/references/customization-flow.md deleted file mode 100644 index 04e370fd6..000000000 --- a/skills/maintain-project-repo/references/customization-flow.md +++ /dev/null @@ -1,31 +0,0 @@ -# Repo Maintenance Toolkit Customization Contract - -## Purpose - -Record lightweight default preferences for `maintain-project-repo` without turning its managed file set into a wide runtime customization surface. - -## Knobs - -| Knob | Default | Status | Effect | -| --- | --- | --- | --- | -| `defaultReleaseMode` | `standard` | `policy-only` | Sets the default planning posture when the user asks for a release flow without saying whether the repo is standalone or a submodule. Standard mode assumes releases run from a branch or worktree into protected `main`. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/install_maintain_project_repo.py` and `scripts/run_workflow.py` do not currently read these customization knobs. -- The managed file set, GitHub workflow wrapper, and release script surfaces are fixed workflow behavior rather than durable runtime customization. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected references to reflect the approved default-policy change. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify the workflow references still describe the same install and release behavior. - -## Validation - -1. Verify `references/repo-maintenance-layout.md` still matches the managed asset tree. -2. Verify `references/release-modes.md` still matches `assets/repo-maintenance/release.sh`. -3. Verify every customization knob is described consistently across `SKILL.md`, this file, and `references/automation-prompts.md`. diff --git a/skills/maintain-project-repo/references/customization.template.yaml b/skills/maintain-project-repo/references/customization.template.yaml deleted file mode 100644 index ed649883a..000000000 --- a/skills/maintain-project-repo/references/customization.template.yaml +++ /dev/null @@ -1,4 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - defaultReleaseMode: "standard" diff --git a/skills/maintain-project-repo/references/pre-commit-vs-ci.md b/skills/maintain-project-repo/references/pre-commit-vs-ci.md index 02c068974..942c6fb39 100644 --- a/skills/maintain-project-repo/references/pre-commit-vs-ci.md +++ b/skills/maintain-project-repo/references/pre-commit-vs-ci.md @@ -1,16 +1,9 @@ -# Pre-Commit vs CI +# Local Validation and CI -Use `maintain-project-repo` with a local-first split: +`just repo-validate` is the complete validation entrypoint for people, agents, +and CI. The managed GitHub workflow only installs the required runtime and +invokes that recipe. Repository-specific checks belong in root-owned +`scripts/repo-maintenance/validations/*.fsx` hooks. -- `scripts/repo-maintenance/validate-all.sh` - - the full local validation command - - the same command CI should call -- `.github/workflows/validate-repo-maintenance.yml` - - a thin wrapper that calls the local script - - install SwiftFormat and SwiftLint before validation so Apple-profile checks are reproducible on fresh macOS runners - - keep workflow logic limited to runner bootstrap and the local validation call -- `scripts/repo-maintenance/hooks/pre-commit.sample` - - an opt-in sample for cheap local checks - - do not turn it into the only validation surface - -Keep expensive or repo-shaping logic in the repo-owned scripts, not in GitHub workflow YAML. +Do not install pre-commit automation, duplicate checks in workflow YAML, or +introduce another command surface. diff --git a/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md b/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md index 9d4545d1b..ed83f2ec1 100644 --- a/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md +++ b/skills/maintain-project-repo/references/project-docs-maintenance-automation-prompts.md @@ -1,30 +1,14 @@ -# Project Docs Maintenance Automation Prompts +# Project Documentation Prompts -Use these prompts when scheduling or delegating the documentation phase owned by -`maintain-project-repo`. +## Audit -## Check-Only Sweep +Run `just docs-check`. Report findings for README, CONTRIBUTING, AGENTS, and +ROADMAP in that order, including responsibility drift. Do not mutate files. -Run `maintain-project-repo --operation report-only` for the target repository. -Report the planned toolkit actions and audit README, CONTRIBUTING, AGENTS, and -ROADMAP in that order. Include owner-skill findings, cross-document -responsibility drift, and stale command evidence. Do not edit files, commit, -push, or open a pull request. +## Apply -## Bounded Apply Sweep +Run `just docs-apply`. Apply the planned four-document transaction atomically, +verify the result, and require a second apply to be byte-identical. -Run `maintain-project-repo --operation refresh` after the operator approves the -repository refresh. Let each owner document workflow edit only its own target -file. Report remaining cross-document issues separately from fixes already -applied. Do not move content across files unless the operator explicitly -requested that cleanup. - -## Subagent Discovery - -When the repository is large, ask subagents for read-only findings before the main thread edits: - -- one worker checks README and contributor docs for stale commands -- one worker checks AGENTS and nested guidance for routing or policy drift -- one worker checks ROADMAP and issue state for small-ticket candidates - -Require file references and concise evidence from each worker. The main thread owns the final edits and validation. +These are the only supported documentation operations. Never split work by +file or add a project-specific schema, status vocabulary, or fix policy. diff --git a/skills/maintain-project-repo/references/release-modes.md b/skills/maintain-project-repo/references/release-modes.md index 46646bbc9..48e77efde 100644 --- a/skills/maintain-project-repo/references/release-modes.md +++ b/skills/maintain-project-repo/references/release-modes.md @@ -1,68 +1,20 @@ -# Release Modes +# Release Workflow -Use these modes only when the current task is actually a release, publish, merge, tag, or protected-main release preparation task. They are not the default completion path for ordinary questions, investigations, local edits, documentation maintenance, or targeted validation. +The managed runtime supports one standard protected-main release workflow. +Run it only for an explicit release task and only through: -## `standard` - -Use this mode for an ordinary standalone repository whose release line is a protected `main` branch. - -Run it from a feature branch or worktree. Do not run standard release mode from `main`; the script treats `main` as the protected integration branch that receives the release through a pull request. - -- run `--operation prepare` for local validation, the version bump, branch push, PR creation, one remote snapshot, and a continuation packet -- require committed changes and a clean worktree -- run the repo-specific version bump hook at the selected profile root: - `scripts/repo-maintenance/version-bump.sh` for every profile -- commit the version bump as `release: bump versions for vX.Y.Z` -- push the branch -- perform one immediate branch-visibility re-read; if it is not visible, emit a continuation packet instead of polling -- open or update a pull request against `main` -- use `--operation inspect` for one PR/check/review snapshot; it emits a continuation packet for unknown or pending remote state -- create one host-native continuation no sooner than five minutes later, then reuse that same matching scheduler item while the gate stays pending and healthy; do not delete/recreate it after an unchanged snapshot. Codex uses heartbeat, Hermes uses an updated continuable `cronjob` with `deliver="origin"` and `attach_to_session=true` -- on wakeup run `inspect` first, then use `--operation advance` only if the packet's branch, commit, PR, and tag identities still match -- stop with a clear message if any required check fails or remains pending, changes are requested, or unresolved comments remain -- stop on requested changes or comments so the maintainer can address valid concerns, add out-of-scope concerns to `ROADMAP.md`, resolve the threads, push, and rerun the same script -- merge the PR with a merge commit once CI is green and the comment pass is clear -- fast-forward local `main` from `origin/main` -- create the annotated release tag locally from the reviewed local `main` -- push the tag -- perform one immediate tag-visibility re-read; if it is not visible, emit a continuation packet instead of polling -- create the GitHub release unless skipped, preferring `docs/releases/vX.Y.Z.md` and then `docs/releases/X.Y.Z.md` as its checked-in body; when neither exists, log the fallback and use GitHub-generated notes. Pass `--prerelease` for SemVer prerelease tags such as `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`, or preview-style suffixes -- perform one immediate GitHub-release re-read; if it is not readable, emit a continuation packet instead of polling -- verify the GitHub release object's prerelease metadata matches the release tag before calling release publication complete -- verify `git log origin/main..main` or the repository's equivalent base/remote comparison is empty before claiming the local base branch is synchronized -- enumerate every local branch still not contained by `main` and account for each branch as already preserved elsewhere, intentionally still in progress, newly archived, newly merged, or safe to delete -- prune stale remote tracking refs and delete only local branches already merged into `main` after branch accounting proves they are safe - -Treat branch accounting as a hard completion gate, not optional cleanup. Use `git branch --no-merged <base>` or the repository's equivalent branch inventory before cleanup, and do not say a release, publish, merge, or cleanup step is done until every local branch not contained by the local base branch has been accounted for. Do not say work is on `main`, merged, recovered, preserved, or safe to clean up until commit reachability has been verified in the exact local repository and remote that statement refers to. Do not delete local branches, remote branches, worktrees, archive refs, or temporary rescue refs until branch accounting is complete and any non-base history is either merged or preserved on an explicit archive ref. - -Example: - -```bash -bash scripts/repo-maintenance/release.sh --mode standard --version v1.2.0 --operation prepare +```text +just repo-release-prepare <version> +just repo-release-inspect <version> +just repo-release-advance <version> ``` -When a release intentionally has no repo version surfaces, pass `--skip-version-bump`. When the PR comment pass has already been handled and only historical comments remain visible through GitHub, rerun with `--review-comments-addressed`. +Prepare validates, runs the optional root-owned `version-bump.fsx`, checks +release notes, commits, pushes, and opens or updates the release PR. Inspect +takes one bounded snapshot of identity, CI, reviews, and comments. Advance +rechecks the snapshot, merges only when every gate is clear, updates the owning +main worktree, tags, pushes, publishes, and performs branch accounting. -Remote waiting is never a release-script operation. `prepare`, `inspect`, and `advance` each take one bounded snapshot and either make an immediate safe transition or emit a continuation packet. Agents create one host-native wakeup no sooner than five minutes later, reuse that same matching scheduler item while the gate remains pending and healthy, and pause/delete it only on resolution, failure, cancellation, or identity drift. Create/update a replacement only after the prior item fires or becomes stale. Resume with `inspect`, and use `advance` only after packet identities match. Do not use shell `sleep`, `gh pr checks --watch`, timer loops, or one-to-four-minute rechecks. - -## `submodule` - -Use this mode when the current repository is checked out as a git submodule inside a larger parent repository: - -- run local validation first -- require a clean worktree -- require an actual superproject relationship -- create the release tag locally -- push the branch and tag in the submodule repository -- perform one immediate branch and tag visibility re-read; if either is absent, create or reuse the matching host-native continuation no sooner than five minutes later, then inspect before another release action rather than polling -- create the GitHub release when `gh` is available, passing `--prerelease` for SemVer prerelease tags such as `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`, or preview-style suffixes -- perform one immediate GitHub release re-read after creation; if it is absent, create or reuse the matching host-native continuation no sooner than five minutes later, then inspect before another release action rather than polling -- verify the GitHub release object's prerelease metadata matches the release tag before calling release publication complete -- verify the submodule branch and tag are visible on the intended remote before calling that work preserved or released -- leave the parent-repo pointer update as a separate explicit follow-up step - -Example: - -```bash -bash scripts/repo-maintenance/release.sh --mode submodule --version v1.2.0 -``` +There is no polling mode. Pending remote state produces a continuation packet. +Never delete branches, worktrees, tags, or refs until reachability and branch +accounting prove the action safe. diff --git a/skills/maintain-project-repo/references/repo-maintenance-layout.md b/skills/maintain-project-repo/references/repo-maintenance-layout.md index 6563759db..c9788e107 100644 --- a/skills/maintain-project-repo/references/repo-maintenance-layout.md +++ b/skills/maintain-project-repo/references/repo-maintenance-layout.md @@ -1,44 +1,23 @@ # Repo Maintenance Layout -The managed target layout is: +The installer owns one fixed runtime under `scripts/repo-maintenance/`: ```text -scripts/ - repo-maintenance/ - validate-all.sh - sync-shared.sh - release.sh - version-bump.sh (optional repo-specific hook) - lib/ - common.sh - validations/ - 10-toolkit-layout.sh - 20-agents-guidance.sh - 30-ci-wrapper.sh - syncing/ - release/ - 10-preflight.sh - 20-tag-release.sh - 30-push-release.sh - 40-github-release.sh - config/ - validation.env - release.env - hooks/ - pre-commit.sample -.github/ - workflows/ - validate-repo-maintenance.yml -.swiftformat (Apple profiles) -.swiftlint.yml (Apple profiles) +maintain-project-docs.fsx +repo-maintenance.fsx +repo-maintenance.just +managed-assets.json +config/profile.json +docs/ +validations/*.fsx +syncing/*.fsx +version-bump.fsx (optional repo-owned release hook) ``` -## Design Rules +The root `justfile` imports `repo-maintenance.just`. Operators use `just`; the +runtime discovers root-owned `.fsx` hooks lexically. Managed files refresh in +place, while files outside the manifest remain repo-owned. -- Top-level scripts are stable entrypoints. -- Ordered `validations/*.sh`, `syncing/*.sh`, and `release/*.sh` are discovered automatically. -- Managed files are safe to refresh in place. -- Repo-specific extra scripts are allowed as long as they do not reuse the managed filenames. -- Apple profiles install `.swiftformat` and `.swiftlint.yml` samples together; SwiftFormat remains the formatting authority and SwiftLint stays scoped to complementary non-formatting checks. -- Standard release mode uses the optional repo-specific `version-bump.sh` hook when it exists and requires either that hook or an explicit `--skip-version-bump` decision. -- The managed GitHub workflow exposes `validate` as the required branch-protection check context. Do not configure protected branches to require the display-style string `Validate Repo Maintenance / validate`. +The only documentation recipes are `docs-check` and `docs-apply`. Both process +all four documents. Do not add Python, shell, per-document recipes, nested +tests, configuration schemas, or duplicate workflow implementations. diff --git a/skills/maintain-project-repo/scripts/customization_config.py b/skills/maintain-project-repo/scripts/customization_config.py deleted file mode 100755 index e932fb806..000000000 --- a/skills/maintain-project-repo/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "maintain-project-repo" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/maintain-project-repo/scripts/install_maintain_project_repo.py b/skills/maintain-project-repo/scripts/install_maintain_project_repo.py deleted file mode 100755 index 06010265a..000000000 --- a/skills/maintain-project-repo/scripts/install_maintain_project_repo.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Install or refresh the managed maintain-project-repo files.""" - -from __future__ import annotations - -import argparse -import json -import shutil -from pathlib import Path - -PROFILE_CHOICES = { - "generic": "Generic repo-maintenance baseline with no Swift or Xcode specialization.", - "xcode-workspace": "Canonical Swift workspace repo-maintenance profile for Apps, Packages, and Services roots.", -} -PROFILE_TOOLKIT_ROOTS = { - "generic": Path("scripts/repo-maintenance"), - "xcode-workspace": Path("scripts/repo-maintenance"), -} -PROFILE_OVERLAY_FILES = { - "xcode-workspace": [ - ("profiles/apple/repo-maintenance/.swiftformat", ".swiftformat"), - ("profiles/apple/repo-maintenance/.swiftlint.yml", ".swiftlint.yml"), - ( - "profiles/xcode-workspace/repo-maintenance/validations/40-xcode-workspace-layout.sh", - "scripts/repo-maintenance/validations/40-xcode-workspace-layout.sh", - ), - ( - "profiles/xcode-workspace/repo-maintenance/workspace/validate-components.sh", - "scripts/repo-maintenance/workspace/validate-components.sh", - ), - ], -} -PROFILE_WORKFLOW_FILES = { - "xcode-workspace": "profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml", -} -MANAGED_TOOLKIT_FILES = [ - ("repo-maintenance/validate-all.sh", "scripts/repo-maintenance/validate-all.sh"), - ("repo-maintenance/sync-shared.sh", "scripts/repo-maintenance/sync-shared.sh"), - ("repo-maintenance/release.sh", "scripts/repo-maintenance/release.sh"), - ("repo-maintenance/lib/common.sh", "scripts/repo-maintenance/lib/common.sh"), - ("repo-maintenance/validations/10-toolkit-layout.sh", "scripts/repo-maintenance/validations/10-toolkit-layout.sh"), - ("repo-maintenance/validations/20-agents-guidance.sh", "scripts/repo-maintenance/validations/20-agents-guidance.sh"), - ("repo-maintenance/validations/30-ci-wrapper.sh", "scripts/repo-maintenance/validations/30-ci-wrapper.sh"), - ("repo-maintenance/syncing/README.md", "scripts/repo-maintenance/syncing/README.md"), - ("repo-maintenance/release/10-preflight.sh", "scripts/repo-maintenance/release/10-preflight.sh"), - ("repo-maintenance/release/20-tag-release.sh", "scripts/repo-maintenance/release/20-tag-release.sh"), - ("repo-maintenance/release/30-push-release.sh", "scripts/repo-maintenance/release/30-push-release.sh"), - ("repo-maintenance/release/40-github-release.sh", "scripts/repo-maintenance/release/40-github-release.sh"), - ("repo-maintenance/config/validation.env", "scripts/repo-maintenance/config/validation.env"), - ("repo-maintenance/config/release.env", "scripts/repo-maintenance/config/release.env"), - ("repo-maintenance/hooks/pre-commit.sample", "scripts/repo-maintenance/hooks/pre-commit.sample"), -] -MANAGED_WORKFLOW_FILE = ".github/workflows/validate-repo-maintenance.yml" -DEFAULT_TOOLKIT_ROOT = Path("scripts/repo-maintenance") -LEGACY_XCODE_TOOLKIT_ROOT = Path("Scripts/repo-maintenance") -EXECUTABLE_SUFFIXES = {".sh", ".py", ".sample"} - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root", required=True) - parser.add_argument("--operation", choices=("install", "refresh", "report-only"), default="install") - parser.add_argument("--profile", choices=sorted(PROFILE_CHOICES), default="generic") - parser.add_argument("--skip-github-workflow", action="store_true") - parser.add_argument("--dry-run", action="store_true") - return parser - - -def assets_root() -> Path: - return Path(__file__).resolve().parents[1] / "assets" - - -def toolkit_root(profile: str) -> Path: - return PROFILE_TOOLKIT_ROOTS[profile] - - -def profile_file(profile: str) -> Path: - return toolkit_root(profile) / "config/profile.env" - - -def profile_target_path(profile: str, target_relative: str) -> Path: - target = Path(target_relative) - try: - suffix = target.relative_to(DEFAULT_TOOLKIT_ROOT) - except ValueError: - return target - return toolkit_root(profile) / suffix - - -def target_pairs(profile: str, skip_github_workflow: bool) -> list[tuple[Path, Path]]: - root = assets_root() - pairs: list[tuple[Path, Path]] = [] - - def add_pair(source_relative: str, target_relative: str) -> None: - target = profile_target_path(profile, target_relative) - for index, (_, existing_target) in enumerate(pairs): - if existing_target == target: - pairs[index] = (root / source_relative, target) - return - pairs.append((root / source_relative, target)) - - for source_relative, target_relative in MANAGED_TOOLKIT_FILES: - if profile == "xcode-workspace" and source_relative == "repo-maintenance/hooks/pre-commit.sample": - continue - add_pair(source_relative, target_relative) - for source_relative, target_relative in PROFILE_OVERLAY_FILES.get(profile, []): - add_pair(source_relative, target_relative) - if not skip_github_workflow: - workflow_source = PROFILE_WORKFLOW_FILES.get( - profile, - "github/repo-maintenance-workflows/validate-repo-maintenance.yml", - ) - pairs.append( - ( - root / workflow_source, - Path(MANAGED_WORKFLOW_FILE), - ) - ) - return pairs - - -def ensure_safe_target(repo_root: Path, relative_target: Path) -> None: - target = repo_root / relative_target - if target.exists() and not target.is_file(): - raise RuntimeError( - f"The managed target path {target} exists but is not a regular file." - ) - - -def xcode_workspace_findings(repo_root: Path) -> list[str]: - findings: list[str] = [] - workspaces = list(repo_root.glob("*.xcworkspace")) - if len(workspaces) != 1: - findings.append(f"expected exactly one root .xcworkspace, found {len(workspaces)}") - - apps_root = repo_root / "Apps" - if not apps_root.is_dir(): - findings.append("expected Apps/ at the repository root") - - if not (repo_root / "project.yml").is_file(): - findings.append("expected root project.yml") - elif not any(repo_root.glob("*.xcodeproj")): - findings.append("expected one generated root .xcodeproj") - - packages_root = repo_root / "Packages" - if not packages_root.is_dir(): - findings.append("expected Packages/ at the repository root") - services_root = repo_root / "Services" - if not services_root.is_dir(): - findings.append("expected Services/ at the repository root") - - component_found = ( - any(apps_root.glob("**/target.y*ml")) - or any(packages_root.glob("**/Package.swift")) - or any(services_root.glob("**/Package.swift")) - ) - if not component_found: - findings.append("expected at least one component under Apps/, Packages/, or Services/") - return findings - - -def ensure_profile_shape(repo_root: Path, profile: str) -> None: - if profile != "xcode-workspace": - return - findings = xcode_workspace_findings(repo_root) - if findings: - raise RuntimeError( - "The xcode-workspace profile requires a canonical Swift product workspace: " - + "; ".join(findings) - + ". Create or align the product through bootstrap-xcode-workspace." - ) - - -def legacy_xcode_toolkit_migration(repo_root: Path, profile: str) -> tuple[Path, Path] | None: - if profile != "xcode-workspace": - return None - - legacy_parent = next((path for path in repo_root.iterdir() if path.name == "Scripts"), None) - if legacy_parent is None: - return None - - legacy_root = legacy_parent / "repo-maintenance" - desired_root = repo_root / toolkit_root(profile) - if not legacy_root.exists(): - return None - - if not legacy_root.is_dir(): - raise RuntimeError( - f"The {profile} profile expects repo-maintenance under " - f"{toolkit_root(profile).as_posix()}, but the legacy path " - f"{LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} exists and is not a directory." - ) - - if desired_root.exists(): - try: - if legacy_root.samefile(desired_root): - temporary_parent = repo_root / ".maintain-project-repo-scripts-case-migration" - if temporary_parent.exists(): - raise RuntimeError( - f"Cannot normalize {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} while " - f"{temporary_parent.name} already exists. Remove or preserve that temporary path " - "and rerun maintain-project-repo." - ) - return legacy_root, desired_root - except OSError: - pass - raise RuntimeError( - f"The {profile} profile expects repo-maintenance under " - f"{toolkit_root(profile).as_posix()}, but both {DEFAULT_TOOLKIT_ROOT.as_posix()} " - f"and {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} already exist as separate paths. " - "Choose the intentional toolkit root, preserve any repo-specific custom files, " - "and rerun maintain-project-repo." - ) - - return legacy_root, desired_root - - -def apply_legacy_xcode_toolkit_migration(repo_root: Path, profile: str) -> str | None: - migration = legacy_xcode_toolkit_migration(repo_root, profile) - if migration is None: - return None - - legacy_root, desired_root = migration - try: - same_root = legacy_root.samefile(desired_root) - except OSError: - same_root = False - - if same_root: - legacy_parent = legacy_root.parent - temporary_parent = repo_root / ".maintain-project-repo-scripts-case-migration" - legacy_parent.rename(temporary_parent) - temporary_parent.rename(desired_root.parent) - return ( - f"normalized legacy {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} to " - f"{toolkit_root(profile).as_posix()} for {profile} profile" - ) - - desired_root.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(legacy_root), str(desired_root)) - try: - legacy_root.parent.rmdir() - except OSError: - pass - return ( - f"migrated legacy {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} to " - f"{toolkit_root(profile).as_posix()} for {profile} profile" - ) - - -def copy_file(source: Path, target: Path, profile: str) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - if profile == "xcode-workspace": - content = source.read_text(encoding="utf-8") - content = content.replace("Scripts/repo-maintenance", "scripts/repo-maintenance") - target.write_text(content, encoding="utf-8") - else: - shutil.copyfile(source, target) - if source.suffix in EXECUTABLE_SUFFIXES: - target.chmod(0o755) - - -def render_profile_env(profile: str) -> str: - description = PROFILE_CHOICES[profile] - return ( - "# Managed by maintain-project-repo. Do not hand-edit unless you also control the installer contract.\n" - f'REPO_MAINTENANCE_PROFILE="{profile}"\n' - f'REPO_MAINTENANCE_PROFILE_DESCRIPTION="{description}"\n' - ) - - -def write_profile_env(repo_root: Path, profile: str) -> None: - target = repo_root / profile_file(profile) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(render_profile_env(profile), encoding="utf-8") - - -def main() -> int: - args = build_parser().parse_args() - repo_root = Path(args.repo_root).expanduser().resolve() - actions: list[str] = [] - managed_files = [relative.as_posix() for _, relative in target_pairs(args.profile, args.skip_github_workflow)] - managed_profile_file = profile_file(args.profile) - managed_files.append(managed_profile_file.as_posix()) - - if not repo_root.exists(): - print( - json.dumps( - { - "status": "blocked", - "path_type": "primary", - "repo_root": str(repo_root), - "managed_files": managed_files, - "actions": actions, - "stderr": "The requested repo root does not exist.", - "next_step": "Create or choose an existing repository root and rerun the workflow.", - }, - indent=2, - sort_keys=True, - ) - ) - return 1 - - if not repo_root.is_dir(): - print( - json.dumps( - { - "status": "blocked", - "path_type": "primary", - "repo_root": str(repo_root), - "managed_files": managed_files, - "actions": actions, - "stderr": "The requested repo root is not a directory.", - "next_step": "Use a directory path for --repo-root and rerun the workflow.", - }, - indent=2, - sort_keys=True, - ) - ) - return 1 - - try: - ensure_profile_shape(repo_root, args.profile) - planned_migration = legacy_xcode_toolkit_migration(repo_root, args.profile) - for _, relative_target in target_pairs(args.profile, args.skip_github_workflow): - ensure_safe_target(repo_root, relative_target) - ensure_safe_target(repo_root, managed_profile_file) - except RuntimeError as exc: - print( - json.dumps( - { - "status": "blocked", - "path_type": "primary", - "repo_root": str(repo_root), - "managed_files": managed_files, - "actions": actions, - "stderr": str(exc), - "next_step": "Resolve the conflicting target path and rerun the workflow.", - }, - indent=2, - sort_keys=True, - ) - ) - return 1 - - if args.operation == "report-only" or args.dry_run: - if planned_migration is not None: - actions.append( - f"migrate legacy {LEGACY_XCODE_TOOLKIT_ROOT.as_posix()} to " - f"{toolkit_root(args.profile).as_posix()} for {args.profile} profile" - ) - for source, relative_target in target_pairs(args.profile, args.skip_github_workflow): - target = repo_root / relative_target - if target.exists(): - actions.append(f"refresh {relative_target.as_posix()} from {source.relative_to(assets_root()).as_posix()}") - else: - actions.append(f"install {relative_target.as_posix()} from {source.relative_to(assets_root()).as_posix()}") - profile_target = repo_root / managed_profile_file - if profile_target.exists(): - actions.append(f"refresh {managed_profile_file.as_posix()} for {args.profile} profile") - else: - actions.append(f"install {managed_profile_file.as_posix()} for {args.profile} profile") - print( - json.dumps( - { - "status": "success", - "path_type": "fallback", - "repo_root": str(repo_root), - "profile": args.profile, - "managed_files": managed_files, - "actions": actions, - "validation_result": "skipped (--dry-run)" if args.dry_run else "skipped (report-only)", - "next_step": "Run without --dry-run or report-only to install or refresh maintain-project-repo.", - }, - indent=2, - sort_keys=True, - ) - ) - return 0 - - migration_action = apply_legacy_xcode_toolkit_migration(repo_root, args.profile) - if migration_action is not None: - actions.append(migration_action) - - for source, relative_target in target_pairs(args.profile, args.skip_github_workflow): - target = repo_root / relative_target - action = "refreshed" if target.exists() else "installed" - copy_file(source, target, args.profile) - actions.append(f"{action} {relative_target.as_posix()}") - profile_target = repo_root / managed_profile_file - profile_action = "refreshed" if profile_target.exists() else "installed" - write_profile_env(repo_root, args.profile) - actions.append(f"{profile_action} {managed_profile_file.as_posix()} for {args.profile} profile") - - print( - json.dumps( - { - "status": "success", - "path_type": "primary", - "repo_root": str(repo_root), - "profile": args.profile, - "managed_files": managed_files, - "actions": actions, - "validation_result": "managed files synced", - "next_step": f"Use {toolkit_root(args.profile).as_posix()}/validate-all.sh locally and keep CI as a thin wrapper around that command.", - }, - indent=2, - sort_keys=True, - ) - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/maintain-project-repo/scripts/maintain-project-docs.fsx b/skills/maintain-project-repo/scripts/maintain-project-docs.fsx new file mode 100644 index 000000000..6b8056734 --- /dev/null +++ b/skills/maintain-project-repo/scripts/maintain-project-docs.fsx @@ -0,0 +1,20 @@ +#!/usr/bin/env -S dotnet fsi +#load "../../../shared/project-docs/ProjectDocs.fsx" +#load "../../../shared/project-docs/DocsCoordinator.fsx" + +open System.IO +open DocsCoordinator + +let pluginRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let asset skill target template = + let root = Path.Combine(pluginRoot, "skills", skill, "assets") + { Name = skill; Target = target; Contract = Path.Combine(root, "document.contract.json"); Template = Path.Combine(root, template) } + +let assets = [ + asset "maintain-project-readme" "README.md" "README.template.md" + asset "maintain-project-contributing" "CONTRIBUTING.md" "CONTRIBUTING.template.md" + asset "maintain-project-agents" "AGENTS.md" "AGENTS.template.md" + asset "maintain-project-roadmap" "ROADMAP.md" "ROADMAP.template.md" +] + +fsi.CommandLineArgs |> Array.skip 1 |> execute assets |> exit diff --git a/skills/maintain-project-repo/scripts/maintain-project-repo.fsx b/skills/maintain-project-repo/scripts/maintain-project-repo.fsx new file mode 100644 index 000000000..64555f771 --- /dev/null +++ b/skills/maintain-project-repo/scripts/maintain-project-repo.fsx @@ -0,0 +1,147 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json + +type ManagedFile = { Source: string; Target: string; Profile: string option } +type Action = { Action: string; Target: string } +type Report = { + Status: string + Operation: string + Profile: string + RepoRoot: string + ManagedFiles: string list + Actions: Action list + DocumentationResult: string + Errors: string list +} + +let scriptRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let skillRoot = Path.GetFullPath(Path.Combine(scriptRoot, "..")) +let pluginRoot = Path.GetFullPath(Path.Combine(skillRoot, "..", "..")) +let manifestPath = Path.Combine(skillRoot, "assets", "managed-assets.json") + +let parseArgs argv = + let mutable repoRoot = "." + let mutable operation = "install" + let mutable profile = "generic" + let rec loop args = + match args with + | [] -> () + | "--repo-root" :: value :: tail -> repoRoot <- value; loop tail + | "--operation" :: value :: tail -> operation <- value; loop tail + | "--profile" :: value :: tail -> profile <- value; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + Path.GetFullPath(repoRoot), operation, profile + +let loadManifest () = + use document = JsonDocument.Parse(File.ReadAllText(manifestPath)) + if document.RootElement.GetProperty("schemaVersion").GetInt32() <> 1 then failwith "Unsupported managed-assets schema." + document.RootElement.GetProperty("files").EnumerateArray() + |> Seq.map (fun item -> + let hasProfile, profile = item.TryGetProperty("profile") + { Source = item.GetProperty("source").GetString(); Target = item.GetProperty("target").GetString(); Profile = if hasProfile then Some(profile.GetString()) else None }) + |> Seq.toList + +let ensureInside (root: string) (relative: string) = + if Path.IsPathRooted(relative) || relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) |> Array.contains ".." then + failwith $"Managed target must be repository-relative: {relative}" + Path.Combine(root, relative) |> Path.GetFullPath + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let ensureJustImport (root: string) (apply: bool) = + let path = Path.Combine(root, "justfile") + let importLine = "import 'scripts/repo-maintenance/repo-maintenance.just'" + let existing = if File.Exists(path) then File.ReadAllText(path).Replace("\r\n", "\n") else "" + if existing.Contains(importLine) then None + else + let updated = existing.TrimEnd() + (if String.IsNullOrWhiteSpace(existing) then "" else "\n\n") + "# BEGIN managed repo-maintenance\n" + importLine + "\n# END managed repo-maintenance\n" + if apply then atomicWrite path updated + Some { Action = (if File.Exists(path) then "update" else "install"); Target = "justfile" } + +let writeProfile (root: string) (profile: string) (apply: bool) = + let target = "scripts/repo-maintenance/config/profile.json" + let path = ensureInside root target + let content = $"{{\n \"schemaVersion\": 1,\n \"profile\": \"{profile}\"\n}}\n" + let action = if File.Exists(path) && File.ReadAllText(path) = content then "unchanged" elif File.Exists(path) then "update" else "install" + if apply && action <> "unchanged" then atomicWrite path content + { Action = action; Target = target } + +let copyManaged (root: string) (apply: bool) (managed: ManagedFile) = + let source = Path.Combine(pluginRoot, managed.Source) |> Path.GetFullPath + if not (File.Exists(source)) then failwith $"Managed source is missing: {source}" + let target = ensureInside root managed.Target + let content = File.ReadAllText(source).Replace("\r\n", "\n") + let action = if File.Exists(target) && File.ReadAllText(target).Replace("\r\n", "\n") = content then "unchanged" elif File.Exists(target) then "update" else "install" + if apply && action <> "unchanged" then atomicWrite target content + { Action = action; Target = managed.Target } + +let runDocs (root: string) (mode: string) = + let startInfo = ProcessStartInfo("dotnet") + startInfo.WorkingDirectory <- root + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in [ "fsi"; Path.Combine(scriptRoot, "maintain-project-docs.fsx"); "--project-root"; root; "--run-mode"; mode; "--format"; "json" ] do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + if child.ExitCode <> 0 then failwith $"Managed documentation {mode} failed: {stderr.Trim()}\n{stdout.Trim()}" + if String.IsNullOrWhiteSpace(stdout) then failwith "Managed documentation returned no report." + stdout.Trim() + +let isGitRepository root = + let startInfo = ProcessStartInfo("git") + startInfo.WorkingDirectory <- root + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in [ "rev-parse"; "--show-toplevel" ] do startInfo.ArgumentList.Add(argument) + use child = Process.Start(startInfo) + child.StandardOutput.ReadToEnd() |> ignore + child.StandardError.ReadToEnd() |> ignore + child.WaitForExit() + child.ExitCode = 0 + +let jsonOptions = + let value = JsonSerializerOptions(WriteIndented = true) + value.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + value + +let execute argv = + try + let root, operation, profile = parseArgs argv + if not (Directory.Exists(root)) then failwith $"Repository root does not exist: {root}" + if not (isGitRepository root) then failwith $"Path is not a Git repository: {root}" + if not (List.contains operation [ "install"; "refresh"; "report-only" ]) then failwith $"Unsupported operation: {operation}" + if not (List.contains profile [ "generic"; "xcode-workspace" ]) then failwith $"Unsupported profile: {profile}" + let apply = operation <> "report-only" + let managed = loadManifest () |> List.filter (fun file -> file.Profile.IsNone || file.Profile = Some profile) + let actions = managed |> List.map (copyManaged root apply) |> ResizeArray + actions.Add(writeProfile root profile apply) + match ensureJustImport root apply with Some action -> actions.Add(action) | None -> () + let docs = runDocs root (if apply then "apply" else "check-only") + let report = { + Status = "success"; Operation = operation; Profile = profile; RepoRoot = root + ManagedFiles = (managed |> List.map (fun file -> file.Target)) @ [ "scripts/repo-maintenance/config/profile.json"; "justfile" ] + Actions = List.ofSeq actions; DocumentationResult = docs; Errors = [] + } + Console.Out.Write(JsonSerializer.Serialize(report, jsonOptions) + "\n") + 0 + with error -> + let report = { Status = "failed"; Operation = ""; Profile = ""; RepoRoot = ""; ManagedFiles = []; Actions = []; DocumentationResult = ""; Errors = [ error.Message ] } + Console.Out.Write(JsonSerializer.Serialize(report, jsonOptions) + "\n") + 1 + +fsi.CommandLineArgs |> Array.skip 1 |> execute |> exit diff --git a/skills/maintain-project-repo/scripts/maintain_project_docs.py b/skills/maintain-project-repo/scripts/maintain_project_docs.py deleted file mode 100644 index f3409aedf..000000000 --- a/skills/maintain-project-repo/scripts/maintain_project_docs.py +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - - -SKILL_ROOT = Path(__file__).resolve().parents[1] -PLUGIN_ROOT = SKILL_ROOT.parents[1] - - -@dataclass(frozen=True) -class DocumentWorkflow: - key: str - label: str - filename: str - script: Path - path_arg: str - - -DOCUMENT_WORKFLOWS: Tuple[DocumentWorkflow, ...] = ( - DocumentWorkflow( - key="readme", - label="README", - filename="README.md", - script=PLUGIN_ROOT - / "skills/maintain-project-readme/scripts/maintain_project_readme.py", - path_arg="--readme-path", - ), - DocumentWorkflow( - key="contributing", - label="CONTRIBUTING", - filename="CONTRIBUTING.md", - script=PLUGIN_ROOT - / "skills/maintain-project-contributing/scripts/maintain_project_contributing.py", - path_arg="--contributing-path", - ), - DocumentWorkflow( - key="agents", - label="AGENTS", - filename="AGENTS.md", - script=PLUGIN_ROOT - / "skills/maintain-project-agents/scripts/maintain_project_agents.py", - path_arg="--agents-path", - ), - DocumentWorkflow( - key="roadmap", - label="ROADMAP", - filename="ROADMAP.md", - script=PLUGIN_ROOT - / "skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py", - path_arg="--roadmap-path", - ), -) - -ISSUE_KEYS = ( - "schema_violations", - "content_quality_issues", - "command_integrity_issues", - "workflow_drift_issues", - "validation_drift_issues", - "boundary_and_safety_issues", - "claim_integrity_issues", - "verification_evidence_issues", - "post_fix_status", -) - - -def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Run a coordinated project documentation maintenance sweep." - ) - parser.add_argument( - "--project-root", required=True, help="Absolute project root path" - ) - parser.add_argument( - "--run-mode", - required=True, - choices=["check-only", "apply"], - help="Execution mode", - ) - parser.add_argument( - "--include", help="Comma-separated document workflow keys to include" - ) - parser.add_argument("--skip", help="Comma-separated document workflow keys to skip") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument( - "--fail-on-issues", - action="store_true", - help="Exit non-zero when findings remain", - ) - parser.add_argument( - "--collect-source-tickets", - action="store_true", - help="Pass source TODO/FIXME collection through to the roadmap workflow.", - ) - parser.add_argument( - "--collect-github-issues", - action="store_true", - help="Pass GitHub issue collection through to the roadmap workflow.", - ) - parser.add_argument( - "--github-repo", - help="Optional OWNER/REPO override for roadmap GitHub issue collection", - ) - return parser.parse_args(argv) - - -def split_keys(raw: Optional[str]) -> List[str]: - if not raw: - return [] - return [part.strip().lower() for part in raw.split(",") if part.strip()] - - -def select_workflows( - include: Optional[str], skip: Optional[str], project_root: Optional[Path] = None -) -> Tuple[List[DocumentWorkflow], List[str]]: - known = {workflow.key: workflow for workflow in DOCUMENT_WORKFLOWS} - errors: List[str] = [] - include_keys = split_keys(include) - skip_keys = set(split_keys(skip)) - for key in [*include_keys, *skip_keys]: - if key not in known: - errors.append(f"Unknown document workflow key: {key}") - if include_keys: - selected = [known[key] for key in include_keys if key in known] - else: - selected = list(DOCUMENT_WORKFLOWS) - return [workflow for workflow in selected if workflow.key not in skip_keys], errors - - -def build_child_command( - args: argparse.Namespace, workflow: DocumentWorkflow, project_root: Path -) -> List[str]: - command = [ - sys.executable, - str(workflow.script), - "--project-root", - str(project_root), - workflow.path_arg, - str(project_root / workflow.filename), - "--run-mode", - args.run_mode, - "--print-json", - ] - if workflow.key == "roadmap": - if args.collect_source_tickets: - command.append("--collect-source-tickets") - if args.collect_github_issues: - command.append("--collect-github-issues") - if args.github_repo: - command.extend(["--github-repo", args.github_repo]) - return command - - -def run_child( - args: argparse.Namespace, workflow: DocumentWorkflow, project_root: Path -) -> Dict[str, Any]: - command = build_child_command(args, workflow, project_root) - proc = subprocess.run( - command, cwd=project_root, capture_output=True, text=True, check=False - ) - child: Dict[str, Any] = { - "key": workflow.key, - "label": workflow.label, - "path": str(project_root / workflow.filename), - "returncode": proc.returncode, - "report": {}, - "errors": [], - } - if proc.stderr.strip(): - child["stderr"] = proc.stderr.strip() - try: - child["report"] = json.loads(proc.stdout) - except json.JSONDecodeError: - child["errors"].append(f"{workflow.label} workflow did not return JSON output.") - if proc.stdout.strip(): - child["stdout"] = proc.stdout.strip() - if proc.returncode != 0: - child["errors"].append( - f"{workflow.label} workflow exited with status {proc.returncode}." - ) - return child - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def heading_present(text: str, heading: str) -> bool: - pattern = rf"(?im)^#+\s+{re.escape(heading)}\s*$" - return re.search(pattern, text) is not None - - -def responsibility_issue( - file: Path, issue_id: str, message: str, destination: str -) -> Dict[str, Any]: - return { - "issue_id": issue_id, - "severity": "warning", - "file": str(file), - "message": message, - "suggested_owner": destination, - } - - -def audit_responsibility_boundaries( - project_root: Path, selected: Sequence[DocumentWorkflow] -) -> List[Dict[str, Any]]: - selected_keys = {workflow.key for workflow in selected} - issues: List[Dict[str, Any]] = [] - - def maybe_read(key: str, filename: str) -> Tuple[Path, str]: - path = project_root / filename - if key not in selected_keys or not path.is_file(): - return path, "" - return path, read_text(path) - - readme_path, readme = maybe_read("readme", "README.md") - if readme: - for heading in ( - "Contribution Workflow", - "Review Expectations", - "Release Process", - ): - if heading_present(readme, heading): - issues.append( - responsibility_issue( - readme_path, - "readme-contains-maintainer-workflow", - f"README.md contains a `{heading}` section; keep README product-focused and link out.", - "CONTRIBUTING.md or maintainer docs", - ) - ) - - contributing_path, contributing = maybe_read("contributing", "CONTRIBUTING.md") - if contributing: - for heading in ("Product Principles", "Milestones", "Small Tickets"): - if heading_present(contributing, heading): - issues.append( - responsibility_issue( - contributing_path, - "contributing-contains-planning-content", - f"CONTRIBUTING.md contains a `{heading}` section; keep planning and backlog content in ROADMAP.md.", - "ROADMAP.md", - ) - ) - - agents_path, agents = maybe_read("agents", "AGENTS.md") - if agents: - for heading in ("Quick Start", "Usage", "Known Gaps"): - if heading_present(agents, heading): - destination = ( - "README.md" if heading in {"Quick Start", "Usage"} else "ROADMAP.md" - ) - issues.append( - responsibility_issue( - agents_path, - "agents-contains-non-agent-content", - f"AGENTS.md contains a `{heading}` section; keep agent guidance focused on durable operating rules.", - destination, - ) - ) - - roadmap_path, roadmap = maybe_read("roadmap", "ROADMAP.md") - if roadmap: - for heading in ("Contribution Workflow", "Local Setup", "Safety Boundaries"): - if heading_present(roadmap, heading): - destination = ( - "CONTRIBUTING.md" if heading != "Safety Boundaries" else "AGENTS.md" - ) - issues.append( - responsibility_issue( - roadmap_path, - "roadmap-contains-procedural-guidance", - f"ROADMAP.md contains a `{heading}` section; keep roadmap content focused on planning.", - destination, - ) - ) - return issues - - -def child_issue_count(child: Dict[str, Any]) -> int: - report = child.get("report") - if not isinstance(report, dict): - return len(child.get("errors", [])) - return sum( - len(report.get(key, [])) - for key in ISSUE_KEYS - if isinstance(report.get(key), list) - ) + len(child.get("errors", [])) - - -def child_fixes(child: Dict[str, Any]) -> List[Dict[str, Any]]: - report = child.get("report") - if not isinstance(report, dict): - return [] - fixes = report.get("fixes_applied", report.get("apply_actions", [])) - return fixes if isinstance(fixes, list) else [] - - -def child_post_fix_status(child: Dict[str, Any]) -> List[Dict[str, Any]]: - report = child.get("report") - if not isinstance(report, dict): - return [] - post_fix = report.get("post_fix_status", []) - return post_fix if isinstance(post_fix, list) else [] - - -def markdown_report(report: Dict[str, Any]) -> str: - lines: List[str] = [ - "# Project Docs Maintenance Report", - "", - "## Document Workflows", - "", - ] - for child in report["document_reports"]: - issue_count = child_issue_count(child) - lines.append( - f"- `{child['key']}`: exit `{child['returncode']}`, {issue_count} issue(s)" - ) - - lines.extend(["", "## Responsibility Issues", ""]) - if report["responsibility_issues"]: - lines.extend( - f"- `{issue['severity']}` `{issue['issue_id']}` in `{issue['file']}`: {issue['message']} Suggested owner: {issue['suggested_owner']}." - for issue in report["responsibility_issues"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Fixes Applied", ""]) - if report["fixes_applied"]: - for fix in report["fixes_applied"]: - action = fix.get("action", "unknown") - reason = fix.get("reason", "") - file = fix.get("file", "") - lines.append(f"- `{action}` in `{file}`: {reason}") - else: - lines.append("- None.") - - lines.extend(["", "## Errors", ""]) - if report["errors"]: - lines.extend(f"- {error}" for error in report["errors"]) - else: - lines.append("- None.") - return "\n".join(lines).rstrip() + "\n" - - -def write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def unresolved_issues(report: Dict[str, Any]) -> bool: - return bool( - report["responsibility_issues"] or report["errors"] or report["post_fix_status"] - ) or any(child_issue_count(child) for child in report["document_reports"]) - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - selected, selection_errors = select_workflows(args.include, args.skip, project_root) - report: Dict[str, Any] = { - "run_context": { - "project_root": str(project_root), - "run_mode": args.run_mode, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "collect_source_tickets": bool(args.collect_source_tickets), - "collect_github_issues": bool(args.collect_github_issues), - "github_repo": args.github_repo or "", - }, - "document_order": [workflow.key for workflow in selected], - "document_reports": [], - "responsibility_issues": [], - "fixes_applied": [], - "post_fix_status": [], - "errors": selection_errors, - } - if not project_root.is_dir(): - report["errors"].append( - f"Project root does not exist or is not a directory: {project_root}" - ) - return report, markdown_report(report) - - if not report["errors"]: - for workflow in selected: - child = run_child(args, workflow, project_root) - report["document_reports"].append(child) - report["fixes_applied"].extend(child_fixes(child)) - report["post_fix_status"].extend(child_post_fix_status(child)) - report["errors"].extend(child["errors"]) - report["responsibility_issues"] = audit_responsibility_boundaries( - project_root, selected - ) - - return report, markdown_report(report) - - -def main() -> int: - args = parse_args() - report, md = run_maintenance(args) - payload = json.dumps(report, indent=2, sort_keys=True) + "\n" - - if args.json_out: - write_text(Path(args.json_out), payload) - if args.md_out: - write_text(Path(args.md_out), md) - - if args.print_json: - sys.stdout.write(payload) - elif args.print_md: - sys.stdout.write(md) - else: - if not unresolved_issues(report): - sys.stdout.write("No findings.\n") - else: - sys.stdout.write(md) - - if report["errors"]: - return 1 - if args.fail_on_issues and unresolved_issues(report): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/maintain-project-repo/scripts/run_workflow.py b/skills/maintain-project-repo/scripts/run_workflow.py deleted file mode 100755 index dbb21ae8e..000000000 --- a/skills/maintain-project-repo/scripts/run_workflow.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Install or refresh repository tooling and canonical project documentation.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path -from typing import Any - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root") - parser.add_argument("--operation", choices=("install", "refresh", "report-only")) - parser.add_argument("--profile", choices=("generic", "xcode-workspace")) - parser.add_argument("--skip-github-workflow", action="store_true") - parser.add_argument("--dry-run", action="store_true") - return parser - - -def decode_payload(proc: subprocess.CompletedProcess[str], fallback: dict[str, Any]) -> dict[str, Any]: - if not proc.stdout.strip(): - return fallback - try: - payload = json.loads(proc.stdout) - except json.JSONDecodeError: - return { - **fallback, - "stdout": proc.stdout, - "stderr": proc.stderr, - } - return payload if isinstance(payload, dict) else fallback - - -def run_documentation(repo_root: str, run_mode: str) -> tuple[int, dict[str, Any], str]: - helper_path = Path(__file__).with_name("maintain_project_docs.py") - command = [ - sys.executable, - str(helper_path), - "--project-root", - repo_root, - "--run-mode", - run_mode, - "--print-json", - ] - proc = subprocess.run(command, capture_output=True, text=True, check=False) - fallback = { - "run_context": {"project_root": repo_root, "run_mode": run_mode}, - "document_order": [], - "document_reports": [], - "responsibility_issues": [], - "fixes_applied": [], - "post_fix_status": [], - "errors": ["The integrated documentation workflow did not return JSON output."], - } - return proc.returncode, decode_payload(proc, fallback), proc.stderr.strip() - - -def main() -> int: - args = build_parser().parse_args() - repo_root = str(Path(args.repo_root or ".").expanduser().resolve()) - operation = args.operation or "install" - profile = args.profile or "generic" - normalized_inputs = { - "repo_root": repo_root, - "operation": operation, - "profile": profile, - "skip_github_workflow": args.skip_github_workflow, - "dry_run": args.dry_run, - } - - helper_path = Path(__file__).with_name("install_maintain_project_repo.py") - command = [ - str(helper_path), - "--repo-root", - repo_root, - "--operation", - operation, - "--profile", - profile, - ] - if args.skip_github_workflow: - command.append("--skip-github-workflow") - if args.dry_run: - command.append("--dry-run") - - proc = subprocess.run(command, capture_output=True, text=True, check=False) - return_code = proc.returncode - payload = decode_payload(proc, { - "status": "failed", - "path_type": "primary", - "repo_root": repo_root, - "normalized_inputs": normalized_inputs, - "managed_files": [], - "actions": [], - "validation_result": None, - "stdout": proc.stdout, - "stderr": proc.stderr, - "next_step": "Fix the maintain-project-repo workflow error and rerun the workflow.", - }) - payload.setdefault("normalized_inputs", normalized_inputs) - if proc.returncode == 0: - documentation_mode = ( - "check-only" - if operation == "report-only" or args.dry_run - else "apply" - ) - docs_code, docs_payload, docs_stderr = run_documentation( - repo_root, documentation_mode - ) - payload["documentation"] = docs_payload - payload["documentation_result"] = ( - "checked (no writes)" - if documentation_mode == "check-only" - else "canonical documents created or refreshed" - ) - if docs_code != 0: - payload["status"] = "failed" - payload["documentation_result"] = "failed after toolkit update" - existing_stderr = str(payload.get("stderr", "")).strip() - details = docs_stderr or "The integrated documentation workflow failed." - payload["stderr"] = "\n".join( - part for part in (existing_stderr, details) if part - ) - payload["next_step"] = ( - "Fix the reported documentation workflow error and rerun " - "maintain-project-repo so tooling and canonical docs agree." - ) - return_code = 1 - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if return_code == 0 else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/maintain-project-roadmap/SKILL.md b/skills/maintain-project-roadmap/SKILL.md index 215d8de4f..120489851 100644 --- a/skills/maintain-project-roadmap/SKILL.md +++ b/skills/maintain-project-roadmap/SKILL.md @@ -1,150 +1,62 @@ --- name: maintain-project-roadmap -description: Maintain checklist-style ROADMAP.md files against a canonical base schema with deterministic check-only and bounded apply modes. Use for milestone planning, issue-sized tickets, TODO/FIXME imports, normalization, or targeted fixes. +description: Maintain ROADMAP.md as the planning member of the canonical four-document repository suite. --- # Maintain Project Roadmap -Maintain checklist-style `ROADMAP.md` files through one deterministic base-template workflow. - -This skill is the general template layer for roadmap maintenance. It defines the canonical shared checklist-roadmap contract that downstream language-, framework-, stack-, or repository-specific customization can adapt through explicit configuration instead of ad hoc structure drift. It also owns small planning tickets that are too small or too unplanned for a milestone, so ordinary bug-fix TODOs do not need a separate `TODO.md` surface by default. - -## Inputs - -- Required: `--project-root <path>` -- Required: `--run-mode <check-only|apply>` -- Optional: `--roadmap-path <path>` -- Optional: `--config <path>` -- Optional: `--collect-source-tickets` -- Optional: `--collect-github-issues` -- Optional: `--github-repo <owner/repo>` -- Optional: `--ticket-section <Small Tickets|Backlog Candidates|Milestone N: Tickets>` -- Optional: `--ticket-text <checklist item text>` -- Optional: `--ticket-state <open|done>` -- Optional: `--ticket-source <repo-relative source>` -- Optional: `--ticket-match <existing checklist item text>` -- Optional: `--allow-duplicate` - -## Workflow - -1. Validate the project root and resolve the target `ROADMAP.md`. -2. Load the canonical roadmap schema from the built-in template config, then merge any explicit customization override. -3. In `check-only`, audit title requirements, top-level section names and order, the required table of contents, milestone ordering, milestone subsection names, milestone status values, milestone progress consistency, small-ticket placement, checkbox syntax, legacy format, and root `TODO.md` files that still need migration into the canonical roadmap structure. -4. When requested, collect small-ticket candidates from source TODO/FIXME comments or open GitHub issues and report them under `small_ticket_candidates`. -5. In `apply`, keep edits bounded to the target `ROADMAP.md` while normalizing the roadmap into the configured canonical checklist structure. If source or GitHub ticket collection was requested, append new candidates to `Small Tickets` without rewriting source files. -6. If an explicit roadmap ticket mutation was requested, add or update one checklist item in `Small Tickets`, `Backlog Candidates`, or a milestone `Tickets` subsection. Dedupe by default, and use `--allow-duplicate` only when the duplicate is intentional. -7. Preserve useful preamble material before the first H2 when normalizing the structural contract around it. -8. Use the bundled roadmap template when bootstrapping a missing `ROADMAP.md`. -9. Re-run the same audit to confirm post-fix status. - -## Writing Expectations - -- `Vision` should describe the long-term outcome the roadmap is meant to deliver, not restate what the project already is. -- `Product Principles` should capture a small set of planning and delivery rules that shape roadmap decisions, not general branding or philosophy. -- `Milestone Progress` should stay a concise rollup of milestone names and statuses, not a second task-management surface. -- `Milestone > Status` should be one plain allowed status value. -- `Milestone > Scope` should describe boundary and intended outcome, not duplicate the ticket list. -- `Milestone > Tickets` should be the actionable checklist for work inside the milestone. -- `Milestone > Exit Criteria` should define what must be true before the milestone counts as complete. -- `Small Tickets` should hold issue-sized fixes, TODO/FIXME imports, and cleanup work that is not substantial enough for a milestone yet. Keep these as checklist items that can be linked to GitHub issues, source comments, or milestone tickets when the evidence exists. -- `Backlog Candidates` should hold plausible future work that is not yet committed to a milestone. -- `History` should record only notable roadmap changes such as milestone additions, scope cuts, resets, or major replans. -- A root `TODO.md` is a legacy planning surface once `ROADMAP.md` has `Small Tickets`. Report it as a migration-needed finding instead of treating it as a parallel canonical backlog. - -## Codex Subagent Fit - -When delegation is explicitly requested or authorized, follow `agent-engineering-skills:orchestrate-agent-work`. This skill is a good fit for read-heavy roadmap discovery before the main workflow edits or reports: checking one milestone family per worker, comparing roadmap claims against release notes, or gathering evidence from docs and issues for backlog triage. - -Keep `apply` edits in the main thread because this skill owns one target roadmap and must preserve one coherent planning structure. Ask workers for concise findings, candidate changes, and references instead of direct roadmap rewrites. - -## Small Ticket Collection - -- Use `--collect-source-tickets` to scan ordinary source and documentation files for TODO/FIXME comments and report candidate `Small Tickets` entries with repo-relative file and line references. -- Use `--collect-github-issues` to call `gh issue list` for open issues. Pass `--github-repo <owner/repo>` when the current checkout's GitHub remote is not the intended issue source. -- In `check-only`, collection is report-only and does not mutate files. -- In `apply`, collection appends new entries to `Small Tickets` in `ROADMAP.md`. It does not rewrite source comments yet; source comment rewrites need a separate explicit mode so code files are not changed as a side effect of roadmap normalization. - -## Explicit Ticket Mutation - -Use explicit ticket mutation when another agent, skill, report, or maintainer -workflow has one known checklist item to add or update in `ROADMAP.md`. - -Examples: - -```bash -scripts/maintain_project_roadmap.py \ - --project-root . \ - --run-mode apply \ - --ticket-section "Backlog Candidates" \ - --ticket-text "Add guarded roadmap apply support" \ - --ticket-source "docs/agents/roadmap-maintenance.md" -``` +## Purpose -```bash -scripts/maintain_project_roadmap.py \ - --project-root . \ - --run-mode apply \ - --ticket-section "Small Tickets" \ - --ticket-text "Add guarded roadmap apply support" \ - --ticket-state done -``` +Keep checklist-style `ROADMAP.md` milestones, tickets, progress, backlog, and +history structurally consistent while the entire canonical documentation suite +is maintained together. + +## Commands -```bash -scripts/maintain_project_roadmap.py \ - --project-root . \ - --run-mode apply \ - --ticket-section "Milestone 2: Tickets" \ - --ticket-text "Wire roadmap ticket mutation into the maintainer workflow" +The only documentation commands are: + +```text +just docs-check +just docs-apply ``` -Rules: +Both always process README, CONTRIBUTING, AGENTS, and ROADMAP. Ticket mutation, +source collection, and GitHub issue collection are not separate command modes; +the managed full-document pass owns deterministic roadmap normalization. -- Ticket mutation requires `--run-mode apply`. -- Ticket mutation requires both `--ticket-section` and `--ticket-text`. -- Supported sections are `Small Tickets`, `Backlog Candidates`, and `Milestone N: Tickets`. -- `--ticket-state open` writes `[ ]`; `--ticket-state done` writes `[x]`. -- `--ticket-source` must be repo-relative or inside the project root when passed as an absolute path. -- Existing matching checklist items are updated by default instead of duplicated. -- Use `--ticket-match` when the existing item text differs from the replacement text. -- Use `--allow-duplicate` only when an intentional duplicate checklist item is needed. +## Managed Contract -## Canonical Base Contract +- `assets/document.contract.json` fixes sections, milestone subsections, + allowed statuses, and a small fixed alias set. +- `assets/ROADMAP.template.md` supplies bootstrap and missing-section content. +- Repositories cannot customize status vocabulary, headings, aliases, order, + or automatic-fix policy. -The authoritative default shared roadmap structure lives in: +## ROADMAP Ownership -- `config/roadmap-customization.template.yaml` -- `assets/ROADMAP.template.md` +ROADMAP owns vision, product principles, milestone progress, milestone scope, +tickets, exit criteria, small tickets, backlog candidates, and notable planning +history. Setup and procedure belong to CONTRIBUTING or maintainer docs; safety +policy belongs to AGENTS. -Treat those two files as the source of truth for the canonical base schema and the canonical bootstrap document. Downstream plugins may extend or change that structure through explicit customization, but this base skill treats the required table of contents plus the configured checklist roadmap section block as hard-enforced. +Milestone Progress and the table of contents are regenerated from the canonical +milestone sections. Missing milestone subsections are added from the managed +template. Known status aliases normalize deterministically; unknown semantic +states remain blocking findings rather than being invented. -## Output Contract +## Deterministic Workflow -- Return Markdown plus JSON with: - - `run_context` - - `customization_state` - - `schema_contract` - - `findings` - - `small_ticket_candidates` - - `apply_actions` - - `errors` -- If there are no findings, no small-ticket candidates, no apply actions, and no errors, output exactly `No findings.` +Use `just docs-check` for the full no-write audit and `just docs-apply` for the +atomic, byte-idempotent four-document normalization transaction. ## Guardrails -- Never auto-commit, auto-push, or open a PR. -- Never invent roadmap status, milestone names, or ticket details that are not grounded in the existing file or the canonical template scaffolding. -- Never edit files other than the target `ROADMAP.md`. -- Never use explicit ticket mutation as a generic prose editor; it may only add or update one checklist item per run. -- Never rewrite source TODO/FIXME comments unless a future explicit source-rewrite mode is implemented and requested. -- Keep checklist-style `ROADMAP.md` as the canonical format. -- Treat legacy table-style roadmap layouts as migration sources, not as an alternate canonical output mode. -- Treat root `TODO.md` as a migration source, not as an alternate canonical output mode. Do not auto-delete or auto-flatten it; migrate useful entries into `ROADMAP.md` in a reviewed documentation pass. +- Never maintain or mutate ROADMAP independently of the full document suite. +- Never invent milestone status, scope, ticket details, or completion claims. +- Never add customization or alternate roadmap formats. +- Never commit, push, or open a pull request as part of documentation upkeep. ## References -- `agents/openai.yaml` -- `config/roadmap-customization.template.yaml` +- `assets/document.contract.json` - `assets/ROADMAP.template.md` -- `references/roadmap-automation-prompts.md` -- `references/roadmap-customization.md` -- `references/roadmap-config-schema.md` diff --git a/skills/maintain-project-roadmap/assets/document.contract.json b/skills/maintain-project-roadmap/assets/document.contract.json new file mode 100644 index 000000000..02d18d7ec --- /dev/null +++ b/skills/maintain-project-roadmap/assets/document.contract.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "document": "roadmap", + "targetFile": "ROADMAP.md", + "requireTableOfContents": true, + "preservePreamble": true, + "allowAdditionalSections": true, + "requiredSections": ["Vision", "Product Principles", "Milestone Progress", "Small Tickets", "Backlog Candidates", "History"], + "sectionOrder": ["Vision", "Product Principles", "Milestone Progress", "__MILESTONES__", "Small Tickets", "Backlog Candidates", "History"], + "requiredSubsections": { + "__MILESTONE__": ["Status", "Scope", "Tickets", "Exit Criteria"] + }, + "sectionAliases": { + "Product Principles": ["Product principles"], + "Small Tickets": ["Small tickets", "TODO", "Todo", "TODOs", "Fixes", "Bug Fixes"], + "Backlog Candidates": ["Backlog candidates"] + }, + "subsectionAliases": { + "Status": ["status"], + "Exit Criteria": ["Exit criteria"] + }, + "allowedStatuses": ["Planned", "In Progress", "Completed", "Blocked", "De-scoped"], + "statusAliases": { + "In Progress": ["Implementation Complete; Release Pending", "Release candidate"] + } +} diff --git a/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml b/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml deleted file mode 100644 index 6945ebb09..000000000 --- a/skills/maintain-project-roadmap/config/roadmap-customization.template.yaml +++ /dev/null @@ -1,70 +0,0 @@ -schemaVersion: 1 -isCustomized: false -profile: base -settings: - preservePreamble: true - allowAdditionalSections: true - statusValues: - - Planned - - In Progress - - Completed - - Blocked - - De-scoped - requiredSections: - - Vision - - Product Principles - - Milestone Progress - - Small Tickets - - Backlog Candidates - - History - sectionOrder: - - Vision - - Product Principles - - Milestone Progress - - __MILESTONES__ - - Small Tickets - - Backlog Candidates - - History - requiredMilestoneSubsections: - - Status - - Scope - - Tickets - - Exit Criteria - sectionAliases: - Product Principles: - - Product principles - Small Tickets: - - Small tickets - - TODO - - Todo - - TODOs - - Fixes - - Bug Fixes - Backlog Candidates: - - Backlog candidates - milestoneSubsectionAliases: - Status: - - status - Exit Criteria: - - Exit criteria - sectionTemplates: - Vision: | - - Describe the long-term outcome this roadmap is meant to deliver, not just what the project currently is. - Product Principles: | - - State the few planning and delivery rules that should shape roadmap decisions and tradeoffs. - Small Tickets: | - - [ ] Record issue-sized fixes, TODO/FIXME imports, and cleanup work that is too small or too unplanned for a milestone. - Backlog Candidates: | - - [ ] Record plausible future work that is not yet committed to a milestone. - History: | - - Initial roadmap scaffold created. - - Record only notable roadmap changes here, such as milestone additions, scope cuts, resets, or major replans. - milestoneSubsectionTemplates: - Status: | - Planned - Scope: | - - [ ] Describe the boundary and intended outcome of this milestone without turning Scope into a task list. - Tickets: | - - [ ] Add the first concrete implementation task for this milestone. - Exit Criteria: | - - [ ] Describe what must be true before this milestone counts as complete. diff --git a/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md b/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md deleted file mode 100644 index 57bfe40f0..000000000 --- a/skills/maintain-project-roadmap/references/roadmap-automation-prompts.md +++ /dev/null @@ -1,125 +0,0 @@ -# Roadmap Automation Prompt Templates - -Use this section order in this file: Suitability, App template, CLI template, Placeholders, Customization Points. - -## Suitability - -- Codex App: `Conditional` - useful for recurring checklist-roadmap audits, bounded updates, and legacy migrations that stay limited to `ROADMAP.md` -- Codex CLI: `Conditional` - useful for scripted check/apply workflows when roadmap edits stay limited to one file -- Source and GitHub ticket collection: `Conditional` - useful when a planning sweep should report or append TODO/FIXME comments and open GitHub issues as `Small Tickets` - -## Codex App Automation Prompt Template - -```markdown -Use $maintain-project-roadmap. - -Scope: -- Project root: <PROJECT_ROOT_ABS_PATH> -- Target file: <ROADMAP_PATH_DEFAULT_PROJECT_ROOT_ROADMAP_MD> -- Run mode: <UPDATE_MODE_CHECK_ONLY_OR_APPLY> - -Execution policy: -- Restrict all edits to <ROADMAP_PATH_DEFAULT_PROJECT_ROOT_ROADMAP_MD> only. -- Preserve useful roadmap content while normalizing it into the canonical checklist roadmap structure. -- Enforce the required table of contents. -- Enforce canonical top-level sections and configured milestone subsection headings. -- Ensure milestone progress matches the actual milestone sections, order, and milestone status values. -- Ensure checklist items use valid markdown checkbox syntax. -- Allow `[P]` only inside milestone `Tickets` subsections. -- If requested, collect source TODO/FIXME comments or open GitHub issues as `Small Tickets` candidates. -- If legacy table-style format is detected: - - In `apply` mode: migrate in-place to checklist standard while preserving useful milestone identity. - - In `check-only` mode: report migration required without editing. -- Never edit unrelated files. -- Never rewrite source TODO/FIXME comments. -- Never commit, push, or open PRs. - -Output contract: -- Report whether the roadmap matches the configured checklist contract. -- If updates were applied, summarize structural changes and why. -- If check-only, report required changes without editing. -- If ticket collection is requested, report `small_ticket_candidates` with source, title, and links. - -No-findings handling: -- If no updates are needed, output exactly `No findings.` and archive the run. -- Otherwise keep the run in inbox triage with a concise change summary. - -Failure handling: -- If roadmap file is missing in check-only mode, report the missing required path. -- If apply mode is blocked by permissions or sandboxing, report the minimum required access. -``` - -## Codex CLI Automation Prompt Template (codex exec) - -### Variant A: Check-only - -- Recommended sandbox: `read-only` - -Prompt template: - -```markdown -Use $maintain-project-roadmap. - -Check roadmap consistency at <ROADMAP_PATH_DEFAULT_PROJECT_ROOT_ROADMAP_MD> for project <PROJECT_ROOT_ABS_PATH>. -Do not edit files. - -Validate: -- the roadmap has a title and the required table of contents -- canonical top-level sections are present and ordered correctly -- milestone sections are ordered deterministically -- each milestone includes the configured required subsections -- milestone statuses use allowed status values -- milestone progress matches the actual milestone headings and statuses -- checklist items use valid markdown checkbox syntax -- `[P]` appears only in milestone `Tickets` subsections -- if legacy table-style sections are present, report that migration is required -- if requested, source TODO/FIXME comments or open GitHub issues that could become `Small Tickets` - -If no updates are needed, output exactly `No findings.`. -Otherwise output a concise required-changes report. -``` - -### Variant B: Apply bounded updates - -- Recommended sandbox: `workspace-write` - -Prompt template: - -```markdown -Use $maintain-project-roadmap. - -Apply bounded updates to <ROADMAP_PATH_DEFAULT_PROJECT_ROOT_ROADMAP_MD> for project <PROJECT_ROOT_ABS_PATH>. -Edit this file only. - -Enforce the configured checklist roadmap structure: -- required table of contents -- canonical top-level sections -- milestone sections in deterministic order -- required milestone subsections -- milestone progress aligned with milestone sections and statuses -- valid checkbox syntax - -If ticket collection is requested, append new source TODO/FIXME or GitHub issue candidates to `Small Tickets` in ROADMAP.md without editing source files. -If legacy table-style format is present, migrate in-place using the canonical checklist template as the target structure. -Keep edits minimal, deterministic, and grounded in the existing roadmap plus bundled scaffold wording. -Never edit other files. -Never commit or push. - -If no updates are needed, output exactly `No findings.`. -If blocked by permissions or sandboxing, report the minimum required access. -``` - -## Placeholders - -- `<PROJECT_ROOT_ABS_PATH>`: absolute project path -- `<ROADMAP_PATH_DEFAULT_PROJECT_ROOT_ROADMAP_MD>`: absolute path to `ROADMAP.md` -- `<UPDATE_MODE_CHECK_ONLY_OR_APPLY>`: `check-only` or `apply` -- Optional ticket collection flags: `--collect-source-tickets`, `--collect-github-issues`, and `--github-repo <owner/repo>` - -## Customization Points - -- top-level section set and ordering -- milestone subsection set and ordering -- heading alias migrations -- scaffolding text for base sections and milestone subsections -- additional-section preservation policy diff --git a/skills/maintain-project-roadmap/references/roadmap-config-schema.md b/skills/maintain-project-roadmap/references/roadmap-config-schema.md deleted file mode 100644 index fc209220e..000000000 --- a/skills/maintain-project-roadmap/references/roadmap-config-schema.md +++ /dev/null @@ -1,37 +0,0 @@ -# Roadmap Configuration Schema - -Persistent roadmap customization for `maintain-project-roadmap` is defined in: - -- Template defaults: `config/roadmap-customization.template.yaml` -- User overrides: `config/roadmap-customization.yaml` - -Checklist roadmap mode is canonical. - -## Top-level fields - -- `schemaVersion`: integer schema version (`1`) -- `isCustomized`: `true` when user overrides exist in `config/roadmap-customization.yaml` -- `profile`: short profile label such as `base`, `team-delivery`, or `quarterly` -- `settings`: roadmap behavior and structure controls - -## `settings` fields - -- `preservePreamble`: whether to preserve preamble content beneath the title before the first H2 -- `allowAdditionalSections`: whether non-canonical top-level sections are preserved after the canonical roadmap block -- `statusValues`: allowed milestone status values used for milestone `Status` subsections and milestone-progress rollups -- `requiredSections`: required non-milestone H2 sections -- `sectionOrder`: canonical roadmap order, including the milestone slot marker `__MILESTONES__` -- `requiredMilestoneSubsections`: required H3 subsections inside every milestone -- `sectionAliases`: top-level heading aliases migrated to canonical names during apply -- `milestoneSubsectionAliases`: milestone subsection aliases migrated to canonical names during apply -- `sectionTemplates`: default body scaffolding for required top-level sections -- `milestoneSubsectionTemplates`: default body scaffolding for required milestone subsections - -Base interpretation notes: - -- `Milestone Progress` should summarize milestone names plus statuses only. -- `Scope` should stay outcome- and boundary-oriented. -- `Small Tickets` should hold issue-sized fixes, TODO/FIXME imports, and cleanup work that is too small or too unplanned for a milestone. -- `Tickets` should carry actionable checklist work. -- `Backlog Candidates` should hold plausible future work that is not yet committed to a milestone or small-ticket item. -- `History` should stay high-signal and record only notable roadmap changes. diff --git a/skills/maintain-project-roadmap/references/roadmap-customization.md b/skills/maintain-project-roadmap/references/roadmap-customization.md deleted file mode 100644 index 896f7cae8..000000000 --- a/skills/maintain-project-roadmap/references/roadmap-customization.md +++ /dev/null @@ -1,66 +0,0 @@ -# Roadmap Customization Guide - -## Canonical Base Contract - -`maintain-project-roadmap` treats checklist-style `ROADMAP.md` as canonical. - -The default shared roadmap structure is defined in: - -- `config/roadmap-customization.template.yaml` -- `assets/ROADMAP.template.md` - -That base contract requires: - -- a top-level `# ...` title -- `## Table of Contents` -- `## Vision` -- `## Product Principles` -- `## Milestone Progress` -- one or more milestone sections named `## Milestone N: Name` -- `## Backlog Candidates` -- `## History` - -Each milestone must include: - -- `### Status` -- `### Scope` -- `### Tickets` -- `### Exit Criteria` - -Interpretation guidance: - -- `Vision` is for the long-term outcome, not a project description. -- `Product Principles` is for roadmap decision rules, not general product philosophy. -- `Milestone Progress` is a status rollup, not a second checklist surface. -- `Status` is one allowed value only. -- `Scope` defines the milestone boundary and intended outcome, not the implementation task inventory. -- `Tickets` is the actionable checklist surface. -- `Exit Criteria` defines what must be true for completion. -- `Backlog Candidates` is for uncommitted future work. -- `History` is for notable roadmap changes, not every minor edit. - -## Customization Model - -Downstream plugins may customize roadmap structure through `config/roadmap-customization.yaml`. - -The intended customization surface is structural and explicit: - -- required top-level sections -- top-level section order -- required milestone subsections -- heading aliases for migration -- section and milestone-subsection scaffolding -- whether additional non-canonical sections are preserved - -## Legacy Migration - -Legacy roadmap layouts such as `Current Milestone` sections or milestone tables are not canonical output modes. - -Runtime policy: - -- in `check-only`, report legacy format as a migration finding -- in `apply`, migrate legacy layout into checklist-roadmap structure -- preserve useful milestone identity where possible -- use canonical template scaffolding when legacy content is incomplete -- when a root `TODO.md` exists beside a canonical `ROADMAP.md`, report it as a migration-needed finding -- do not automatically delete or flatten root `TODO.md`; move useful entries into `ROADMAP.md` milestones, `Small Tickets`, or `Backlog Candidates` in a reviewed documentation pass diff --git a/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py b/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py deleted file mode 100644 index b1228cd66..000000000 --- a/skills/maintain-project-roadmap/scripts/maintain_project_roadmap.py +++ /dev/null @@ -1,1647 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Checklist ROADMAP maintainer with deterministic check-only and apply modes.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import yaml - -H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) -H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) -H3_RE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE) -CHECKBOX_RE = re.compile(r"^\s*-\s+\[( |x)\]\s+.+$") -ANY_CHECKBOX_RE = re.compile(r"^\s*-\s+\[[^\]]\]\s+.+$") -MILESTONE_HEADING_RE = re.compile(r"^Milestone\s+(\d+)\s*:\s*(.+?)\s*$") -PROGRESS_LINE_RE = re.compile(r"^\s*-\s+Milestone\s+(\d+)\s*:\s*(.+?)\s+-\s+(.+?)\s*$") -PLACEHOLDER_PATTERNS = [ - re.compile(r"\bTODO\b", re.IGNORECASE), - re.compile(r"\bTBD\b", re.IGNORECASE), - re.compile(r"<[^>]+>"), -] -SOURCE_TICKET_RE = re.compile( - r"(?:^|\s)(?://|#warning\(?\"?|#|/\*|<!--|--)\s*" - r"(?P<kind>TODO|FIXME)\b(?:\s*[:\-]\s*|\s+)(?P<body>.+?)" - r"(?:\*/|-->|\"\)?\s*)?$", - re.IGNORECASE, -) -SOURCE_TICKET_EXTENSIONS = { - ".c", - ".cc", - ".cpp", - ".cs", - ".css", - ".go", - ".h", - ".hpp", - ".html", - ".java", - ".js", - ".jsx", - ".kt", - ".m", - ".mm", - ".py", - ".rb", - ".rs", - ".sh", - ".swift", - ".ts", - ".tsx", -} -IGNORED_SOURCE_PARTS = { - ".build", - ".git", - ".pytest_cache", - ".ruff_cache", - ".venv", - "__pycache__", - "node_modules", -} - -MILESTONE_SLOT = "__MILESTONES__" - - -@dataclass -class Finding: - finding_id: str - category: str - severity: str - message: str - file: str - auto_fixable: bool - - def to_dict(self) -> Dict[str, Any]: - return { - "finding_id": self.finding_id, - "category": self.category, - "severity": self.severity, - "message": self.message, - "file": self.file, - "auto_fixable": self.auto_fixable, - } - - -@dataclass -class ApplyAction: - action: str - reason: str - file: str - - def to_dict(self) -> Dict[str, str]: - return {"action": self.action, "reason": self.reason, "file": self.file} - - -@dataclass -class SmallTicketCandidate: - source: str - kind: str - title: str - detail: str - file: str = "" - line: Optional[int] = None - url: str = "" - number: Optional[int] = None - - def identity(self) -> str: - if self.url: - return self.url - if self.file and self.line is not None: - return f"{self.file}#L{self.line}" - return f"{self.source}:{self.kind}:{self.title}" - - def to_dict(self) -> Dict[str, Any]: - return { - "source": self.source, - "kind": self.kind, - "title": self.title, - "detail": self.detail, - "file": self.file, - "line": self.line, - "url": self.url, - "number": self.number, - "identity": self.identity(), - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Audit and optionally apply bounded checklist ROADMAP maintenance from a hard-enforced schema." - ) - parser.add_argument("--project-root", required=True, help="Absolute project root path") - parser.add_argument("--roadmap-path", help="Optional roadmap path (default: <project-root>/ROADMAP.md)") - parser.add_argument("--run-mode", required=True, choices=["check-only", "apply"], help="Execution mode") - parser.add_argument("--config", help="Optional roadmap config override") - parser.add_argument("--json-out", help="Write JSON report path") - parser.add_argument("--md-out", help="Write markdown report path") - parser.add_argument("--print-json", action="store_true", help="Print JSON report") - parser.add_argument("--print-md", action="store_true", help="Print markdown report") - parser.add_argument("--fail-on-issues", action="store_true", help="Exit non-zero when findings remain") - parser.add_argument( - "--collect-source-tickets", - action="store_true", - help="Scan source files for TODO/FIXME comments and report or append Small Tickets entries.", - ) - parser.add_argument( - "--collect-github-issues", - action="store_true", - help="Collect open GitHub issues with gh and report or append Small Tickets entries.", - ) - parser.add_argument( - "--github-repo", - help="Optional GitHub OWNER/REPO override for --collect-github-issues.", - ) - parser.add_argument( - "--ticket-section", - help=( - "Optional roadmap checklist target. Use 'Small Tickets', 'Backlog Candidates', " - "or 'Milestone N: Tickets'. Requires --run-mode apply and --ticket-text." - ), - ) - parser.add_argument("--ticket-text", help="Optional roadmap checklist item text to add or update.") - parser.add_argument( - "--ticket-state", - choices=["open", "done"], - default="open", - help="Checklist state for --ticket-text. Defaults to open.", - ) - parser.add_argument( - "--ticket-source", - help="Optional repo-relative source reference appended to a new checklist item.", - ) - parser.add_argument( - "--ticket-match", - help="Optional existing checklist item text to update instead of matching --ticket-text.", - ) - parser.add_argument( - "--allow-duplicate", - action="store_true", - help="Append --ticket-text even when a matching checklist item already exists.", - ) - return parser.parse_args() - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8", errors="ignore") - - -def write_text(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - - -def relative_path(project_root: Path, path: Path) -> str: - try: - return path.relative_to(project_root).as_posix() - except ValueError: - return path.as_posix() - - -def normalize_whitespace(text: str) -> str: - return text.strip() + "\n" - - -def read_yaml(path: Path) -> Dict[str, Any]: - data = yaml.safe_load(read_text(path)) - return data if isinstance(data, dict) else {} - - -def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - merged: Dict[str, Any] = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def load_config(project_root: Path, config_override: Optional[str]) -> Dict[str, Any]: - default_path = Path(__file__).resolve().parents[1] / "config" / "roadmap-customization.template.yaml" - default_config = read_yaml(default_path) - loaded_path = default_path - - if config_override: - override_path = Path(config_override).expanduser().resolve() - merged = deep_merge(default_config, read_yaml(override_path)) - merged["isCustomized"] = True - merged["configPath"] = str(override_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - project_config = project_root / "config" / "roadmap-customization.yaml" - if project_config.is_file(): - loaded_path = project_config - merged = deep_merge(default_config, read_yaml(project_config)) - merged["isCustomized"] = True - else: - merged = dict(default_config) - merged["isCustomized"] = bool(default_config.get("isCustomized", False)) - - merged["configPath"] = str(loaded_path) - merged["defaultConfigPath"] = str(default_path) - return merged - - -def config_settings(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config.get("settings", {}) - return settings if isinstance(settings, dict) else {} - - -def required_sections(settings: Dict[str, Any]) -> List[str]: - value = settings.get("requiredSections", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def section_order(settings: Dict[str, Any]) -> List[str]: - value = settings.get("sectionOrder", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def required_milestone_subsections(settings: Dict[str, Any]) -> List[str]: - value = settings.get("requiredMilestoneSubsections", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def status_values(settings: Dict[str, Any]) -> List[str]: - value = settings.get("statusValues", []) - return [str(item) for item in value] if isinstance(value, list) else [] - - -def section_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("sectionAliases", {}) - if not isinstance(raw, dict): - return {} - return {str(key): [str(item) for item in value] for key, value in raw.items() if isinstance(value, list)} - - -def subsection_aliases(settings: Dict[str, Any]) -> Dict[str, List[str]]: - raw = settings.get("milestoneSubsectionAliases", {}) - if not isinstance(raw, dict): - return {} - return {str(key): [str(item) for item in value] for key, value in raw.items() if isinstance(value, list)} - - -def section_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("sectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def milestone_subsection_templates(settings: Dict[str, Any]) -> Dict[str, str]: - raw = settings.get("milestoneSubsectionTemplates", {}) - if not isinstance(raw, dict): - return {} - return {str(key): str(value).strip() for key, value in raw.items()} - - -def allow_additional_sections(settings: Dict[str, Any]) -> bool: - return bool(settings.get("allowAdditionalSections", True)) - - -def preserve_preamble(settings: Dict[str, Any]) -> bool: - return bool(settings.get("preservePreamble", True)) - - -def slugify_heading(heading: str) -> str: - slug = heading.lower().strip() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"\s+", "-", slug) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def build_toc(headings: Sequence[str]) -> str: - return "\n".join(f"- [{heading}](#{slugify_heading(heading)})" for heading in headings) - - -def toc_entries(body: str) -> List[str]: - entries: List[str] = [] - for line in body.splitlines(): - match = re.match(r"^\s*-\s+\[(.+?)\]\(#(.+?)\)\s*$", line.strip()) - if match: - entries.append(match.group(1).strip()) - return entries - - -def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H2_RE.finditer(text)) - if not matches: - return text.strip(), [] - - preamble = text[: matches[0].start()].rstrip() - sections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) - heading = match.group(1).strip() - body = text[start:end].strip("\n") - sections.append((heading, body)) - return preamble, sections - - -def split_subsections(body: str) -> Tuple[str, List[Tuple[str, str]]]: - matches = list(H3_RE.finditer(body)) - if not matches: - return body.strip(), [] - - preamble = body[: matches[0].start()].strip() - subsections: List[Tuple[str, str]] = [] - for idx, match in enumerate(matches): - start = match.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body) - heading = match.group(1).strip() - subsection_body = body[start:end].strip("\n") - subsections.append((heading, subsection_body)) - return preamble, subsections - - -def section_map(sections: Sequence[Tuple[str, str]]) -> Dict[str, str]: - return {heading: body for heading, body in sections} - - -def parse_title(preamble: str) -> Tuple[Optional[str], List[str]]: - lines = [line.rstrip() for line in preamble.splitlines()] - title: Optional[str] = None - extras: List[str] = [] - title_index: Optional[int] = None - - for idx, line in enumerate(lines): - if line.startswith("# "): - title = line[2:].strip() - title_index = idx - break - - if title_index is None: - return None, lines - - for idx, line in enumerate(lines): - if idx == title_index: - continue - extras.append(line) - return title, extras - - -def collapse_blank_lines(lines: Sequence[str]) -> List[str]: - collapsed: List[str] = [] - previous_blank = False - for line in lines: - blank = line.strip() == "" - if blank and previous_blank: - continue - collapsed.append(line) - previous_blank = blank - while collapsed and collapsed[0].strip() == "": - collapsed.pop(0) - while collapsed and collapsed[-1].strip() == "": - collapsed.pop() - return collapsed - - -def normalize_preamble(preamble: str, keep_extras: bool) -> str: - title, extras = parse_title(preamble) - normalized_title = title or "Project Roadmap" - - lines = [f"# {normalized_title}"] - if keep_extras: - extra_lines = collapse_blank_lines(extras) - if extra_lines: - lines.extend(["", *extra_lines]) - return "\n".join(lines).strip() - - -def is_milestone_heading(heading: str) -> bool: - return MILESTONE_HEADING_RE.match(heading) is not None - - -def parse_milestone_heading(heading: str) -> Optional[Tuple[int, str]]: - match = MILESTONE_HEADING_RE.match(heading) - if not match: - return None - return int(match.group(1)), match.group(2).strip() - - -def parse_progress(body: str) -> Dict[int, Tuple[str, str]]: - progress: Dict[int, Tuple[str, str]] = {} - for line in body.splitlines(): - match = PROGRESS_LINE_RE.match(line) - if match: - progress[int(match.group(1))] = (match.group(2).strip(), match.group(3).strip()) - return progress - - -def alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - aliases = section_aliases(settings) - reverse: Dict[str, str] = {} - for canonical, names in aliases.items(): - for alias in names: - reverse[alias] = canonical - return reverse - - -def subsection_alias_lookup(settings: Dict[str, Any]) -> Dict[str, str]: - aliases = subsection_aliases(settings) - reverse: Dict[str, str] = {} - for canonical, names in aliases.items(): - for alias in names: - reverse[alias] = canonical - return reverse - - -def render_template_bootstrap() -> str: - template_path = Path(__file__).resolve().parents[1] / "assets" / "ROADMAP.template.md" - return normalize_whitespace(read_text(template_path)) - - -def has_legacy_format(text: str) -> bool: - if re.search(r"^##\s+Current Milestone\s*$", text, flags=re.MULTILINE): - return True - if re.search(r"^##\s+Milestones\s*$", text, flags=re.MULTILINE) and "|" in text: - return True - if re.search(r"\|\s*Milestone\s*\|", text, flags=re.IGNORECASE): - return True - return False - - -def is_ignored_source_path(path: Path) -> bool: - return any(part in IGNORED_SOURCE_PARTS for part in path.parts) - - -def source_ticket_title(body: str) -> str: - normalized = re.sub(r"\s+", " ", body).strip() - normalized = normalized.strip("*/#- ") - if len(normalized) <= 90: - return normalized - return normalized[:87].rstrip() + "..." - - -def collect_source_ticket_candidates(project_root: Path) -> List[SmallTicketCandidate]: - candidates: List[SmallTicketCandidate] = [] - for path in sorted(project_root.rglob("*")): - if not path.is_file() or is_ignored_source_path(path): - continue - if path.suffix.lower() not in SOURCE_TICKET_EXTENSIONS: - continue - rel_path = relative_path(project_root, path) - if rel_path == "ROADMAP.md": - continue - try: - lines = read_text(path).splitlines() - except OSError: - continue - for line_number, line in enumerate(lines, start=1): - match = SOURCE_TICKET_RE.search(line) - if not match: - continue - body = match.group("body").strip() - if not body or re.fullmatch(r"(TODO|FIXME)-\d+", body, flags=re.IGNORECASE): - continue - kind = match.group("kind").upper() - candidates.append( - SmallTicketCandidate( - source="source", - kind=kind, - title=source_ticket_title(body), - detail=body, - file=rel_path, - line=line_number, - ) - ) - return candidates - - -def run_gh_issue_list(project_root: Path, github_repo: Optional[str]) -> subprocess.CompletedProcess[str]: - args = [ - "gh", - "issue", - "list", - "--state", - "open", - "--limit", - "100", - "--json", - "number,title,url,labels", - ] - if github_repo: - args.extend(["--repo", github_repo]) - return subprocess.run(args, cwd=project_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - -def collect_github_issue_candidates(project_root: Path, github_repo: Optional[str]) -> Tuple[List[SmallTicketCandidate], List[str]]: - result = run_gh_issue_list(project_root, github_repo) - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "gh issue list failed without output" - return [], [f"GitHub issue collection failed: {detail}"] - try: - issues = json.loads(result.stdout or "[]") - except json.JSONDecodeError as error: - return [], [f"GitHub issue collection returned invalid JSON: {error}"] - if not isinstance(issues, list): - return [], ["GitHub issue collection returned an unexpected JSON shape."] - - candidates: List[SmallTicketCandidate] = [] - for issue in issues: - if not isinstance(issue, dict): - continue - title = str(issue.get("title", "")).strip() - url = str(issue.get("url", "")).strip() - number_value = issue.get("number") - number = number_value if isinstance(number_value, int) else None - if not title or not url or number is None: - continue - labels = issue.get("labels", []) - label_names = [ - str(label.get("name", "")).strip() - for label in labels - if isinstance(label, dict) and str(label.get("name", "")).strip() - ] - candidates.append( - SmallTicketCandidate( - source="github", - kind="GitHub Issue", - title=title, - detail=", ".join(label_names), - url=url, - number=number, - ) - ) - return candidates, [] - - -def parse_legacy_milestones(text: str) -> List[Tuple[int, str, str]]: - rows: List[Tuple[int, str, str]] = [] - lines = text.splitlines() - in_table = False - for line in lines: - if re.match(r"^\|\s*Milestone\s*\|", line, flags=re.IGNORECASE): - in_table = True - continue - if in_table and re.match(r"^\|\s*[-:]+\s*\|", line): - continue - if in_table and line.strip().startswith("|"): - cols = [c.strip() for c in line.strip().strip("|").split("|")] - if len(cols) >= 2: - name = cols[0] - status = cols[1] - match = re.search(r"(\d+)", name) - idx = int(match.group(1)) if match else len(rows) - title = re.sub(r"^Milestone\s*\d+\s*[:\-]?\s*", "", name, flags=re.IGNORECASE).strip() or name - rows.append((idx, title, status)) - elif in_table and line.strip() == "": - in_table = False - return sorted(rows, key=lambda item: item[0]) - - -def build_migrated_from_legacy(text: str, settings: Dict[str, Any]) -> str: - rows = parse_legacy_milestones(text) - if not rows: - rows = [(0, "Foundation", "Planned")] - - section_template_map = section_templates(settings) - subsection_template_map = milestone_subsection_templates(settings) - required = required_sections(settings) - order = section_order(settings) - milestone_children = required_milestone_subsections(settings) - - section_bodies: Dict[str, str] = { - "Vision": "- Preserve the long-term project direction while migrating this roadmap into checklist format.", - "Product Principles": "- Keep roadmap updates checklist-based, reviewable, and tied to real delivery.", - "Small Tickets": section_template_map.get("Small Tickets", ""), - "Backlog Candidates": section_template_map.get("Backlog Candidates", ""), - } - - milestones: List[Tuple[int, str, str]] = [] - for idx, title, status in rows: - lines: List[str] = [] - for child in milestone_children: - template = subsection_template_map.get(child, "") - if child == "Status": - template = status.strip() or template - elif child == "Scope": - template = f"- [ ] Preserve or restate the milestone scope from the legacy roadmap entry ({status})." - elif child == "Tickets": - template = "- [ ] Reconcile legacy milestone work into explicit checklist tickets." - elif child == "Exit Criteria": - template = "- [ ] Confirm this migrated milestone is complete, current, and internally consistent." - lines.extend([f"### {child}", "", template.strip(), ""]) - milestones.append((idx, title, "\n".join(lines).strip())) - - progress_lines = [f"- Milestone {idx}: {title} - {status.strip() or 'Planned'}" for idx, title, status in rows] - section_bodies["Milestone Progress"] = "\n".join(progress_lines).strip() - - return render_document( - title="Project Roadmap", - preamble_lines=[], - ordered_section_bodies=section_bodies, - milestones=milestones, - extra_sections=[], - order=order, - required=required, - allow_additional=allow_additional_sections(settings), - ) - - -def normalize_milestone_subsection_body(body: str, subsection_name: str) -> str: - normalized_lines: List[str] = [] - for line in body.splitlines(): - fixed = re.sub(r"^\s*-\s+\[(X)\]\s+", "- [x] ", line) - if "[P]" in fixed and subsection_name != "Tickets": - fixed = fixed.replace("[P]", "").replace(" ", " ").rstrip() - normalized_lines.append(fixed) - return "\n".join(normalized_lines).strip() - - -def render_milestone_body(existing_body: str, settings: Dict[str, Any]) -> str: - required_children = required_milestone_subsections(settings) - template_map = milestone_subsection_templates(settings) - alias_map = subsection_alias_lookup(settings) - _preamble, subsections = split_subsections(existing_body) - canonical_lookup: Dict[str, str] = {} - - for name, body in subsections: - canonical = alias_map.get(name, name) - canonical_lookup[canonical] = body - - lines: List[str] = [] - for idx, child in enumerate(required_children): - child_body = canonical_lookup.get(child, "").strip() or template_map.get(child, "") - child_body = normalize_milestone_subsection_body(child_body, child) - lines.extend([f"### {child}", "", child_body.strip()]) - if idx < len(required_children) - 1: - lines.append("") - - extras = [(name, body) for name, body in subsections if alias_map.get(name, name) not in set(required_children)] - if extras: - lines.append("") - for idx, (name, body) in enumerate(extras): - lines.extend([f"### {name}", "", body.strip()]) - if idx < len(extras) - 1: - lines.append("") - - return "\n".join(lines).strip() - - -def render_document( - title: str, - preamble_lines: Sequence[str], - ordered_section_bodies: Dict[str, str], - milestones: Sequence[Tuple[int, str, str]], - extra_sections: Sequence[Tuple[str, str]], - order: Sequence[str], - required: Sequence[str], - allow_additional: bool, -) -> str: - rendered_lines: List[str] = [f"# {title}"] - if preamble_lines: - rendered_lines.extend(["", *preamble_lines]) - - toc_headings: List[str] = [] - for item in order: - if item == MILESTONE_SLOT: - toc_headings.extend(f"Milestone {idx}: {name}" for idx, name, _body in milestones) - else: - toc_headings.append(item) - if allow_additional: - toc_headings.extend(heading for heading, _body in extra_sections) - - rendered_lines.extend(["", "## Table of Contents", "", build_toc(toc_headings)]) - - for item in order: - if item == MILESTONE_SLOT: - for idx, name, body in milestones: - rendered_lines.extend(["", f"## Milestone {idx}: {name}", "", body.strip()]) - continue - body = ordered_section_bodies.get(item, "").strip() - rendered_lines.extend(["", f"## {item}", "", body]) - - if allow_additional: - for heading, body in extra_sections: - rendered_lines.extend(["", f"## {heading}", "", body.strip()]) - - return normalize_whitespace("\n".join(rendered_lines)) - - -def small_ticket_line(candidate: SmallTicketCandidate) -> str: - if candidate.source == "github" and candidate.url and candidate.number is not None: - return f"- [ ] GitHub #{candidate.number}: {candidate.title} ([#{candidate.number}]({candidate.url}))" - if candidate.file and candidate.line is not None: - file_link = f"{candidate.file}#L{candidate.line}" - return f"- [ ] {candidate.kind}: {candidate.title} ([{candidate.file}:{candidate.line}]({file_link}))" - return f"- [ ] {candidate.kind}: {candidate.title}" - - -def existing_small_ticket_body(roadmap_text: str) -> str: - _preamble, sections = split_sections(roadmap_text) - return section_map(sections).get("Small Tickets", "") - - -def filter_new_small_ticket_candidates(roadmap_text: str, candidates: Sequence[SmallTicketCandidate]) -> List[SmallTicketCandidate]: - existing_text = roadmap_text - existing_small_tickets = existing_small_ticket_body(roadmap_text) - new_candidates: List[SmallTicketCandidate] = [] - for candidate in candidates: - identity = candidate.identity() - if identity in existing_text: - continue - if small_ticket_line(candidate) in existing_small_tickets: - continue - new_candidates.append(candidate) - return new_candidates - - -def append_small_ticket_candidates(roadmap_text: str, candidates: Sequence[SmallTicketCandidate]) -> Tuple[str, int]: - if not candidates: - return roadmap_text, 0 - preamble, sections = split_sections(roadmap_text) - updated_sections: List[Tuple[str, str]] = [] - inserted = 0 - found = False - for heading, body in sections: - if heading != "Small Tickets": - updated_sections.append((heading, body)) - continue - found = True - lines = body.rstrip().splitlines() if body.strip() else [] - if lines and lines[-1].strip(): - lines.append("") - for candidate in candidates: - lines.append(small_ticket_line(candidate)) - inserted += 1 - updated_sections.append((heading, "\n".join(lines).strip())) - if not found: - return roadmap_text, 0 - - rendered = preamble.strip() - for heading, body in updated_sections: - rendered += f"\n\n## {heading}\n\n{body.strip()}" - return normalize_whitespace(rendered), inserted - - -def normalize_ticket_text(text: str) -> str: - normalized = re.sub(r"^\s*-\s+\[[ xX]\]\s+", "", text).strip() - normalized = re.sub(r"\s+\([^)]*\)\s*$", "", normalized).strip() - normalized = re.sub(r"\s+", " ", normalized) - return normalized - - -def roadmap_ticket_line(text: str, state: str, source: str = "") -> str: - checkbox = "x" if state == "done" else " " - line = f"- [{checkbox}] {normalize_ticket_text(text)}" - if source: - line += f" ({source})" - return line - - -def render_sections(preamble: str, sections: Sequence[Tuple[str, str]]) -> str: - rendered = preamble.strip() - for heading, body in sections: - rendered += f"\n\n## {heading}\n\n{body.strip()}" - return normalize_whitespace(rendered) - - -def parse_ticket_section(section: str) -> Tuple[str, Optional[int], str]: - normalized = section.strip() - milestone_match = re.fullmatch( - r"Milestone\s+(\d+)(?::\s*(?:Tickets)?)?", - normalized, - flags=re.IGNORECASE, - ) - if milestone_match: - return "milestone", int(milestone_match.group(1)), "Tickets" - - milestone_tickets_match = re.fullmatch( - r"Milestone\s+(\d+)\s*:\s*Tickets", - normalized, - flags=re.IGNORECASE, - ) - if milestone_tickets_match: - return "milestone", int(milestone_tickets_match.group(1)), "Tickets" - - if normalized in {"Small Tickets", "Backlog Candidates"}: - return "top-level", None, normalized - - return "unknown", None, normalized - - -def mutate_checklist_body( - body: str, - *, - ticket_text: str, - ticket_state: str, - ticket_source: str, - ticket_match: Optional[str], - allow_duplicate: bool, -) -> Tuple[str, str]: - desired_text = normalize_ticket_text(ticket_text) - match_text = normalize_ticket_text(ticket_match or ticket_text) - new_line = roadmap_ticket_line(desired_text, ticket_state, ticket_source) - lines = body.rstrip().splitlines() if body.strip() else [] - - if not allow_duplicate: - for index, line in enumerate(lines): - if not CHECKBOX_RE.match(line): - continue - if normalize_ticket_text(line) != match_text: - continue - lines[index] = roadmap_ticket_line(desired_text, ticket_state, ticket_source) - return "\n".join(lines).strip(), "update-roadmap-ticket" - - if lines and lines[-1].strip(): - lines.append("") - lines.append(new_line) - return "\n".join(lines).strip(), "add-roadmap-ticket" - - -def apply_roadmap_ticket_request( - project_root: Path, - roadmap_text: str, - *, - section: str, - ticket_text: str, - ticket_state: str, - ticket_source: str, - ticket_match: Optional[str], - allow_duplicate: bool, -) -> Tuple[str, ApplyAction]: - source = ticket_source.strip() - if source: - source_path = Path(source).expanduser() - if source_path.is_absolute(): - try: - source = source_path.resolve().relative_to(project_root).as_posix() - except ValueError as error: - raise ValueError( - "Roadmap ticket source must be repo-relative or inside the project root." - ) from error - - target_type, milestone_number, target_name = parse_ticket_section(section) - if target_type == "unknown": - raise ValueError( - "Unsupported --ticket-section. Use 'Small Tickets', 'Backlog Candidates', " - "or 'Milestone N: Tickets'." - ) - - preamble, sections = split_sections(roadmap_text) - - if target_type == "top-level": - updated_sections: List[Tuple[str, str]] = [] - action_name: Optional[str] = None - for heading, body in sections: - if heading != target_name: - updated_sections.append((heading, body)) - continue - updated_body, action_name = mutate_checklist_body( - body, - ticket_text=ticket_text, - ticket_state=ticket_state, - ticket_source=source, - ticket_match=ticket_match, - allow_duplicate=allow_duplicate, - ) - updated_sections.append((heading, updated_body)) - if action_name: - return render_sections(preamble, updated_sections), ApplyAction( - action=action_name, - reason=f"Updated {target_name} with roadmap ticket: {normalize_ticket_text(ticket_text)}.", - file="ROADMAP.md", - ) - raise ValueError(f"ROADMAP is missing required section '## {target_name}'.") - - updated_sections = [] - milestone_action_name: Optional[str] = None - for heading, body in sections: - parsed = parse_milestone_heading(heading) - if not parsed or parsed[0] != milestone_number: - updated_sections.append((heading, body)) - continue - - sub_preamble, subsections = split_subsections(body) - updated_subsections: List[Tuple[str, str]] = [] - found_tickets = False - for subheading, subbody in subsections: - if subheading != target_name: - updated_subsections.append((subheading, subbody)) - continue - found_tickets = True - updated_body, milestone_action_name = mutate_checklist_body( - subbody, - ticket_text=ticket_text, - ticket_state=ticket_state, - ticket_source=source, - ticket_match=ticket_match, - allow_duplicate=allow_duplicate, - ) - updated_subsections.append((subheading, updated_body)) - - if not found_tickets: - raise ValueError(f"Milestone {milestone_number} is missing '### Tickets'.") - - rendered_body = sub_preamble.strip() - for subheading, subbody in updated_subsections: - rendered_body += f"\n\n### {subheading}\n\n{subbody.strip()}" - updated_sections.append((heading, rendered_body.strip())) - if milestone_action_name: - return render_sections(preamble, updated_sections), ApplyAction( - action=milestone_action_name, - reason=( - f"Updated Milestone {milestone_number} Tickets with roadmap ticket: " - f"{normalize_ticket_text(ticket_text)}." - ), - file="ROADMAP.md", - ) - - raise ValueError(f"ROADMAP is missing milestone {milestone_number}.") - - -def validate_schema( - project_root: Path, - roadmap_path: Path, - roadmap_text: str, - config: Dict[str, Any], -) -> List[Finding]: - settings = config_settings(config) - required = required_sections(settings) - section_alias_map = alias_lookup(settings) - milestone_child_alias_map = subsection_alias_lookup(settings) - required_children = required_milestone_subsections(settings) - allowed_status_values = status_values(settings) - - findings: List[Finding] = [] - preamble, sections = split_sections(roadmap_text) - lookup = section_map(sections) - title, _extras = parse_title(preamble) - - if not title: - findings.append( - Finding( - finding_id="missing-title", - category="schema", - severity="high", - message="ROADMAP is missing a top-level '# <title>' heading.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if "Table of Contents" not in lookup: - findings.append( - Finding( - finding_id="missing-table-of-contents", - category="schema", - severity="medium", - message="ROADMAP is missing the required '## Table of Contents' section.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - headings = [heading for heading, _body in sections] - milestones = [(heading, body) for heading, body in sections if is_milestone_heading(heading)] - milestone_numbers: List[int] = [] - - for heading in required: - if heading not in lookup: - alias_found = next((alias for alias, canonical in section_alias_map.items() if canonical == heading and alias in lookup), None) - if alias_found: - findings.append( - Finding( - finding_id=f"non-canonical-heading-{slugify_heading(heading)}", - category="schema", - severity="medium", - message=f"ROADMAP uses alias heading '## {alias_found}' where the canonical schema expects '## {heading}'.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - else: - findings.append( - Finding( - finding_id=f"missing-section-{slugify_heading(heading)}", - category="schema", - severity="high", - message=f"ROADMAP is missing required section '## {heading}'.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if not milestones: - findings.append( - Finding( - finding_id="missing-milestones", - category="schema", - severity="high", - message="ROADMAP is missing milestone sections (expected headings like '## Milestone N: Name').", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - for heading, body in milestones: - parsed = parse_milestone_heading(heading) - if not parsed: - continue - number, _name = parsed - milestone_numbers.append(number) - _sub_preamble, subsections = split_subsections(body) - found_names = [milestone_child_alias_map.get(name, name) for name, _sub_body in subsections] - subsection_lookup = {milestone_child_alias_map.get(name, name): sub_body for name, sub_body in subsections} - for child in required_children: - if child not in found_names: - findings.append( - Finding( - finding_id=f"milestone-{number}-missing-{slugify_heading(child)}", - category="schema", - severity="high", - message=f"Milestone {number} is missing required subsection '### {child}'.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - continue - lines = [line for line in subsection_lookup[child].splitlines() if line.strip()] - if child != "Status" and not any(CHECKBOX_RE.match(line) for line in lines): - findings.append( - Finding( - finding_id=f"milestone-{number}-{slugify_heading(child)}-missing-checklists", - category="schema", - severity="medium", - message=f"Milestone {number} subsection '{child}' should contain checklist items.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - status_body = subsection_lookup.get("Status", "").strip() - if status_body: - status_lines = [line.strip() for line in status_body.splitlines() if line.strip()] - if len(status_lines) != 1: - findings.append( - Finding( - finding_id=f"milestone-{number}-status-format", - category="schema", - severity="medium", - message=f"Milestone {number} status should be a single plain status value.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - elif allowed_status_values and status_lines[0] not in allowed_status_values: - findings.append( - Finding( - finding_id=f"milestone-{number}-invalid-status", - category="schema", - severity="medium", - message=f"Milestone {number} status '{status_lines[0]}' is not in the allowed status vocabulary.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - current_block = "" - for line in body.splitlines(): - if line.startswith("### "): - current_block = milestone_child_alias_map.get(line[4:].strip(), line[4:].strip()) - continue - if ANY_CHECKBOX_RE.match(line) and not CHECKBOX_RE.match(line): - findings.append( - Finding( - finding_id=f"invalid-checkbox-milestone-{number}-{slugify_heading(line)}", - category="schema", - severity="medium", - message=f"Milestone {number} contains invalid checkbox syntax; use [ ] or [x].", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - if "[P]" in line and current_block != "Tickets": - findings.append( - Finding( - finding_id=f"parallel-marker-milestone-{number}", - category="schema", - severity="medium", - message=f"Milestone {number} uses '[P]' outside the 'Tickets' subsection.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if any(pattern.search(body) for pattern in PLACEHOLDER_PATTERNS): - findings.append( - Finding( - finding_id=f"placeholder-content-milestone-{number}", - category="content-quality", - severity="medium", - message=f"Milestone {number} contains placeholder-style content.", - file=str(roadmap_path), - auto_fixable=False, - ) - ) - - if milestone_numbers and milestone_numbers != sorted(milestone_numbers): - findings.append( - Finding( - finding_id="milestone-order", - category="schema", - severity="medium", - message="Milestone sections are not in deterministic ascending order.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if "Milestone Progress" in lookup and milestones: - progress = parse_progress(lookup["Milestone Progress"]) - parsed_milestones = [ - parsed - for parsed in (parse_milestone_heading(heading) for heading, _body in milestones) - if parsed is not None - ] - milestone_statuses: Dict[int, str] = {} - for heading, body in milestones: - parsed = parse_milestone_heading(heading) - if not parsed: - continue - number, _name = parsed - _sub_preamble, subsections = split_subsections(body) - subsection_lookup = {milestone_child_alias_map.get(name, name): sub_body for name, sub_body in subsections} - status_lines = [line.strip() for line in subsection_lookup.get("Status", "").splitlines() if line.strip()] - milestone_statuses[number] = status_lines[0] if status_lines else "" - expected = [ - f"Milestone {number}: {name} - {milestone_statuses.get(number, '').strip()}" - for number, name in sorted(parsed_milestones, key=lambda item: item[0]) - ] - actual = [f"Milestone {number}: {title} - {status}" for number, (title, status) in sorted(progress.items())] - if actual != expected: - findings.append( - Finding( - finding_id="stale-milestone-progress", - category="schema", - severity="medium", - message="Milestone Progress does not match the current milestone section list, order, and statuses.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - if "Table of Contents" in lookup: - expected_toc = [heading for heading, _body in sections if heading != "Table of Contents"] - actual_toc = toc_entries(lookup["Table of Contents"]) - if actual_toc != expected_toc: - findings.append( - Finding( - finding_id="stale-table-of-contents", - category="schema", - severity="low", - message="Table of contents entries do not match the canonical roadmap headings in order.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - positions = {heading: index for index, heading in enumerate(headings)} - if "Milestone Progress" in positions and milestones: - first_milestone_position = min(positions[heading] for heading, _body in milestones) - if positions["Milestone Progress"] > first_milestone_position: - findings.append( - Finding( - finding_id="milestone-progress-order", - category="schema", - severity="medium", - message="'Milestone Progress' should appear before milestone sections.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - if "Backlog Candidates" in positions and milestones: - last_milestone_position = max(positions[heading] for heading, _body in milestones) - if positions["Backlog Candidates"] < last_milestone_position: - findings.append( - Finding( - finding_id="backlog-order", - category="schema", - severity="medium", - message="'Backlog Candidates' should appear after milestone sections.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - if "Small Tickets" in positions and milestones: - last_milestone_position = max(positions[heading] for heading, _body in milestones) - if positions["Small Tickets"] < last_milestone_position: - findings.append( - Finding( - finding_id="small-tickets-order", - category="schema", - severity="medium", - message="'Small Tickets' should appear after milestone sections.", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - legacy_todo_path = project_root / "TODO.md" - if "Small Tickets" in lookup and legacy_todo_path.is_file() and legacy_todo_path.resolve() != roadmap_path.resolve(): - findings.append( - Finding( - finding_id="legacy-todo-md-migration-needed", - category="schema", - severity="medium", - message=( - "Root TODO.md exists while ROADMAP.md has a canonical Small Tickets section. " - "Migrate useful TODO.md backlog items into ROADMAP.md milestones, Small Tickets, " - "or Backlog Candidates, then remove TODO.md in a reviewed documentation pass." - ), - file=str(legacy_todo_path), - auto_fixable=False, - ) - ) - - if has_legacy_format(roadmap_text): - findings.append( - Finding( - finding_id="legacy-format", - category="schema", - severity="high", - message="Legacy roadmap sections detected (`Current Milestone` / `Milestones` table).", - file=str(roadmap_path), - auto_fixable=True, - ) - ) - - return findings - - -def apply_fixes(project_root: Path, roadmap_path: Path, roadmap_text: str, config: Dict[str, Any]) -> Tuple[str, List[ApplyAction]]: - if not roadmap_text.strip(): - bootstrap = render_template_bootstrap() - write_text(roadmap_path, bootstrap) - return ( - bootstrap, - [ - ApplyAction( - action="create-roadmap-from-template", - reason="Created a missing ROADMAP.md from the bundled canonical roadmap template.", - file=str(roadmap_path), - ) - ], - ) - - settings = config_settings(config) - required = required_sections(settings) - order = section_order(settings) - section_alias_map = alias_lookup(settings) - template_map = section_templates(settings) - allow_additional = allow_additional_sections(settings) - keep_preamble = preserve_preamble(settings) - - if has_legacy_format(roadmap_text): - migrated = build_migrated_from_legacy(roadmap_text, settings) - write_text(roadmap_path, migrated) - return ( - migrated, - [ - ApplyAction( - action="migrate-legacy-roadmap", - reason="Migrated a legacy roadmap layout into the canonical checklist roadmap structure.", - file=str(roadmap_path), - ) - ], - ) - - preamble, sections = split_sections(roadmap_text) - normalized_preamble = normalize_preamble(preamble, keep_preamble) - title, preamble_extras = parse_title(normalized_preamble) - title = title or "Project Roadmap" - preamble_lines = collapse_blank_lines(preamble_extras) if keep_preamble else [] - existing_lookup = section_map(sections) - - section_bodies: Dict[str, str] = {} - extra_sections: List[Tuple[str, str]] = [] - milestones: List[Tuple[int, str, str]] = [] - progress = parse_progress(existing_lookup.get("Milestone Progress", "")) - used_aliases: List[str] = [] - - for heading, body in sections: - if heading == "Table of Contents": - continue - parsed = parse_milestone_heading(heading) - if parsed: - number, name = parsed - milestones.append((number, name, render_milestone_body(body, settings))) - continue - canonical = section_alias_map.get(heading, heading) - if canonical in required: - if heading != canonical: - used_aliases.append(heading) - section_bodies[canonical] = body.strip() - elif allow_additional: - extra_sections.append((heading, body.strip())) - - if not milestones: - template_text = render_template_bootstrap() - _template_preamble, template_sections = split_sections(template_text) - for heading, body in template_sections: - if is_milestone_heading(heading): - parsed = parse_milestone_heading(heading) - if parsed: - milestones.append((parsed[0], parsed[1], body.strip())) - - milestones = sorted(milestones, key=lambda item: item[0]) - - for heading in required: - if heading == "Milestone Progress": - continue - body = section_bodies.get(heading, "").strip() or template_map.get(heading, "") - section_bodies[heading] = body.strip() - - milestone_alias_map = subsection_alias_lookup(settings) - progress_lines = [] - for number, name, body in milestones: - _sub_preamble, subsections = split_subsections(body) - subsection_lookup = {milestone_alias_map.get(subheading, subheading): sub_body for subheading, sub_body in subsections} - status_line_candidates = [line.strip() for line in subsection_lookup.get("Status", "").splitlines() if line.strip()] - status_value = status_line_candidates[0] if status_line_candidates else progress.get(number, (name, "Planned"))[1] - progress_lines.append(f"- Milestone {number}: {name} - {status_value}") - section_bodies["Milestone Progress"] = "\n".join(progress_lines).strip() - - updated = render_document( - title=title, - preamble_lines=preamble_lines, - ordered_section_bodies=section_bodies, - milestones=milestones, - extra_sections=extra_sections, - order=order, - required=required, - allow_additional=allow_additional, - ) - - actions: List[ApplyAction] = [] - if updated != normalize_whitespace(roadmap_text): - write_text(roadmap_path, updated) - actions.append( - ApplyAction( - action="normalize-roadmap-schema", - reason="Normalized the roadmap into the configured canonical checklist structure.", - file=str(roadmap_path), - ) - ) - if used_aliases: - actions.append( - ApplyAction( - action="migrate-alias-headings", - reason=f"Migrated alias headings into canonical heading names: {', '.join(sorted(set(used_aliases)))}.", - file=str(roadmap_path), - ) - ) - - return updated, actions - - -def markdown_report(report: Dict[str, Any]) -> str: - lines = [ - "# Maintain Project Roadmap Report", - "", - "## Run Context", - "", - f"- Project root: `{report['run_context']['project_root']}`", - f"- Roadmap path: `{report['run_context']['roadmap_path']}`", - f"- Run mode: `{report['run_context']['run_mode']}`", - f"- Timestamp: `{report['run_context']['timestamp_utc']}`", - "", - "## Customization State", - "", - f"- Config path: `{report['customization_state'].get('config_path', 'none')}`", - f"- Default config path: `{report['customization_state'].get('default_config_path', 'none')}`", - f"- Profile: `{report['customization_state'].get('profile', 'base')}`", - f"- Customized: `{report['customization_state'].get('is_customized', False)}`", - "", - "## Schema Contract", - "", - f"- Required sections: `{', '.join(report['schema_contract'].get('required_sections', []))}`", - f"- Canonical order: `{', '.join(report['schema_contract'].get('section_order', []))}`", - f"- Required milestone subsections: `{', '.join(report['schema_contract'].get('required_milestone_subsections', []))}`", - "", - "## Findings", - "", - ] - - if report["findings"]: - lines.extend( - f"- `{finding['severity']}` `{finding['finding_id']}`: {finding['message']}" - for finding in report["findings"] - ) - else: - lines.append("- None.") - - lines.extend(["", "## Changes Applied", ""]) - if report["apply_actions"]: - lines.extend(f"- `{action['action']}`: {action['reason']}" for action in report["apply_actions"]) - else: - lines.append("- None.") - - lines.extend(["", "## Small Ticket Candidates", ""]) - if report["small_ticket_candidates"]: - for candidate in report["small_ticket_candidates"]: - if candidate["source"] == "github": - lines.append(f"- GitHub #{candidate['number']}: {candidate['title']} ({candidate['url']})") - elif candidate["file"] and candidate["line"]: - lines.append( - f"- {candidate['kind']} in `{candidate['file']}:{candidate['line']}`: {candidate['title']}" - ) - else: - lines.append(f"- {candidate['kind']}: {candidate['title']}") - else: - lines.append("- None.") - - lines.extend(["", "## Errors", ""]) - if report["errors"]: - lines.extend(f"- {error}" for error in report["errors"]) - else: - lines.append("- None.") - - return "\n".join(lines).rstrip() + "\n" - - -def unresolved_issues(report: Dict[str, Any]) -> List[Dict[str, Any]]: - return list(report["findings"]) - - -def schema_contract(config: Dict[str, Any]) -> Dict[str, Any]: - settings = config_settings(config) - return { - "required_sections": required_sections(settings), - "section_order": section_order(settings), - "required_milestone_subsections": required_milestone_subsections(settings), - "status_values": status_values(settings), - } - - -def run_maintenance(args: argparse.Namespace) -> Tuple[Dict[str, Any], str]: - project_root = Path(args.project_root).expanduser().resolve() - roadmap_path = Path(args.roadmap_path).expanduser().resolve() if args.roadmap_path else (project_root / "ROADMAP.md") - - report: Dict[str, Any] = { - "run_context": { - "project_root": str(project_root), - "roadmap_path": str(roadmap_path), - "run_mode": args.run_mode, - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - }, - "customization_state": {}, - "schema_contract": {}, - "findings": [], - "small_ticket_candidates": [], - "apply_actions": [], - "errors": [], - } - - ticket_requested = bool(args.ticket_section or args.ticket_text or args.ticket_match or args.ticket_source) - if ticket_requested: - if args.run_mode != "apply": - report["errors"].append("Roadmap ticket mutation requires --run-mode apply.") - if not args.ticket_section: - report["errors"].append("Roadmap ticket mutation requires --ticket-section.") - if not args.ticket_text: - report["errors"].append("Roadmap ticket mutation requires --ticket-text.") - - if not project_root.is_dir(): - report["errors"].append(f"Project root does not exist or is not a directory: {project_root}") - return report, markdown_report(report) - - config = load_config(project_root, args.config) - report["customization_state"] = { - "config_path": config.get("configPath", "none"), - "default_config_path": config.get("defaultConfigPath", "none"), - "profile": config.get("profile", "base"), - "is_customized": bool(config.get("isCustomized", False)), - } - report["schema_contract"] = schema_contract(config) - - if roadmap_path.is_file(): - roadmap_text = read_text(roadmap_path) - findings = validate_schema(project_root, roadmap_path, roadmap_text, config) - report["findings"] = [finding.to_dict() for finding in findings] - elif args.run_mode == "apply": - roadmap_text = "" - report["findings"] = [ - Finding( - finding_id="missing-roadmap", - category="schema", - severity="high", - message=f"ROADMAP file is missing at {roadmap_path}.", - file=str(roadmap_path), - auto_fixable=True, - ).to_dict() - ] - else: - roadmap_text = "" - report["findings"] = [ - Finding( - finding_id="missing-roadmap", - category="schema", - severity="high", - message=f"ROADMAP file is missing at {roadmap_path}.", - file=str(roadmap_path), - auto_fixable=True, - ).to_dict() - ] - - small_ticket_candidates: List[SmallTicketCandidate] = [] - if args.collect_source_tickets: - small_ticket_candidates.extend(collect_source_ticket_candidates(project_root)) - if args.collect_github_issues: - github_candidates, github_errors = collect_github_issue_candidates(project_root, args.github_repo) - small_ticket_candidates.extend(github_candidates) - report["errors"].extend(github_errors) - if small_ticket_candidates: - small_ticket_candidates = filter_new_small_ticket_candidates(roadmap_text, small_ticket_candidates) - report["small_ticket_candidates"] = [candidate.to_dict() for candidate in small_ticket_candidates] - - if args.run_mode == "apply" and not report["errors"]: - updated_text, actions = apply_fixes(project_root, roadmap_path, roadmap_text, config) - new_candidates = filter_new_small_ticket_candidates(updated_text, small_ticket_candidates) - updated_text, inserted_count = append_small_ticket_candidates(updated_text, new_candidates) - if inserted_count: - write_text(roadmap_path, updated_text) - actions.append( - ApplyAction( - action="append-small-ticket-candidates", - reason=f"Appended {inserted_count} collected source or GitHub issue candidate(s) to Small Tickets.", - file=str(roadmap_path), - ) - ) - if ticket_requested and args.ticket_section and args.ticket_text: - try: - updated_text, ticket_action = apply_roadmap_ticket_request( - project_root, - updated_text, - section=args.ticket_section, - ticket_text=args.ticket_text, - ticket_state=args.ticket_state, - ticket_source=args.ticket_source or "", - ticket_match=args.ticket_match, - allow_duplicate=bool(args.allow_duplicate), - ) - write_text(roadmap_path, updated_text) - actions.append(ticket_action) - except ValueError as error: - report["errors"].append(str(error)) - report["apply_actions"] = [action.to_dict() for action in actions] - post_findings = validate_schema(project_root, roadmap_path, updated_text, config) - report["findings"] = [finding.to_dict() for finding in post_findings] - - markdown = markdown_report(report) - return report, markdown - - -def main() -> int: - args = parse_args() - report, markdown = run_maintenance(args) - payload = json.dumps(report, indent=2, sort_keys=True) + "\n" - - if args.json_out: - write_text(Path(args.json_out), payload) - if args.md_out: - write_text(Path(args.md_out), markdown) - - if args.print_json: - sys.stdout.write(payload) - elif args.print_md: - sys.stdout.write(markdown) - else: - if ( - not unresolved_issues(report) - and not report["small_ticket_candidates"] - and not report["apply_actions"] - and not report["errors"] - ): - sys.stdout.write("No findings.\n") - else: - sys.stdout.write(markdown) - - if report["errors"]: - return 1 - if args.fail_on_issues and (unresolved_issues(report) or report["errors"]): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/repository-maintenance-e2e.fsx b/tests/repository-maintenance-e2e.fsx new file mode 100644 index 000000000..46cad447d --- /dev/null +++ b/tests/repository-maintenance-e2e.fsx @@ -0,0 +1,54 @@ +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type Result = { ExitCode: int; Stdout: string; Stderr: string } + +let socketRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) +let installer = Path.Combine(socketRoot, "plugins", "repository-skills", "skills", "maintain-project-repo", "scripts", "maintain-project-repo.fsx") +let testRoot = Path.Combine(Path.GetTempPath(), $"socket-repository-maintenance-e2e-{Guid.NewGuid():N}") +Directory.CreateDirectory(testRoot) |> ignore + +let run cwd executable arguments = + let info = ProcessStartInfo(executable) + info.WorkingDirectory <- cwd + info.UseShellExecute <- false + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + for argument in arguments do info.ArgumentList.Add(argument) + use child = Process.Start(info) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + { ExitCode = child.ExitCode; Stdout = stdout; Stderr = stderr } + +let requireSuccess description result = + if result.ExitCode <> 0 then failwith $"{description} failed: {result.Stderr}\n{result.Stdout}" + +let snapshot () = + Directory.GetFiles(testRoot, "*", SearchOption.AllDirectories) + |> Array.filter (fun path -> not (path.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}"))) + |> Array.sort + |> Array.map (fun path -> Path.GetRelativePath(testRoot, path), File.ReadAllBytes(path)) + +run testRoot "git" [ "init"; "-q" ] |> requireSuccess "temporary Git initialization" +run socketRoot "dotnet" [ "fsi"; installer; "--repo-root"; testRoot; "--operation"; "install"; "--profile"; "generic" ] |> requireSuccess "repository-skills installation" + +let contributing = Path.Combine(testRoot, "CONTRIBUTING.md") +File.AppendAllText(contributing, "\n```text\nSigned-off-by: Your Name <you@example.com>\n```\n") + +run testRoot "just" [ "docs-apply" ] |> requireSuccess "first full documentation apply" +let first = snapshot () +run testRoot "just" [ "docs-apply" ] |> requireSuccess "second full documentation apply" +let second = snapshot () +if first.Length <> second.Length || Array.exists2 (fun (leftPath, leftBytes) (rightPath, rightBytes) -> leftPath <> rightPath || leftBytes <> rightBytes) first second then + failwith "Second full documentation apply was not byte-idempotent." + +run testRoot "just" [ "docs-check" ] |> requireSuccess "full documentation check" +run testRoot "git" [ "add"; "-A" ] |> requireSuccess "stage generated repository" +run testRoot "git" [ "-c"; "user.name=Socket Tests"; "-c"; "user.email=tests@example.invalid"; "commit"; "-qm"; "test fixture" ] |> requireSuccess "commit generated repository" +run testRoot "just" [ "repo-validate" ] |> requireSuccess "managed repository validation" + +printfn "Repository-maintenance end-to-end test passed." +printfn "Temporary fixture: %s" testRoot From f9c8b5600ba9af301b57b37ced20fc640681e00e Mon Sep 17 00:00:00 2001 From: Gale W <mail@galewilliams.com> Date: Thu, 20 Aug 2026 23:34:32 -0400 Subject: [PATCH 2/5] tests: consolidate Socket validation at the root Why: Socket now uses one essential integration validation path and one root end-to-end test, with no nested or unit suites. Breaking: Permanently removes the swiftasb-skills plugin, its marketplace surfaces, tests, and repository references. Verification: just docs-check just repo-validate just test git diff --check --- .agents/plugins/marketplace.json | 12 - .claude-plugin/marketplace.json | 1 - AGENTS.md | 9 +- CONTRIBUTING.md | 286 ++------ README.md | 2 - ROADMAP.md | 38 +- docs/maintainers/claude-compatibility.json | 1 - docs/maintainers/dotnet-skills-plugin-plan.md | 2 +- docs/maintainers/validation-and-test-audit.md | 200 +----- .../test_design_agent_automation_workflow.py | 155 ----- .../tests/test_design_agent_eval_workflow.py | 94 --- .../test_bootstrap_skills_plugin_repo.py | 60 -- .../tests/test_sync_skills_repo_guidance.py | 156 ----- .../tests/test_agent_protocol_workflows.py | 46 -- .../docs/desktop-bridge-mcp-skill-plan.md | 1 - .../appkit-app-architecture-workflow/SKILL.md | 9 +- .../tests/test_app_extension_workflows.py | 69 -- .../test_appkit_app_architecture_workflow.py | 72 --- ...t_apple_developer_provisioning_workflow.py | 88 --- .../test_apple_ui_accessibility_workflow.py | 57 -- .../test_arkit_spatial_face_body_workflows.py | 119 ---- .../tests/test_author_swift_docc_docs.py | 98 --- .../test_camera_capture_depth_workflow.py | 102 --- ...est_core_animation_typography_workflows.py | 108 ---- .../tests/test_customization_cli.py | 88 --- .../test_customization_template_paths.py | 118 ---- .../test_design_animation_symbol_workflows.py | 110 ---- .../test_devicecheck_app_attest_workflow.py | 77 --- .../test_explore_apple_swift_docs_workflow.py | 232 ------- .../tests/test_format_swift_sources_export.py | 87 --- .../test_imaging_foundation_workflows.py | 102 --- .../test_macos_platform_security_workflows.py | 67 -- .../test_macos_virtualization_workflows.py | 79 --- .../tests/test_media_audio_workflows.py | 171 ----- .../tests/test_media_expansion_audit.py | 117 ---- .../test_milestone24_system_ui_workflows.py | 88 --- .../test_photos_library_editing_workflow.py | 107 --- .../test_safari_extension_control_workflow.py | 71 -- .../tests/test_safari_mcp_workflow.py | 47 -- ...st_structure_swift_sources_file_headers.py | 140 ---- ...ucture_swift_sources_todo_fixme_ledgers.py | 228 ------- .../test_structure_swift_sources_workflow.py | 140 ---- .../test_swift_cleanup_skill_boundaries.py | 35 - .../test_swift_package_build_run_workflow.py | 110 ---- .../test_swift_package_extension_workflow.py | 91 --- .../test_swift_package_testing_workflow.py | 121 ---- .../test_swiftui_app_architecture_workflow.py | 85 --- .../test_swiftui_component_audit_workflow.py | 29 - .../tests/test_tipkit_workflow.py | 58 -- .../tests/test_tvos_workflows.py | 71 -- .../test_video_codec_processing_workflow.py | 116 ---- .../test_vision_recognition_workflows.py | 96 --- .../tests/test_xcode_build_run_workflow.py | 156 ----- ...test_xcode_coding_intelligence_workflow.py | 108 ---- ...ice_window_telemetry_debugger_workflows.py | 58 -- .../tests/test_xcode_localization_workflow.py | 59 -- .../tests/test_xcode_testing_workflow.py | 186 ------ ...test_xcode_toolchain_selection_guidance.py | 68 -- .../tests/test_xcode_workspace_workflows.py | 555 ---------------- .../tests/test_macos_security_handoffs.py | 30 - .../tests/test_dice_job_search_workflow.py | 92 --- .../test_build_python_agent_service_skill.py | 43 -- .../python-skills/tests/test_plugin_smoke.py | 146 ----- .../shared/project-docs/DocsCoordinator.fsx | 4 + .../test_research_macos_security_control.py | 33 - .../swiftasb-skills/.codex-plugin/plugin.json | 46 -- plugins/swiftasb-skills/AGENTS.md | 34 - .../assets/swiftasb-skills-icon.svg | 24 - .../examples/explain-before-implementation.md | 59 -- .../skills/build-appkit-app/SKILL.md | 287 -------- .../skills/build-swift-package/SKILL.md | 247 ------- .../skills/build-swiftui-app/SKILL.md | 310 --------- .../skills/choose-integration-shape/SKILL.md | 191 ------ .../skills/diagnose-integration/SKILL.md | 328 ---------- .../skills/explain-swiftasb/SKILL.md | 147 ----- .../repo-maintenance/lib/DocsCoordinator.fsx | 4 + .../validations/50-socket.fsx | 69 ++ scripts/repo-maintenance/version-bump.fsx | 46 ++ shared/project-docs/DocsCoordinator.fsx | 4 + tests/repository-maintenance-e2e.fsx | 32 + tests/test_audit_skill_surfaces.py | 203 ------ .../test_audit_xcode_plugin_compatibility.py | 85 --- tests/test_check_acp_registry.py | 56 -- tests/test_cleanup_legacy_socket_installs.py | 154 ----- tests/test_cybersecurity_skill_contracts.py | 155 ----- .../test_deployment_build_safety_contracts.py | 43 -- ...cos_platform_security_forward_scenarios.py | 78 --- ..._macos_virtualization_forward_scenarios.py | 65 -- ...st_macos_virtualization_skill_contracts.py | 24 - tests/test_model_lab_skill_contracts.py | 270 -------- tests/test_release_version.py | 91 --- tests/test_release_workflow.py | 159 ----- tests/test_repository_maintenance_workflow.py | 612 ------------------ tests/test_spi_add_package.py | 159 ----- tests/test_swiftasb_skills_install.py | 86 --- .../test_unified_swift_workspace_contracts.py | 93 --- tests/test_validate_claude_compatibility.py | 114 ---- tests/test_validate_hermes_compatibility.py | 235 ------- tests/test_validate_socket.py | 147 ----- tests/test_validate_socket_metadata.py | 518 --------------- 100 files changed, 259 insertions(+), 11120 deletions(-) delete mode 100644 plugins/agent-engineering-skills/skills/design-agent-automation-workflow/tests/test_design_agent_automation_workflow.py delete mode 100644 plugins/agent-engineering-skills/skills/design-agent-eval-workflow/tests/test_design_agent_eval_workflow.py delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/tests/test_bootstrap_skills_plugin_repo.py delete mode 100644 plugins/agent-portability-skills/skills/sync-skills-repo-guidance/tests/test_sync_skills_repo_guidance.py delete mode 100644 plugins/agent-portability-skills/tests/test_agent_protocol_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_app_extension_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_appkit_app_architecture_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_apple_developer_provisioning_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_apple_ui_accessibility_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_arkit_spatial_face_body_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_author_swift_docc_docs.py delete mode 100644 plugins/apple-dev-skills/tests/test_camera_capture_depth_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_core_animation_typography_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_customization_cli.py delete mode 100644 plugins/apple-dev-skills/tests/test_customization_template_paths.py delete mode 100644 plugins/apple-dev-skills/tests/test_design_animation_symbol_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_devicecheck_app_attest_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_explore_apple_swift_docs_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_format_swift_sources_export.py delete mode 100644 plugins/apple-dev-skills/tests/test_imaging_foundation_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_macos_platform_security_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_macos_virtualization_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_media_audio_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_media_expansion_audit.py delete mode 100644 plugins/apple-dev-skills/tests/test_milestone24_system_ui_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_photos_library_editing_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_safari_extension_control_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_safari_mcp_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_structure_swift_sources_file_headers.py delete mode 100644 plugins/apple-dev-skills/tests/test_structure_swift_sources_todo_fixme_ledgers.py delete mode 100644 plugins/apple-dev-skills/tests/test_structure_swift_sources_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_swift_cleanup_skill_boundaries.py delete mode 100644 plugins/apple-dev-skills/tests/test_swift_package_build_run_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_swift_package_extension_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_swift_package_testing_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_swiftui_app_architecture_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_swiftui_component_audit_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_tipkit_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_tvos_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_video_codec_processing_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_vision_recognition_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_build_run_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_coding_intelligence_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_device_window_telemetry_debugger_workflows.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_localization_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_testing_workflow.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_toolchain_selection_guidance.py delete mode 100644 plugins/apple-dev-skills/tests/test_xcode_workspace_workflows.py delete mode 100644 plugins/cybersecurity-skills/tests/test_macos_security_handoffs.py delete mode 100644 plugins/professional-skills/skills/dice-job-search-workflow/tests/test_dice_job_search_workflow.py delete mode 100644 plugins/python-skills/tests/test_build_python_agent_service_skill.py delete mode 100644 plugins/python-skills/tests/test_plugin_smoke.py delete mode 100644 plugins/reverse-engineering-skills/tests/test_research_macos_security_control.py delete mode 100644 plugins/swiftasb-skills/.codex-plugin/plugin.json delete mode 100644 plugins/swiftasb-skills/AGENTS.md delete mode 100644 plugins/swiftasb-skills/assets/swiftasb-skills-icon.svg delete mode 100644 plugins/swiftasb-skills/examples/explain-before-implementation.md delete mode 100644 plugins/swiftasb-skills/skills/build-appkit-app/SKILL.md delete mode 100644 plugins/swiftasb-skills/skills/build-swift-package/SKILL.md delete mode 100644 plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md delete mode 100644 plugins/swiftasb-skills/skills/choose-integration-shape/SKILL.md delete mode 100644 plugins/swiftasb-skills/skills/diagnose-integration/SKILL.md delete mode 100644 plugins/swiftasb-skills/skills/explain-swiftasb/SKILL.md create mode 100644 scripts/repo-maintenance/validations/50-socket.fsx create mode 100644 scripts/repo-maintenance/version-bump.fsx delete mode 100644 tests/test_audit_skill_surfaces.py delete mode 100644 tests/test_audit_xcode_plugin_compatibility.py delete mode 100644 tests/test_check_acp_registry.py delete mode 100644 tests/test_cleanup_legacy_socket_installs.py delete mode 100644 tests/test_cybersecurity_skill_contracts.py delete mode 100644 tests/test_deployment_build_safety_contracts.py delete mode 100644 tests/test_macos_platform_security_forward_scenarios.py delete mode 100644 tests/test_macos_virtualization_forward_scenarios.py delete mode 100644 tests/test_macos_virtualization_skill_contracts.py delete mode 100644 tests/test_model_lab_skill_contracts.py delete mode 100644 tests/test_release_version.py delete mode 100644 tests/test_release_workflow.py delete mode 100644 tests/test_repository_maintenance_workflow.py delete mode 100644 tests/test_spi_add_package.py delete mode 100644 tests/test_swiftasb_skills_install.py delete mode 100644 tests/test_unified_swift_workspace_contracts.py delete mode 100644 tests/test_validate_claude_compatibility.py delete mode 100644 tests/test_validate_hermes_compatibility.py delete mode 100644 tests/test_validate_socket.py delete mode 100644 tests/test_validate_socket_metadata.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index f45e68275..a06b5fdeb 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -227,18 +227,6 @@ "shortDescription": "Local speech and playback workflows for Codex." } }, - { - "name": "swiftasb-skills", - "source": { - "source": "local", - "path": "./plugins/swiftasb-skills" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Developer Tools" - }, { "name": "web-dev-skills", "source": { diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2ed507981..48af92140 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -28,7 +28,6 @@ { "name": "server-side-jvm", "source": "./plugins/server-side-jvm", "description": "Java, Scala, JVM service, build, and testing workflows.", "category": "developer-tools", "tags": ["java", "scala", "jvm"], "strict": false }, { "name": "server-side-swift", "source": "./plugins/server-side-swift", "description": "Vapor, Hummingbird, SwiftNIO, Docker, and Apple container workflows.", "category": "developer-tools", "tags": ["swift", "server", "containers"], "strict": false }, { "name": "swift-lang", "source": "./plugins/swift-lang", "description": "Shared Swift language, syntax, compiler, semantic indexing, LSP, formatting, and modernization workflows.", "category": "developer-tools", "tags": ["swift", "language", "tooling"], "strict": false }, - { "name": "swiftasb-skills", "source": "./plugins/swiftasb-skills", "description": "SwiftASB integration and application-development workflows.", "category": "developer-tools", "tags": ["swift", "swiftasb"], "strict": false }, { "name": "web-dev-skills", "source": "./plugins/web-dev-skills", "description": "Web and Expo native-boundary workflows.", "category": "developer-tools", "tags": ["web", "expo"], "strict": false } ] } diff --git a/AGENTS.md b/AGENTS.md index 3b114ee20..166574469 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,17 +49,18 @@ cross-repository policy. ### Setup ```bash -uv sync --dev +just --list ``` ### Validation ```bash -uv run scripts/validate_socket.py --profile compatibility +just repo-validate +just test ``` -Use the full or release workflow only when the changed surface requires it; -`CONTRIBUTING.md` owns that routing. +Socket intentionally has one root integration/E2E test and no nested or unit +test suites. `CONTRIBUTING.md` owns release routing. ### Optional Project Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d0b297be..d7c3ba0be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,4 @@ -# Contributing to socket - -Use this guide when preparing root-level changes so the `socket` superproject stays understandable, runnable, and reviewable for the next maintainer. +# Contributing to Socket ## Table of Contents @@ -16,288 +14,142 @@ Use this guide when preparing root-level changes so the `socket` superproject st ### Who This Guide Is For -This guide is for contributors working on the `socket` superproject layer itself: the root marketplace, root maintainer docs, root validation scripts, and root coordination rules for the child repositories under [`plugins/`](./plugins/). +This guide is for contributors changing Socket's root marketplace, managed +documentation, repository maintenance, compatibility metadata, or the +monorepo-owned plugin payloads under [`plugins/`](./plugins/). ### Before You Start -Read the root [README.md](./README.md) and [AGENTS.md](./AGENTS.md), confirm whether the task belongs in the root superproject or in a specific child repository, and if the work affects subtree-managed children use the documented subtree workflow instead of improvising a mixed root-and-child change. If the change affects root docs, marketplace wiring, or maintainer automation, plan to update the relevant root docs in the same pass. +Read [README.md](./README.md), [AGENTS.md](./AGENTS.md), and +[ROADMAP.md](./ROADMAP.md). Work in the closest owning plugin when a change is +plugin-specific. Speak Swiftly remains Git-backed and is maintained in its +standalone repository. ## Contribution Workflow ### Choosing Work -Use the root repository for work about: - -- repo-root marketplace wiring in [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json) -- root maintainer docs under [`docs/`](./docs/) -- root policies in [README.md](./README.md), [AGENTS.md](./AGENTS.md), and [ROADMAP.md](./ROADMAP.md) -- root validation and CI such as [`scripts/validate_socket.py`](./scripts/validate_socket.py) and [`.github/workflows/validate-socket.yml`](./.github/workflows/validate-socket.yml) -- coordinated child-skill guidance that needs one consistent policy across multiple monorepo-owned plugin or skills repositories - -If the change is really about one child repository's own skills, packaging, tests, or release flow, start in that child repository's docs and workflow instead of treating `socket` as a generic catch-all. +Root work includes marketplace wiring, shared exports, root documentation, +cross-plugin compatibility, FSX repository automation, and release policy. +Child implementation belongs under its owning directory in `plugins/`. ### Making Changes -Keep changes bounded to one coherent root concern at a time, such as docs-only root alignment, marketplace-path or manifest-alignment fixes, root validation improvements, or root subtree-workflow documentation updates. Start implementation work from a branch-backed worktree by default so the base `main` checkout stays clean for coordination and release verification. Direct local-`main` edits are reserved for explicit direct-main requests, read-only work, or repo-owned release helpers that document the direct-main operation. - -For ordinary work in monorepo-owned child directories, including `plugins/apple-dev-skills`, edit the copy in the relevant directory under `plugins/` directly from the active `socket` feature worktree. The standalone `gaelic-ghost/apple-dev-skills` repository is only a compatibility marketplace pointer to Socket; do not subtree-push Socket payload changes back into it unless a future migration restores that workflow. For Speak Swiftly plugin payload changes, work in the standalone `SpeakSwiftlyServer` checkout; `socket` lists that payload by Git-backed marketplace reference and does not keep a local `plugins/SpeakSwiftlyServer` mirror. - -When changing user-facing plugin install or update docs, make the Git-backed marketplace path the default. Use commands shaped like `codex plugin marketplace add owner/repo` for install setup and `codex plugin marketplace upgrade marketplace-name` for updates; keep explicit refs such as `owner/repo@vX.Y.Z` scoped to pinned reproducible installs, and keep manual local marketplace roots scoped to development, unpublished testing, or fallback instructions. - -For coordinated child-skill guidance, keep the root explanation small and put detailed behavior in the child repo that owns the skill surface. The root docs should explain why the pass is coordinated; the child docs should explain the actual skill contract. - -When adding root screenshots or other documentation media, place them under [`docs/media/`](./docs/media/), use portable descriptive filenames, and add nearby text that explains what the artifact proves or demonstrates. Do not rely on image content alone to explain a workflow. +Use a `<scope>/<slug>` feature branch. Keep one coherent concern per change and +update the owning source rather than an installed cache or generated copy. +Repository Skills source lives under `plugins/repository-skills`; root exports +are synchronized by `just repo-sync`. -When updating root docs, keep [README.md](./README.md) short, nontechnical, and focused on people or agents installing and using the Socket marketplace. Put contributor workflow, maintainer commands, release process, subtree accounting, marketplace source-shape details, and root validation expectations in this file or the maintainer docs under [`docs/maintainers/`](./docs/maintainers/). Put durable agent-facing operating rules in [AGENTS.md](./AGENTS.md). +Documentation is always maintained as one four-file transaction. The only docs +commands are: -Do not add `README.md` files to monorepo-owned child plugin roots by default. The root Socket docs, plugin manifests, skill metadata, child `AGENTS.md`, and root planning docs are the normal documentation surfaces for those children. Keep child root READMEs only for public compatibility surfaces such as `apple-dev-skills`; keep server-specific README files under bundled server directories such as `mcp/`. +```bash +just docs-check +just docs-apply +``` ### Asking For Review -A root change is ready for review when: - -- the change clearly belongs at the superproject layer -- any affected root docs and automation surfaces were updated together -- verification relevant to the changed root surface has been run -- the PR or review request explains whether the change affects root docs, marketplace wiring, subtree workflow, or validation behavior +State the changed ownership surface, compatibility consequence, and exact +commands run. Do not claim a release, installation, or synchronization that was +not performed. ## Local Setup ### Runtime Config -The root superproject uses one `uv` environment for root and monorepo-owned -child maintainer tooling: - -```bash -uv sync --dev -``` - -Declare Python quality tooling in the root `pyproject.toml` dev dependencies -rather than creating child environments or assuming a machine-global install. -Treat `pytest`, `ruff`, and `mypy` as the normal Python maintainer baseline when -the shipped validation surface uses them. Root validation redirects tool caches -to `.codex/.cache/` and disables bytecode writes so checks do not recreate -generated state inside plugin payloads. - -At the `socket` root, run `uv run mypy` without a path argument. The root mypy config intentionally checks root maintainer scripts, root tests, and package-shaped child maintainer code instead of crawling every standalone skill `scripts/` directory as one Python module namespace. - -For the read-only Xcode plug-in source assessment, run: +Socket pins .NET in [`global.json`](./global.json) and exposes maintainer work +through [`justfile`](./justfile). Install a compatible .NET 10 SDK and `just`, +then inspect the recipes: ```bash -uv run scripts/audit_xcode_plugin_compatibility.py +just --list ``` -The report classifies each Socket child plug-in separately for Xcode-launched -Codex, Xcode internal agents, and external agents using Xcode MCP. It does not -write Xcode configuration or claim runtime support for hooks, MCP servers, -apps, or custom agents. - -Versioned guidance in this repository is a floor unless it explicitly says an exact pin is required. For GitHub Actions, runners, language toolchains, package managers, and stack versions, prefer newer stable versions when official release notes or documentation support the update and the relevant local or CI validation passes. - -The root validation path does not require application secrets. If your change involves subtree sync or GitHub operations, make sure your git remotes and GitHub authentication are already configured on your machine before you start those steps. +Do not add Python or shell maintainer scripts, direct operator-facing script +commands, nested test suites, or per-document recipes. ### Runtime Behavior -`socket` does not run a root application or service. A healthy root setup means: - -- the `uv` dev environment is synced -- the root marketplace file is valid JSON -- the root packaged plugin paths still point at real installable plugin surfaces -- the core Socket validation profile completes successfully - -Use the compatibility profile for ordinary root changes. It runs the core marketplace, skill-metadata, root-test, type, and lint checks plus the checked-in Hermes and Claude compatibility checks: - -```bash -uv run scripts/validate_socket.py --profile compatibility -``` - -Use `--profile full` when the change affects child validation or behavior; it -runs each participating child suite once from its owning project. Socket's -release workflow runs this same profile before the release PR and again on -reviewed `main`; validation profiles do not own tagging or publication. - -Every new or materially changed Socket plugin, skill, or MCP declaration needs -an explicit Hermes compatibility outcome in the same pass. When that outcome -changes the checked-in Hermes skill tap, regenerate and validate it: +Socket has no root application. Its essential validation is integration-level: ```bash -uv run scripts/export_hermes_skills.py -uv run scripts/validate_hermes_compatibility.py +just repo-validate +just test ``` -Do not edit root `skills/` by hand. The current generated export is maintained -from the sources declared by the export script; see the -[Hermes compatibility guide](./docs/maintainers/hermes-compatibility.md) for -the required skill-export decision, MCP translation rules, and native-plugin -boundary. +`repo-validate` checks marketplace-to-plugin resolution, shared version +alignment, Claude compatibility references, root-only test placement, managed +repository assets, and the four canonical documents. `test` runs the installer, +docs apply/idempotence/check, Just interface, and repository validation in a +temporary Git repository. There are no unit-style or child-local test suites. -Every new or materially changed Socket marketplace plugin also needs a checked -Claude Code and Cowork classification. Keep the Claude marketplace and -[`docs/maintainers/claude-compatibility.json`](./docs/maintainers/claude-compatibility.json) -aligned, then run: - -```bash -uv run scripts/validate_claude_compatibility.py -``` - -Use the [Claude compatibility guide](./docs/maintainers/claude-compatibility.md) -for the skills-first Cowork boundary, local-MCP adapter rules, and selective -subagent policy. - -When a change adds or changes any `plugins/**/.mcp.json` declaration, update -the matching checked-in fragment under -[`docs/maintainers/hermes-mcp/`](./docs/maintainers/hermes-mcp/) and its -inventory entry in `index.yaml`. The Hermes validator requires every declared -Socket MCP configuration to be accounted for and rejects committed -machine-local paths or undocumented environment placeholders. +Use `just repo-sync` to refresh generated root skill exports and validate the +result. Do not edit root `skills/` by hand. ### Xcode Workspace -The root [`Socket.xcworkspace`](./Socket.xcworkspace) is a browse-only workspace for maintainers who want to use Xcode's file navigator and Markdown editor, especially Xcode 27 beta's WYSIWYG Markdown surface. It references root docs, the marketplace file, `docs/`, `plugins/`, and `scripts/`, but it intentionally has no schemes, targets, package products, or root build settings. - -Do not add a generated `.xcodeproj`, root `Package.swift`, or workspace scheme only to improve documentation editing. If Socket later gains a real root build product, document that build surface separately and update the workspace guidance in [`docs/maintainers/socket-xcode-workspace.md`](./docs/maintainers/socket-xcode-workspace.md). +[`Socket.xcworkspace`](./Socket.xcworkspace) is browse-only. Do not add a root +project, scheme, target, or package solely for documentation browsing. ### Marketplace Shape -The repo-root marketplace lives at [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json). It is a catalog, not a root aggregate plugin. - -The installable local child entries currently point at: - -- `./plugins/agent-portability-skills` -- `./plugins/agent-engineering-skills` -- `./plugins/android-dev-skills` -- `./plugins/apple-dev-skills` -- `./plugins/cloud-deployment-skills` -- `./plugins/cloud-inference-skills` -- `./plugins/codebase-understanding-skills` -- `./plugins/model-lab-skills` -- `./plugins/agentdeck` -- `./plugins/dotnet-skills` -- `./plugins/game-dev-skills` -- `./plugins/network-protocol-skills` -- `./plugins/professional-skills` -- `./plugins/python-skills` -- `./plugins/repository-skills` -- `./plugins/reverse-engineering-skills` -- `./plugins/rust-skills` -- `./plugins/server-side-jvm` -- `./plugins/server-side-swift` -- `./plugins/swift-lang` -- `./plugins/swiftasb-skills` -- `./plugins/web-dev-skills` - -The Speak Swiftly entry points at the canonical Git-backed `gaelic-ghost/SpeakSwiftlyServer` plugin source as `speak-swiftly`, with the display name `Speak Swiftly`. - -For the detailed packaging stance, use [`docs/maintainers/plugin-packaging-strategy.md`](./docs/maintainers/plugin-packaging-strategy.md). For isolated install testing that leaves personal production installs alone, use [`docs/maintainers/plugin-install-testing.md`](./docs/maintainers/plugin-install-testing.md). - -### Legacy Install Cleanup - -If a contributor is cleaning up an older copied-plugin or local-personal-marketplace setup after confirming the Git-backed Socket marketplace works, use the repo-owned cleanup helper: - -```bash -uv run scripts/cleanup_legacy_socket_installs.py -uv run scripts/cleanup_legacy_socket_installs.py --apply -``` - -The first command is a dry run. The `--apply` command backs up known legacy Socket install artifacts before removing them. +The catalog at [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json) +points to monorepo-owned local plugins plus the Git-backed Speak Swiftly source. +It is not an aggregate plugin. Each local entry must resolve to a matching +`.codex-plugin/plugin.json`. ## Development Expectations ### Naming Conventions -Keep root terminology aligned with the repository docs: - -- `skill` means a reusable workflow-authoring unit -- `plugin` means an installable distribution bundle -- `subagent` means a delegated runtime worker with its own context and tool policy - -Use the same names for the same concepts across `SKILL.md`, plugin manifests, marketplace metadata, docs, automation prompts, scripts, and validation messages. +Use `skill` for a reusable workflow, `plugin` for an installable bundle, and +`subagent` for a delegated runtime worker. Keep names aligned across manifests, +skills, marketplaces, documentation, and reports. ### Accessibility Expectations -Contributors must keep root-level changes aligned with the project's accessibility contract in [ACCESSIBILITY.md](./ACCESSIBILITY.md). - -If a change affects root docs, structural navigation, command readability, log clarity, workflow operability, or other root maintainer-facing surfaces, verify the affected surface against the documented accessibility expectations before asking for review. - -If a root-level change introduces a new accessibility limitation, exception, or remediation path, update [ACCESSIBILITY.md](./ACCESSIBILITY.md) in the same pass unless maintainers have explicitly agreed on a different tracking path. +Follow [ACCESSIBILITY.md](./ACCESSIBILITY.md). Keep commands, logs, headings, +links, and errors readable and actionable. ### Verification -Prefer grounded validation commands that match the changed root surface. - -Root baseline validation: +Run the root integration surface: ```bash -uv sync --dev -uv run scripts/validate_socket.py --profile compatibility +just docs-check +just repo-validate +just test ``` -Inspect the shared version without changing it with: +For a requested release, author `docs/releases/vX.Y.Z.md`, then use only: ```bash -scripts/release.sh inventory +just repo-release-prepare X.Y.Z +just repo-release-inspect X.Y.Z +just repo-release-advance X.Y.Z ``` -Socket has one release workflow for every semantic-version level and catalog -refresh. Author `docs/releases/vX.Y.Z.md` on the feature branch, then use: - -```bash -scripts/release.sh prepare X.Y.Z -scripts/release.sh inspect X.Y.Z -scripts/release.sh advance X.Y.Z -``` - -The workflow owns the version bump, full local and GitHub validation, release -PR, reviewed-main verification, commit-bound marketplace and Dependabot -evidence, annotated tag, GitHub release, structured branch/child accounting, -and final marketplace refresh. See -[`docs/maintainers/release-workflow.md`](./docs/maintainers/release-workflow.md) -for the complete contract. Do not invoke the internal Python modules or create -a direct-main shortcut. - -If the changed surface also introduces or expands Python-backed repo checks, -add the required tools to the root `uv` dev group and add the focused child -command to `scripts/validate_socket.py` instead of creating a child -`pyproject.toml`, lockfile, environment, or cache root. - -When editing docs, also review the rendered Markdown structure and cross-links for the files you changed. - -When editing docs that include media, also review the image path, alt text, and adjacent explanatory prose. - -When adding or updating agent reports under [`docs/agents/`](./docs/agents/), verify that the report contains no secrets, no private environment values, and no machine-local absolute links intended for repository-facing docs. +See [`docs/maintainers/release-workflow.md`](./docs/maintainers/release-workflow.md) +for release gates. Do not invoke internal FSX files directly. ## Pull Request Expectations -A good root PR should make the changed superproject surface obvious. Include: - -- what root concern changed -- why the change belongs in `socket` instead of a child repo -- any root docs updated to keep the policy surface aligned -- the verification you ran - -If a PR touches subtree-managed children, call that out explicitly so reviewers know whether they are looking at ordinary monorepo edits or subtree workflow changes. +A pull request should state what changed, why it belongs at that ownership +layer, any generated or compatibility surfaces updated, and the integration +commands run. Preserve the current PR body when updating an existing pull +request. ## Communication -Surface uncertainty early when a change starts to look architectural, cross-repo, or hard to keep bounded. In particular, pause and ask for alignment if the work would: - -- change the root marketplace model -- widen the superproject's ownership boundary -- add a new root abstraction or coordination layer -- blur the line between root policy and child-repo behavior - -When docs and scripts disagree, fix the script or narrow the documented contract so the two surfaces match. +Surface ownership, packaging, compatibility, release, or destructive cleanup +consequences explicitly. Ask before widening the marketplace architecture. ## License and Contribution Terms -Unless a contribution explicitly says otherwise in writing, contributions to `socket` are made under the Apache License 2.0 terms in [LICENSE](./LICENSE). The root legal-notice surface for this superproject lives in [NOTICE](./NOTICE). - -Outside contributions must be signed off under the [Developer Certificate of Origin](./DCO.md). Add a `Signed-off-by:` line to each commit using your real name and an email address you are willing to have recorded in public Git history: +Contributions are licensed under [Apache License 2.0](./LICENSE). Use Developer +Certificate of Origin sign-off when the repository requires it: ```text Signed-off-by: Your Name <you@example.com> ``` - -By submitting a contribution, you agree that, unless you explicitly state otherwise in writing, the contribution is submitted under the Apache License 2.0 as described in [LICENSE](./LICENSE). - -Do not submit contributions to `socket` unless you have the right to make the DCO certification and submit the work under the Apache License 2.0. diff --git a/README.md b/README.md index 33cea0e1e..6105dcb58 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,6 @@ Currently available from the catalog: - `swift-lang` - `rust-skills` - `speak-swiftly` -- `swiftasb-skills` - `web-dev-skills` ## Development @@ -212,5 +211,4 @@ Current Socket catalog shape: - `swift-lang`: shared Swift language, API style, error handling, functional pipelines, formatting, source organization, SwiftSyntax transformation, compiler inspection, SourceKit semantics and indexing, SourceKit-LSP diagnosis, Swiftly/Xcode toolchain routing, and modernization cleanup workflows - `rust-skills`: Rust, Cargo, rustup, crate, workspace, CLI, library, package, CI, test, lint, and format workflow guidance - `speak-swiftly`: Git-backed Speak Swiftly plugin from the standalone SpeakSwiftlyServer repository -- `swiftasb-skills`: SwiftASB companion guidance - `web-dev-skills`: Expo SDK 56+ inline native modules, type generation, native-boundary inspection, and validation handoff guidance diff --git a/ROADMAP.md b/ROADMAP.md index 9b52bbacc..07c70dbad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,6 @@ - [Vision](#vision) - [Product Principles](#product-principles) - [Milestone Progress](#milestone-progress) -- [Milestone 5: SwiftASB skills plugin](#milestone-5-swiftasb-skills-plugin) - [Milestone 6: Dotnet skills plugin](#milestone-6-dotnet-skills-plugin) - [Milestone 7: Python skills plugin expansion](#milestone-7-python-skills-plugin-expansion) - [Milestone 8: Server-Side Swift skills plugin](#milestone-8-server-side-swift-skills-plugin) @@ -53,7 +52,6 @@ ## Milestone Progress -- Milestone 5: SwiftASB skills plugin - Completed - Milestone 6: Dotnet skills plugin - Completed - Milestone 7: Python skills plugin expansion - Completed - Milestone 8: Server-Side Swift skills plugin - Completed @@ -83,39 +81,6 @@ - Milestone 32: tvOS app experience and media playback workflows - Completed - Milestone 33: Unified Swift workspace and CI-owned cloud deployment - In Progress -## Milestone 5: SwiftASB skills plugin - -### Status - -Completed - -### Scope - -- [x] Add a Socket-hosted `swiftasb-skills` child plugin that helps agents explain SwiftASB, choose an integration shape, and build SwiftUI, AppKit, and Swift package surfaces on top of SwiftASB. -- [x] Keep the plugin as a companion guidance surface rather than a runtime plugin: do not bundle an MCP server, duplicate SwiftASB source, or copy generated schema files into `socket`. -- [x] Keep Apple framework workflow rules delegated to `apple-dev-skills`, with this plugin focused on SwiftASB-specific explanation, decision support, integration, and troubleshooting. - -### Tickets - -- [x] Create `plugins/swiftasb-skills/` with its own `.codex-plugin/plugin.json` and authored `skills/` source. -- [x] Add first-slice skills for explaining SwiftASB, choosing an integration shape, and building a SwiftUI app on top of SwiftASB. -- [x] Add `swiftasb:build-appkit-app` for AppKit apps after the first slice proves useful. -- [x] Add `swiftasb:build-swift-package` for Swift package authors after the first slice proves useful. -- [x] Add an integration diagnostics skill for runtime discovery, app-server startup, threads, turns, approvals, diagnostics, MCP status, history reads, and live-test isolation. -- [x] Wire `swiftasb-skills` into the root Socket marketplace as a normal local child plugin. -- [x] Update root README and maintainer docs so users understand the split between the SwiftASB package source of truth and the Socket-hosted Codex guidance plugin. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py` and any child-plugin checks added by the new plugin. -- [x] Sync `swiftasb-skills` with current SwiftASB changes, starting from the live SwiftASB source and docs so the explanation, integration-shape, SwiftUI, AppKit, package, and diagnostics skills match the current client API and runtime behavior. -- [x] Refresh `swiftasb-skills` for SwiftASB `v1.6.0`, including plan-mode turn starts, `CodexThread.Agenda`, thread goal helpers, and plan/goal diagnostics across the existing skill set. -- [x] Refresh `swiftasb-skills` for SwiftASB `v1.8.0`, including Codex CLI `0.142.x` compatibility guidance, the compatible `0.141.x` prior-minor window, `CodexTurnItem.Kind.sleep`, and ASBPresentation/ASBAppKit/ASBSwiftUI product guidance across the existing skill set. - -### Exit Criteria - -- [x] The Socket marketplace exposes `swiftasb-skills` as an installable child plugin. -- [x] The new skills can help an agent explain SwiftASB to a user before implementation, including when SwiftASB is not the right fit. -- [x] The new skills guide SwiftUI, AppKit, and Swift package integrations without duplicating broad Apple framework guidance that belongs to `apple-dev-skills`. -- [x] Root Socket docs, marketplace wiring, and validation agree on the plugin's install surface. - ## Milestone 6: Dotnet skills plugin ### Status @@ -1254,7 +1219,7 @@ and test/production deployments for GitHub Actions. - [ ] Investigate an iTerm2 automation and integration skill covering AI Chat, the Python API, scripting fundamentals, variables, shell integration, tmux integration, and deprecated AppleScript boundaries. Keep the first pass docs-first and decide whether the skill should expose terminal-control workflows, app integration guidance, or only safe handoffs to existing shell and Codex GUI worktree guidance. - [ ] Add language validation triager roles after one shared contract is agreed: `python-skills:python-validation-triager`, `rust-skills:rust-validation-triager`, and `dotnet-skills:dotnet-validation-triager`, each report-first and scoped to logs, manifests, CI, test, tooling, package, and upgrade evidence. - [ ] Add Codex GUI local environment templates and auto-copy/install behavior to `dotnet-skills` for F#, C#, and mixed `.NET` repos, keeping setup/actions portable and preserving customized `.codex/environments/*.toml` files the same way the SwiftPM and Xcode workflows do. -- [ ] Revisit maybe-later subagent roles only after the owning plugin surface justifies them: `agent-engineering-skills:automation-plan-designer`, `swiftasb-skills:swiftasb-steward`, and `web-dev-skills:expo-native-boundary-scout`. +- [ ] Revisit maybe-later subagent roles only after the owning plugin surface justifies them: `agent-engineering-skills:automation-plan-designer` and `web-dev-skills:expo-native-boundary-scout`. - [ ] Keep write-heavy surfaces out of bundled roles for now: do not add `android-dev-skills:android-steward` or a `maintain-project-repo` worker role until those surfaces have enough read-heavy workflow evidence and safe boundaries. - [x] Grow Swift Steward from read-heavy guidance-sync and repo-maintenance scans into reviewable patch artifacts that can be saved, edited, or applied by the main thread, then decide whether any apply-mode behavior belongs in the main thread, a guarded report workflow, or a future repo-local sidecar. - [x] Turn the placeholder `android-dev-skills` child plugin into an installable Android guidance plugin. It covers Kotlin-first Android project work, Java interoperability or Java-only maintenance when a repo requires it, Gradle and Android Gradle Plugin alignment, emulator-aware validation, release readiness, and clear handoffs to existing mobile testing plugins instead of duplicating emulator tooling. @@ -1301,7 +1266,6 @@ and test/production deployments for GitHub Actions. - Completed the subtree workflow hardening milestone by documenting subtree add, pull, and push paths, adding the root marketplace audit pass, and adding a public child plugin removal checklist. - Completed [#35](https://github.com/gaelic-ghost/socket/issues/35) / [#37](https://github.com/gaelic-ghost/socket/issues/37) by hardening release and PR scripts around delayed GitHub state. - Completed [#39](https://github.com/gaelic-ghost/socket/issues/39) by adding the Swift Package Index add-package gate and one-shot script around the documented `SwiftPackageIndex/PackageList` Add Package issue form. -- Planned a `swiftasb-skills` child plugin to help agents explain SwiftASB and build SwiftUI, AppKit, and Swift package integrations from a Socket-visible guidance surface. - Added and exposed a `web-dev-skills` Expo inline native modules workflow for SDK 56+ inline Swift/Kotlin modules, `expo-type-information`, CNG/prebuild validation, and Apple Dev Skills handoffs. - Updated `repository-skills:maintain-project-repo` so heavy remote CI can be deferred after full local validation, branch push, PR creation, and initial check discovery, with Codex expected to use native thread Timer/Wakeup or heartbeat automation to resume the release instead of keeping an idle CI-waiting script open. - Added root `docs/media` screenshot assets and README media guidance so the Codex plugin-directory catalog surface is visible without weakening text-first documentation. diff --git a/docs/maintainers/claude-compatibility.json b/docs/maintainers/claude-compatibility.json index 2367fbcb5..15684a493 100644 --- a/docs/maintainers/claude-compatibility.json +++ b/docs/maintainers/claude-compatibility.json @@ -26,7 +26,6 @@ "server-side-swift": { "claudeCode": "supported", "cowork": "skills_only", "note": "Instruction-only workflows; Apple container execution requires a supported local Mac." }, "speak-swiftly": { "claudeCode": "not_supported", "cowork": "not_supported", "note": "The standalone payload auto-loads a Codex-only hook with a hard-coded Codex cache path; add a Claude-native payload there before exposing it here." }, "swift-lang": { "claudeCode": "supported", "cowork": "skills_only", "note": "Instruction-only workflows." }, - "swiftasb-skills": { "claudeCode": "supported", "cowork": "skills_only", "note": "Skill guidance is portable; live Codex app-server probes remain Codex-specific." }, "web-dev-skills": { "claudeCode": "supported", "cowork": "skills_only", "note": "Instruction-only workflows." } } } diff --git a/docs/maintainers/dotnet-skills-plugin-plan.md b/docs/maintainers/dotnet-skills-plugin-plan.md index c535c2446..d4d79368d 100644 --- a/docs/maintainers/dotnet-skills-plugin-plan.md +++ b/docs/maintainers/dotnet-skills-plugin-plan.md @@ -273,7 +273,7 @@ The first slice should be intentionally small but installable: - [x] Add `dotnet:build-fsharp-project`. - [x] Add `dotnet:build-csharp-project`. - [x] Add `dotnet:testing-workflow`. -- [x] Decide not to add per-skill `agents/openai.yaml` metadata in the first slice because this child plugin follows the existing SwiftASB skills shape. +- [x] Decide not to add per-skill `agents/openai.yaml` metadata in the first slice because the first plugin slice keeps metadata at the plugin level. - [x] Switch the root marketplace entry for `dotnet-skills` to installable only after real skill content exists. - [x] Update `README.md` and `ROADMAP.md` so Socket documents the new child plugin surface. - [x] Run `uv run scripts/validate_socket_metadata.py`. diff --git a/docs/maintainers/validation-and-test-audit.md b/docs/maintainers/validation-and-test-audit.md index 432693aa2..017f36944 100644 --- a/docs/maintainers/validation-and-test-audit.md +++ b/docs/maintainers/validation-and-test-audit.md @@ -1,185 +1,29 @@ -# Socket Validation and Test Audit +# Socket Integration Validation -## Decision Standard +Socket intentionally keeps one essential validation path and one root E2E. -This audit covers every validation gate and collected test in Socket as of -v10.0.1. A check remains only when it protects at least one current, shipped -contract: +## Live Gates -- executable behavior or error handling; -- marketplace, plugin, skill, or consumer compatibility; -- a destructive-action, credential, privacy, security, or release boundary; -- source ownership, routing, or discovery that changes agent behavior; or -- generated payload integrity. +- `just repo-validate` checks managed repository assets, all four canonical + documents, marketplace-to-plugin resolution, aligned plugin versions, Claude + marketplace references, and root-only test placement. +- `just test` installs the repository-maintenance payload into a temporary Git + repository and exercises docs apply, byte-idempotence, docs check, the exact + Just recipe surface, and installed validation end to end. +- `.github/workflows/validate-repo-maintenance.yml` invokes + `just repo-validate` without duplicating repository policy. -A check is not justified when it only records completed history, proves that a -retired thing remains absent, repeats another live gate without isolating its -behavior, or copies source tests into a generated payload. No absence regression -test is required when a surface is permanently removed. +## Test Ownership -Every retained test function is covered below by its owning file and exact test -count. The retained full profile collects 432 tests. +All tests live directly under the Socket root [`tests/`](../../tests/). Socket +does not retain nested plugin tests, skill-local tests, unit suites, per-file +docs tests, or permanent-absence regression tests. Shipped behavior is proved +through the current install-and-operate path. -## Live Validation Gates +## Commands -The repository has one CI entrypoint: `.github/workflows/validate-socket.yml`. -It runs the full profile on macOS because several shipped workflows and path -contracts are Apple-platform specific. The nested Apple and Python workflow -files were removed because GitHub does not load workflows below the repository -root and the root full profile already owns their checks. - -| Gate | Decision | Current contract | -| --- | --- | --- | -| Root marketplace metadata | Keep | Rejects malformed marketplace entries, missing local payloads, unsafe custom-agent permissions, invalid MCP transport, and broken interface assets. | -| Shared skill metadata | Keep | Enforces skill-directory identity and OpenAI interface shape across every authored plugin skill. | -| Root tests (160) | Keep | Exercises root release, export, compatibility, installer, safety, validator, and cross-plugin ownership behavior listed below. | -| Root mypy | Keep | Checks the typed root maintainer and release programs before they can mutate repository or release state. | -| Root Ruff | Keep | Rejects Python defects in the root scripts and their tests. | -| Hermes compatibility | Keep | Proves the generated skill tap, groupings, metadata limits, and translated MCP configuration are installable by Hermes. | -| Claude compatibility | Keep | Proves marketplace classification agrees with Claude/Cowork support and rejects unsupported local MCP claims. | -| Agent Engineering tests (12) | Keep | Protects the executable automation/evaluation workflow schemas, routing, and safety boundaries. | -| Agent Portability tests (13) | Keep | Protects protocol routing plus the two executable repository bootstrap/synchronization tools. | -| Agent Portability Ruff | Keep | Lints the shipped portability scripts, including files outside their focused tests. | -| Agent Portability mypy | Keep | Type-checks the shipped portability scripts and their structured payloads. | -| Apple docs/layout validator | Keep, pruned | Protects the active 58-skill inventory, discovery symlink, required skill structure, shared-snippet synchronization, docs-source order, repository-skill delegation, and skill-creator contract. Historical roadmap prose and retired-path assertions were removed. | -| Apple tests (227) | Keep | Exercises executable Apple workflow planners/configuration tools and safety-critical guidance contracts listed below. | -| Professional Skills tests (5) | Keep | Protects the Dice search workflow's request shaping, URL construction, filtering, and output behavior. | -| Python metadata | Keep | Validates the current Python plugin manifest, skill metadata, links, and agent interfaces. | -| Python tests (11) | Keep | Exercises all three scaffold generators and current agent/service/testing guidance contracts. | -| Python Ruff | Keep | Lints the shipped Python plugin scripts and tests. | -| Python mypy | Keep | Type-checks the shipped Python plugin scripts and structured inputs. | -| Cybersecurity metadata | Keep | Validates the current cybersecurity manifest, skill metadata, links, and agent interfaces. | -| Cybersecurity tests (2) | Keep | Protects routing between macOS threat work, platform controls, isolation, recovery, and reverse engineering. | -| Reverse Engineering metadata | Keep | Validates the current reverse-engineering manifest, skill metadata, links, and agent interfaces. | -| Reverse Engineering tests (2) | Keep | Protects public/private/runtime evidence separation and bounded-probe handoffs. | - -## Retained Root Tests - -| File | Tests | Justification | -| --- | ---: | --- | -| `tests/test_audit_skill_surfaces.py` | 7 | Exercises the shipped audit report in JSON/Markdown/file modes and enforces direct SwiftData guidance where a weak query-only answer changes implementation advice. | -| `tests/test_audit_xcode_plugin_compatibility.py` | 3 | Exercises plugin classification/reporting and proves every live marketplace entry is classified for Xcode compatibility. | -| `tests/test_check_acp_registry.py` | 3 | Exercises exact registry matching, schema loading, and the non-error missing-agent result used by the ACP workflow. | -| `tests/test_cleanup_legacy_socket_installs.py` | 5 | Protects deletion target selection, backup-before-remove behavior, cache exclusion, and reporting of foreign configuration. | -| `tests/test_cybersecurity_skill_contracts.py` | 14 | Protects authorization, isolation, evidence retention, non-binary verdicts, recovery, containment, detection fixtures, and specialist routing. | -| `tests/test_deployment_build_safety_contracts.py` | 3 | Protects clean GitHub builds, single-session build ownership, immutable deployment artifacts, and native-local/cloud-build separation. | -| `tests/test_macos_platform_security_forward_scenarios.py` | 1 | Parameterizes eight concrete TCC, sandbox, entitlement, threat, and private-evidence decisions that prevent unsafe platform advice. | -| `tests/test_macos_virtualization_forward_scenarios.py` | 1 | Parameterizes eight concrete VM/container/isolation decisions that prevent invalid macOS evidence claims. | -| `tests/test_macos_virtualization_skill_contracts.py` | 1 | Ensures every virtualization owner remains discoverable in both the Hermes export and public grouping. | -| `tests/test_model_lab_skill_contracts.py` | 9 | Protects current inventory/routing, authorization controls, experiment validation, paired comparison correctness, provenance stability, and version alignment. | -| `tests/test_release_version.py` | 4 | Exercises target discovery, aligned SemVer calculation, split-version rejection, and atomic manifest/lockfile updates. | -| `tests/test_release_workflow.py` | 10 | Exercises PR/check gating, branch ownership/accounting, version ordering, publication checks, evidence generation, and the single release CLI. | -| `tests/test_repository_maintenance_workflow.py` | 29 | Exercises generated validation/release assets, mandatory documentation creation and refresh, non-mutating reports, owner dispatch, bootstrap integration, workspace dispatch, delayed GitHub state, prerelease metadata, notes selection, branch accounting, triggers, and preservation of repo-owned extensions. | -| `tests/test_spi_add_package.py` | 10 | Exercises canonical URL/form construction, live form validation, package readiness, tag/toolchain checks, and the explicit prohibition on unauthorized alternate submission writes. | -| `tests/test_swiftasb_skills_install.py` | 2 | Performs a real temporary Codex marketplace install and verifies the published .NET skill inventory. | -| `tests/test_unified_swift_workspace_contracts.py` | 7 | Protects positive package/workspace context, component ownership, target layout, native-local/cloud deployment, immutable artifacts, and Soto lifecycle policy. | -| `tests/test_validate_claude_compatibility.py` | 3 | Exercises acceptance and the two unsupported-classification failures enforced by the live Claude gate. | -| `tests/test_validate_hermes_compatibility.py` | 9 | Exercises exact export comparison, grouping, metadata size, MCP translation, placeholder documentation, and stale/missing payload failures. | -| `tests/test_validate_socket.py` | 8 | Protects profile composition, non-duplication, macOS CI routing, dry-run behavior, and shared skill metadata acceptance/rejection. | -| `tests/test_validate_socket_metadata.py` | 18 | Exercises every supported marketplace source/MCP/interface/agent shape and the corresponding unsafe or malformed rejection paths. | - -## Retained Apple Tests - -These files test the shipped guidance itself because guidance is the plugin's -runtime product: removing a required safety, ownership, or handoff statement -changes what an agent will do. - -| File | Tests | Justification | -| --- | ---: | --- | -| `test_app_extension_workflows.py` | 4 | Process/product boundaries, MailKit privacy, File Provider/Finder ownership, and discoverability. | -| `test_appkit_app_architecture_workflow.py` | 4 | AppKit/SwiftUI ownership, restoration, observation, menu/status surfaces, and handoffs. | -| `test_apple_developer_provisioning_workflow.py` | 5 | Portal/API limits, credential and mutation confirmation, CloudKit token safety, discovery, and customization behavior. | -| `test_apple_ui_accessibility_workflow.py` | 4 | Framework breadth, accessibility handoffs, verification limits, and semantic tree examples. | -| `test_arkit_spatial_face_body_workflows.py` | 5 | Tracking capability boundaries, authentication limits, privacy/device evidence, metadata, and handoffs. | -| `test_author_swift_docc_docs.py` | 5 | Executable task inference, docs lookup/generation handoffs, defer policy, and blocked ambiguity. | -| `test_camera_capture_depth_workflow.py` | 4 | Capture lifecycle, capability/pressure handling, calibrated depth synchronization, metadata, and handoffs. | -| `test_core_animation_typography_workflows.py` | 3 | Layer ownership, Dynamic Type/font licensing, handoffs, metadata, and discovery. | -| `test_customization_cli.py` | 2 | Apply/effective/reset behavior and invalid override rejection. | -| `test_customization_template_paths.py` | 6 | Canonical template location, partial merge semantics, and YAML/schema rejection paths. | -| `test_design_animation_symbol_workflows.py` | 3 | Rendering/motion/accessibility boundaries plus metadata and discovery. | -| `test_devicecheck_app_attest_workflow.py` | 5 | DeviceCheck/App Attest separation, server trust, entitlement/docs limits, handoffs, and discovery. | -| `test_explore_apple_swift_docs_workflow.py` | 13 | Source-order routing, user preference, open-source fallback, approval-gated Dash installation, and structured generation. | -| `test_format_swift_sources_export.py` | 3 | SwiftFormat option inference, deterministic export, and plist input loading. | -| `test_imaging_foundation_workflows.py` | 4 | Core Image/representation ownership, conversion evidence, metadata, and customization. | -| `test_macos_platform_security_workflows.py` | 5 | TCC, sandbox authorization lifetime, entitlement evidence, discovery, and metadata. | -| `test_macos_virtualization_workflows.py` | 5 | Shape selection, guest/state identity, evidence boundaries, metadata, and customization. | -| `test_media_audio_workflows.py` | 4 | Framework/type ownership, repair guidance, references, discovery, and validation inventory. | -| `test_media_expansion_audit.py` | 4 | Complete media workflow structure, non-overlapping framework owners, privacy/device evidence, and public inventory. | -| `test_milestone24_system_ui_workflows.py` | 5 | Current App Intents, Liquid Glass, telemetry/distribution, Help Viewer, and Feedback Assistant boundaries; the filename is historical but assertions protect live guidance. | -| `test_photos_library_editing_workflow.py` | 4 | Picker/authorization, typed asset delivery, transactional editing, metadata, customization, and handoffs. | -| `test_safari_extension_control_workflow.py` | 5 | Extension-shape ownership, bounded controls, messaging privacy, and handoffs. | -| `test_safari_mcp_workflow.py` | 3 | Runtime-versus-architecture ownership, scoped evidence, registration, and data boundaries. | -| `test_structure_swift_sources_file_headers.py` | 4 | Header reporting, license preservation, deterministic replacement, and inventory schema. | -| `test_structure_swift_sources_todo_fixme_ledgers.py` | 3 | TODO/FIXME parsing, apply behavior, link rendering, stable IDs, and CLI JSON. | -| `test_structure_swift_sources_workflow.py` | 6 | Task inference, owner handoffs, runtime customization, and SwiftUI file-structure rules. | -| `test_swift_cleanup_skill_boundaries.py` | 2 | Keeps DocC work with its owning workflow from both cleanup entrypoints. | -| `test_swift_package_build_run_workflow.py` | 8 | Build planning, nested roots, resources/Metal routing, Xcode coexistence, handoffs, and blocked ambiguity. | -| `test_swift_package_extension_workflow.py` | 5 | Trait/macro/plugin planning, toolchain floor, Xcode coexistence, references, and routing. | -| `test_swift_package_testing_workflow.py` | 9 | Test planning/context, build/extension handoffs, coverage, accessibility/model scheduling, and blocked ambiguity. | -| `test_swiftui_app_architecture_workflow.py` | 6 | Scene/focus architecture, component ownership, preview rules, and handoffs. | -| `test_swiftui_component_audit_workflow.py` | 1 | Enforces declarative component ownership rather than external view-model indirection. | -| `test_tipkit_workflow.py` | 4 | Setup/presentation ownership, eligibility/test lifecycle, docs/handoffs, and discovery. | -| `test_tvos_workflows.py` | 4 | Focus/input, playback ownership, accessibility/device evidence, handoffs, and discovery. | -| `test_video_codec_processing_workflow.py` | 5 | Codec lifecycle, pixel/color/HDR fidelity, diagnostics, type ownership, metadata, and handoffs. | -| `test_vision_recognition_workflows.py` | 4 | Vision/Core ML ownership, provenance/evaluation, coordinate/confidence rules, metadata, and handoffs. | -| `test_xcode_build_run_workflow.py` | 9 | Build routing, workspace inference, customization, pbxproj mutation policy, XcodeGen ownership, and dependency recovery. | -| `test_xcode_coding_intelligence_workflow.py` | 8 | Setup/execution ownership, dated beta claims, system paths, MCP/ACP capability boundaries, and permissions. | -| `test_xcode_device_window_telemetry_debugger_workflows.py` | 4 | Device, window, telemetry, debugger, AgentDeck, privacy, and beta-loader ownership boundaries. | -| `test_xcode_localization_workflow.py` | 3 | String Catalog workflow, human-review/provenance limits, metadata, and discovery. | -| `test_xcode_testing_workflow.py` | 11 | Test routing, plans/context, mutation policy, accessibility/device evidence, XcodeGen, coverage, and model scheduling. | -| `test_xcode_toolchain_selection_guidance.py` | 3 | Explicit Xcode selection authority and stable/beta application boundaries. | -| `test_xcode_workspace_workflows.py` | 23 | Executable create/adopt/add/align behavior, canonical layout, pre-write blockers, evidence-preserving adoption, target/service routing, and workspace visibility. | - -## Retained Other Child Tests - -| File | Tests | Justification | -| --- | ---: | --- | -| Agent Engineering `test_design_agent_automation_workflow.py` | 7 | Protects automation workflow shape, triggers, approvals, tools, validation, and generated output. | -| Agent Engineering `test_design_agent_eval_workflow.py` | 5 | Protects evaluation scope, dataset/metric controls, execution routing, and report artifacts. | -| Agent Portability `test_bootstrap_skills_plugin_repo.py` | 4 | Exercises repository bootstrap output, idempotence, validation, and safe refusal behavior. | -| Agent Portability `test_sync_skills_repo_guidance.py` | 6 | Exercises guidance synchronization, preservation, validation commands, and error paths. | -| Agent Portability `test_agent_protocol_workflows.py` | 3 | Protects A2A/ACP role, security, and routing distinctions. | -| Cybersecurity `test_macos_security_handoffs.py` | 2 | Protects macOS platform-control and reverse-engineering handoffs. | -| Professional `test_dice_job_search_workflow.py` | 5 | Exercises the shipped search workflow's request, filtering, URL, and result contracts. | -| Python `test_build_python_agent_service_skill.py` | 3 | Protects local-first framework choice, exact model/approval disclosure, uv use, and bounded tool access. | -| Python `test_plugin_smoke.py` | 8 | Protects manifest/discovery, host-provided FastMCP docs, current workflow inventory, shared bootstrap ownership, and three scaffold generators. | -| Reverse Engineering `test_research_macos_security_control.py` | 2 | Protects evidence hierarchy, bounded probes, and owner handoffs. | - -## Validation Assets That Are Not Socket Gates - -These remain because they are executable parts of shipped skills, not duplicate -Socket CI: - -- `plugins/model-lab-skills/.../validate_experiment_manifest.py` validates user - experiment manifests and is exercised by the model-lab tests. -- `plugins/repository-skills/.../validate-all.sh`, its component dispatcher, and - the generic/Apple GitHub workflow templates are installed into other - repositories by `maintain-project-repo`; the repository-maintenance tests - exercise their generation and behavior. -- The corresponding root `skills/` copies are required runtime files in the - Hermes export. Source `tests/` directories are deliberately excluded because - they are maintainer-only and were redundant in the exported payload. - -## Removed Checks - -| Removed item | Reason | -| --- | --- | -| Three branch-added tests for `maintain-project-api` absence and centralized child caches/tooling | The user prohibited new tests and permanent-absence regression tests. | -| `test_retired_standalone_and_sync_skills_are_absent` | Only froze retired paths. | -| `test_retired_server_local_environment_templates_are_absent` | Only froze deleted templates. | -| `test_release_version_module_has_no_release_choreography_entrypoint` | Only proved removed attributes stayed absent. | -| Duplicate unified-workspace profile inventory test | The repository-maintenance suite already tests the actual accepted profile API and installer behavior. | -| `tests/test_root_agents_guidance.py` | Only synchronized historical prose about a removed local mirror and asserted its absence. | -| `plugins/apple-dev-skills/tests/test_customization_consolidation_review.py` (4 tests) | Locked a historical review document, exact counts, and completed roadmap milestones instead of live behavior. | -| Apple `test_roadmap_marks_milestone_complete` | Only locked a completed milestone. | -| Apple docs-validator pytest wrapper | Re-ran the same shell validator immediately after the full profile had already run it. | -| Python metadata pytest wrapper | Re-ran the same metadata validator immediately after the full profile had already run it. | -| Roadmap milestone assertions inside nine retained Apple discovery tests | Milestone completion is history; the useful metadata/discovery assertions remain. | -| Retired-name/path assertions in Python and unified-workspace tests | Current positive inventory and context behavior remain; deletion history is not a contract. | -| Historical/retired-path blocks in the Apple docs validator | They checked roadmap prose or permanent absence rather than current layout and ownership. | -| Nested Apple and Python workflow YAML files | Inert in this monorepo and duplicates of the root full profile. | -| Python `.github/scripts/validate_repo_docs.sh` | One-line forwarding wrapper to the directly invoked metadata validator. | -| Exported `skills/*/tests` copies (2 directories, 10 duplicate definitions) | Not collected by Socket and not needed by Hermes consumers; authored source tests remain and still run. | - -The collected full-suite count changed from 447 to 432: 12 pre-existing -unjustified tests and the 3 newly added tests were removed. No test was added. +```bash +just docs-check +just repo-validate +just test +``` diff --git a/plugins/agent-engineering-skills/skills/design-agent-automation-workflow/tests/test_design_agent_automation_workflow.py b/plugins/agent-engineering-skills/skills/design-agent-automation-workflow/tests/test_design_agent_automation_workflow.py deleted file mode 100644 index f02ff380a..000000000 --- a/plugins/agent-engineering-skills/skills/design-agent-automation-workflow/tests/test_design_agent_automation_workflow.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import yaml - - -SKILL_ROOT = Path(__file__).resolve().parents[1] - - -def read(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def frontmatter(text: str) -> dict[str, str]: - assert text.startswith("---\n") - _empty, raw_yaml, _body = text.split("---", 2) - return yaml.safe_load(raw_yaml) - - -def test_skill_metadata_names_framework_neutral_scope() -> None: - metadata = frontmatter(read(SKILL_ROOT / "SKILL.md")) - - assert metadata["name"] == "design-agent-automation-workflow" - description = metadata["description"] - for required in [ - "Codex app automations", - "codex exec", - "Codex subagents", - "OpenAI Agents SDK", - "LangGraph", - "Hermes", - "full-auto", - "auto-with-escalation", - "no automation yet", - ]: - assert required in description - - -def test_skill_body_preserves_planning_not_runtime_boundary() -> None: - body = read(SKILL_ROOT / "SKILL.md") - - assert "framework-neutral planning surface" in body - assert "Do not implement framework runtime code" in body - assert "Do not wrap OpenAI Agents SDK, LangGraph, Hermes, or Codex runtimes" in body - assert "Prefer safe full automation" in body - assert "Use human review only for the exact" in body - assert "Return a concise plan with these sections" in body - assert "local-agent-frameworks.md" in body - assert "capability check" in body - - -def test_openai_interface_metadata_matches_skill() -> None: - metadata = yaml.safe_load(read(SKILL_ROOT / "agents" / "openai.yaml")) - interface = metadata["interface"] - - assert interface["display_name"] == "Design Agent Automation Workflow" - assert "agent or automation surface" in interface["short_description"] - assert "$design-agent-automation-workflow" in interface["default_prompt"] - assert "safe full automation" in interface["default_prompt"] - assert "auto-with-escalation" in interface["default_prompt"] - assert "implementation handoff" in interface["default_prompt"] - - -def test_framework_reference_covers_all_selection_surfaces() -> None: - reference = read(SKILL_ROOT / "references" / "framework-selection.md") - - for required in [ - "Codex app automation", - "`codex exec` or Codex GitHub Action", - "Codex subagents", - "OpenAI Agents SDK service", - "LangGraph graph", - "Hermes-specific workflow", - "Full-auto execution", - "Auto-with-escalation", - "No automation yet", - ]: - assert required in reference - - for official_link in [ - "https://developers.openai.com/codex/app/automations", - "https://developers.openai.com/codex/noninteractive", - "https://developers.openai.com/codex/subagents", - "https://developers.openai.com/api/docs/guides/agents", - "https://docs.langchain.com/oss/python/langgraph/overview", - "https://hermes-agent.nousresearch.com/docs", - ]: - assert official_link in reference - - -def test_local_framework_reference_keeps_local_inference_separate() -> None: - reference = read(SKILL_ROOT / "references" / "local-agent-frameworks.md") - - for required in [ - "Inference Server Is Not The Agent Framework", - "OpenAI Agents SDK", - "LangChain and LangGraph", - "LlamaIndex", - "n8n", - "Google Agent Development Kit (ADK)", - "Pydantic AI", - "AutoGen", - "CrewAI", - "Semantic Kernel", - "Ollama", - "LM Studio", - "tool calling", - "structured output", - "read-only", - "auto-with-escalation", - ]: - assert required in reference - - for official_link in [ - "https://developers.openai.com/api/docs/guides/agents", - "https://docs.langchain.com/oss/python/langgraph/overview", - "https://docs.llamaindex.ai/en/latest/understanding/agent/structured_output/", - "https://n8n.io/integrations/ollama/", - "https://adk.dev/", - "https://pydantic.dev/docs/ai/models/ollama/", - "https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/models.html", - "https://learn.microsoft.com/en-us/semantic-kernel/overview/", - ]: - assert official_link in reference - - -def test_plan_template_has_required_output_sections() -> None: - template = read(SKILL_ROOT / "references" / "automation-plan-template.md") - - for heading in [ - "## Recommendation", - "## Not Chosen", - "## State And Safety", - "## Scaffold", - "## Validation", - "## Handoff", - "## Sources", - ]: - assert heading in template - - -def test_n8n_workflow_skill_keeps_visual_automation_deterministic() -> None: - skill = (SKILL_ROOT.parent / "design-n8n-agent-workflow" / "SKILL.md").read_text( - encoding="utf-8" - ) - - for required in [ - "deterministic", - "idempotency", - "draft-only", - "auto-with-escalation", - "capability gate", - ]: - assert required in skill diff --git a/plugins/agent-engineering-skills/skills/design-agent-eval-workflow/tests/test_design_agent_eval_workflow.py b/plugins/agent-engineering-skills/skills/design-agent-eval-workflow/tests/test_design_agent_eval_workflow.py deleted file mode 100644 index 54176e7ea..000000000 --- a/plugins/agent-engineering-skills/skills/design-agent-eval-workflow/tests/test_design_agent_eval_workflow.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import yaml - - -SKILL_ROOT = Path(__file__).resolve().parents[1] - - -def read(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def frontmatter(text: str) -> dict[str, str]: - assert text.startswith("---\n") - _empty, raw_yaml, _body = text.split("---", 2) - return yaml.safe_load(raw_yaml) - - -def test_skill_metadata_names_eval_scope() -> None: - metadata = frontmatter(read(SKILL_ROOT / "SKILL.md")) - - assert metadata["name"] == "design-agent-eval-workflow" - description = metadata["description"] - for required in [ - "agent", - "skill", - "prompt", - "automation", - "full-auto", - "OpenAI Agents SDK", - "LangGraph", - ]: - assert required in description - - -def test_skill_body_prefers_safe_full_automation() -> None: - body = read(SKILL_ROOT / "SKILL.md") - - assert "Prefer full automation" in body - assert "Use human-in-the-loop only for the exact decision" in body - assert "auto-with-escalation" in body - assert "manual-only-for-now" in body - - -def test_openai_interface_metadata_matches_skill() -> None: - metadata = yaml.safe_load(read(SKILL_ROOT / "agents" / "openai.yaml")) - interface = metadata["interface"] - - assert interface["display_name"] == "Design Agent Eval Workflow" - assert "agent, skill, prompt, and automation behavior" in interface["short_description"] - assert "$design-agent-eval-workflow" in interface["default_prompt"] - assert "full-auto" in interface["default_prompt"] - - -def test_eval_surface_reference_covers_runtime_choices() -> None: - reference = read(SKILL_ROOT / "references" / "eval-surface-selection.md") - - for required in [ - "Local script or pytest", - "`codex exec` or Codex GitHub Action", - "Codex app automation", - "OpenAI Agents SDK eval/tracing", - "LangGraph or LangSmith eval", - "Stack-owned runner", - ]: - assert required in reference - - for official_link in [ - "https://developers.openai.com/codex/noninteractive", - "https://developers.openai.com/codex/github-action", - "https://developers.openai.com/api/docs/guides/evals", - "https://docs.langchain.com/oss/python/langgraph/overview", - "https://docs.smith.langchain.com/evaluation", - ]: - assert official_link in reference - - -def test_eval_plan_template_has_required_output_sections() -> None: - template = read(SKILL_ROOT / "references" / "eval-plan-template.md") - - for heading in [ - "## Recommendation", - "## Behavior Under Test", - "## Case Set", - "## Graders", - "## Safety Gates", - "## Run Cadence", - "## Scaffold", - "## Handoff", - "## Sources", - ]: - assert heading in template diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/tests/test_bootstrap_skills_plugin_repo.py b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/tests/test_bootstrap_skills_plugin_repo.py deleted file mode 100644 index 2f0065218..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/tests/test_bootstrap_skills_plugin_repo.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import importlib.util -import os -import sys -from pathlib import Path - - -def _load_module(): - module_path = Path(__file__).resolve().parents[1] / "scripts" / "bootstrap_skills_plugin_repo.py" - spec = importlib.util.spec_from_file_location("bootstrap_skills_plugin_repo", module_path) - assert spec is not None - assert spec.loader is not None - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod - - -m = _load_module() - - -def test_audit_repo_flags_missing_paths(tmp_path: Path) -> None: - findings = m.audit_repo(tmp_path, "example-skills") - - issue_ids = {finding.issue_id for finding in findings} - assert "missing-path" in issue_ids - assert "missing-symlink" in issue_ids - - -def test_apply_repo_creates_expected_discovery_mirrors(tmp_path: Path) -> None: - actions, created_paths = m.apply_repo(tmp_path, "example-skills") - - assert any(action["action"] == "create-symlink" for action in actions) - assert (tmp_path / ".agents" / "skills").is_symlink() - assert os.readlink(tmp_path / ".agents" / "skills") == "../skills" - assert "README.md" in created_paths - assert "AGENTS.md" in created_paths - agents_text = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") - assert "check the current OpenAI Codex docs" in agents_text - assert "`hooks/`" in agents_text - assert "Default user-facing Codex plugin install and update guidance to Git-backed marketplace sources" in agents_text - assert "Resolve shared project dependencies only from GitHub repository URLs" in agents_text - assert "Machine-local dependency paths are expressly prohibited" in agents_text - - -def test_audit_repo_flags_forbidden_nested_plugin_dir(tmp_path: Path) -> None: - (tmp_path / "plugins").mkdir(parents=True) - - findings = m.audit_repo(tmp_path, "example-skills") - - assert any(finding.issue_id == "forbidden-path" for finding in findings) - - -def test_skill_routes_github_settings_to_repo_maintenance_owner() -> None: - skill_text = (Path(__file__).resolve().parents[1] / "SKILL.md").read_text(encoding="utf-8") - - assert "repository-skills:maintain-github-repository" in skill_text - assert "web commit sign-off when DCO applies" in skill_text - assert "documented maintainer direct-push workflow" in skill_text diff --git a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/tests/test_sync_skills_repo_guidance.py b/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/tests/test_sync_skills_repo_guidance.py deleted file mode 100644 index 978b40f5c..000000000 --- a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/tests/test_sync_skills_repo_guidance.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import importlib.util -import os -import sys -from pathlib import Path - - -def _load_module(): - module_path = Path(__file__).resolve().parents[1] / "scripts" / "sync_skills_repo_guidance.py" - spec = importlib.util.spec_from_file_location("sync_skills_repo_guidance", module_path) - assert spec is not None - assert spec.loader is not None - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod - - -m = _load_module() - - -def _write_repo(repo_root: Path, _plugin_name: str) -> None: - (repo_root / "skills" / "example-skill").mkdir(parents=True) - (repo_root / "docs" / "maintainers").mkdir(parents=True) - (repo_root / "README.md").write_text( - "\n".join( - [ - "Installable maintainer skills for skills-export and plugin-export repositories.", - "OpenAI's documented Codex plugin system exposes repo-visible plugins through marketplace catalogs and does not document a richer repo-private scoping model beyond that.", - "codex plugin marketplace add gaelic-ghost/socket", - "codex plugin marketplace upgrade socket", - "`agent-portability-skills` entry points at `./plugins/agent-portability-skills`", - "Git-backed marketplace sources", - "dev dependencies in `pyproject.toml`", - "`pytest`, `ruff`, and `mypy`", - "`\"skills\": \"./skills/\"`", - "Only `plugin.json` belongs in `.codex-plugin/`", - "refresh the official OpenAI docs", - ] - ) - + "\n", - encoding="utf-8", - ) - (repo_root / "AGENTS.md").write_text( - "\n".join( - [ - "canonical authored and exported surface", - 'manifest points to bundled skills with `"skills": "./skills/"`', - "`hooks/`", - "Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories", - "Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly", - "Default user-facing install and update guidance to Git-backed marketplace sources", - "`skills/install-plugin-to-socket`", - "`skills/validate-plugin-install-surfaces`", - "check the current OpenAI Codex docs", - ] - ) - + "\n", - encoding="utf-8", - ) - (repo_root / ".gitignore").write_text(".venv/\n", encoding="utf-8") - (repo_root / ".codex-plugin").mkdir() - (repo_root / ".codex-plugin" / "plugin.json").write_text('{"skills": "./skills/"}\n', encoding="utf-8") - (repo_root / "docs" / "maintainers" / "reality-audit.md").write_text( - "\n".join( - [ - "This repository ships root `.codex-plugin` packaging and does not track a nested staged plugin directory for itself.", - 'Its plugin manifest must declare `"skills": "./skills/"`', - "user installs normally come through the Git-backed `socket` marketplace", - "This repository does not ship `install-plugin-to-socket`.", - "This repository does not ship `validate-plugin-install-surfaces`.", - ] - ) - + "\n", - encoding="utf-8", - ) - (repo_root / "docs" / "maintainers" / "codex-plugin-install-surfaces.md").write_text( - "\n".join( - [ - "only `plugin.json` belongs in `.codex-plugin/`", - 'plugin manifests point to bundled skill folders with a root-relative `"skills": "./skills/"` field', - "Tracked marketplace source", - "Preferred User Install And Update Path", - "codex plugin marketplace add gaelic-ghost/socket", - "codex plugin marketplace upgrade socket", - "Documented plugin path: `~/.codex/config.toml`", - "If you mention project-scoped `.codex/config.toml`, label it as a general Codex config capability rather than part of the documented plugin install-surface map.", - "first route through the Codex harness surfaces that are already available in the current session", - "install the plugin through Codex's plugin directory for future sessions", - ] - ) - + "\n", - encoding="utf-8", - ) - (repo_root / ".agents").mkdir() - os.symlink("../skills", repo_root / ".agents" / "skills") - - -def test_audit_repo_accepts_expected_repo_shape(tmp_path: Path) -> None: - _write_repo(tmp_path, "example-skills") - - findings = m.audit_repo(tmp_path, "example-skills") - - assert findings == [] - - -def test_audit_repo_flags_missing_guidance_and_forbidden_path(tmp_path: Path) -> None: - (tmp_path / "plugins").mkdir(parents=True) - (tmp_path / "AGENTS.md").write_text("", encoding="utf-8") - (tmp_path / "docs" / "maintainers").mkdir(parents=True) - (tmp_path / "docs" / "maintainers" / "reality-audit.md").write_text("", encoding="utf-8") - - findings = m.audit_repo(tmp_path, "example-skills") - - issue_ids = {finding.issue_id for finding in findings} - assert "agents-missing-snippet" in issue_ids - assert "missing-symlink" in issue_ids - assert "forbidden-path" in issue_ids - assert "missing-plugin-manifest" in issue_ids - - -def test_audit_repo_accepts_missing_readme(tmp_path: Path) -> None: - _write_repo(tmp_path, "example-skills") - (tmp_path / "README.md").unlink() - - findings = m.audit_repo(tmp_path, "example-skills") - - assert findings == [] - - -def test_audit_repo_does_not_require_optional_maintainer_docs(tmp_path: Path) -> None: - _write_repo(tmp_path, "example-skills") - (tmp_path / "docs" / "maintainers" / "reality-audit.md").unlink() - (tmp_path / "docs" / "maintainers" / "codex-plugin-install-surfaces.md").unlink() - - findings = m.audit_repo(tmp_path, "example-skills") - - assert findings == [] - - -def test_audit_repo_flags_manifest_without_skills_component(tmp_path: Path) -> None: - _write_repo(tmp_path, "example-skills") - (tmp_path / ".codex-plugin" / "plugin.json").write_text("{}\n", encoding="utf-8") - - findings = m.audit_repo(tmp_path, "example-skills") - - assert any(finding.issue_id == "missing-skills-component" for finding in findings) - - -def test_skill_audits_github_settings_through_repo_maintenance_owner() -> None: - skill_text = (Path(__file__).resolve().parents[1] / "SKILL.md").read_text(encoding="utf-8") - - assert "repository-skills:maintain-github-repository" in skill_text - assert "Keep this audit read-only unless the user requested settings changes" in skill_text - assert "visibility changes" in skill_text diff --git a/plugins/agent-portability-skills/tests/test_agent_protocol_workflows.py b/plugins/agent-portability-skills/tests/test_agent_protocol_workflows.py deleted file mode 100644 index 9a22b7ebe..000000000 --- a/plugins/agent-portability-skills/tests/test_agent_protocol_workflows.py +++ /dev/null @@ -1,46 +0,0 @@ -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def read(relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - -def test_a2a_skill_keeps_protocol_roles_and_security_distinct() -> None: - skill = read("skills/operate-a2a-agent-integration/SKILL.md") - reference = read( - "skills/operate-a2a-agent-integration/references/a2a-operations-map.md" - ) - - for term in ("Agent Card", "contextId", "taskId", "input-required"): - assert term in skill - assert "editor-to-agent hosting (ACP)" in skill - assert "agent-to-tool calls (MCP)" in skill - assert "SSRF controls" in skill - assert "Hermes 0.20 Surface" in reference - - -def test_protocol_chooser_routes_peer_agents_to_a2a() -> None: - skill = read("skills/choose-agent-integration-protocol/SKILL.md") - reference = read( - "skills/choose-agent-integration-protocol/references/protocol-decision-map.md" - ) - - assert "Use A2A when independently operated agents" in skill - assert "operate-a2a-agent-integration" in skill - assert "three distinct trust, lifecycle, and permission boundaries" in reference - - -def test_acp_guidance_separates_latest_v1_from_draft_work() -> None: - operator = read("skills/operate-acp-agent-integration/SKILL.md") - builder = read("skills/build-acp-agent/SKILL.md") - implementation = read("skills/build-acp-agent/references/acp-implementation-map.md") - - for text in (operator, builder, implementation): - assert "ACP v1" in text - assert "v2" in text - assert "draft RFD" in operator - assert "session/close" in implementation - assert "additional directories" in implementation diff --git a/plugins/agentdeck/docs/desktop-bridge-mcp-skill-plan.md b/plugins/agentdeck/docs/desktop-bridge-mcp-skill-plan.md index f3793e523..515ffa15d 100644 --- a/plugins/agentdeck/docs/desktop-bridge-mcp-skill-plan.md +++ b/plugins/agentdeck/docs/desktop-bridge-mcp-skill-plan.md @@ -20,7 +20,6 @@ This split keeps the macOS trust boundary stable while preserving a hot-swappabl It does not belong in: - `apple-dev-skills`, because the MCP/skill is for using the desktop, not building Apple apps. -- `swiftasb-skills`, because the initial runtime should not make SwiftASB own the macOS trust boundary. - `agent-engineering-skills`, because this is a concrete local utility, not general automation-design guidance. - A standalone plugin payload, because Socket should expose the Codex-facing adapter and keep the installed app separate. diff --git a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md index 886325a7c..325492cc6 100644 --- a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md @@ -71,8 +71,6 @@ root `Controllers/` directory. library workflow when the real issue is not AppKit app structure. - Do not use this skill as the primary path for Xcode execution, signing, target-membership, sandbox, entitlement, or test mechanics. -- Do not absorb SwiftASB-specific runtime guidance; use the SwiftASB skills when - the AppKit app is specifically integrating SwiftASB. ## Single-Path Workflow @@ -136,7 +134,7 @@ root `Controllers/` directory. - the documented Apple behavior relied on - any anti-pattern correction - one handoff when the work is really docs lookup, SwiftUI architecture, - execution, accessibility, or SwiftASB integration + execution, or accessibility ## Inputs @@ -200,7 +198,7 @@ root `Controllers/` directory. - Do not hide controller lifetimes behind broad coordinators, managers, command buses, or wrappers unless a concrete ownership problem requires that surface. - Do not silently absorb raw Apple-docs lookup, SwiftUI architecture, - accessibility work, Xcode execution, or SwiftASB integration. + accessibility work, or Xcode execution. - Stop with `blocked` when the request is too vague to determine whether the issue is app-level, status-item-level, window-level, controller-level, document-level, model-level, or hosted-SwiftUI structure. @@ -223,9 +221,6 @@ root `Controllers/` directory. or test diagnosis. - Recommend `apple-ui-accessibility-workflow` when the next honest step is accessibility-specific implementation or review. -- Recommend SwiftASB skills when the AppKit question is specifically about - adding, diagnosing, or explaining SwiftASB integration. - ## Customization Use `references/customization-flow.md`. diff --git a/plugins/apple-dev-skills/tests/test_app_extension_workflows.py b/plugins/apple-dev-skills/tests/test_app_extension_workflows.py deleted file mode 100644 index 01c6e29d9..000000000 --- a/plugins/apple-dev-skills/tests/test_app_extension_workflows.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class AppExtensionWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_architecture_skill_keeps_process_and_product_boundaries_explicit(self) -> None: - skill = self.read("skills/app-extension-architecture-workflow/SKILL.md") - - self.assertIn("separate process", skill) - self.assertIn("App Group", skill) - self.assertIn("MailKit", skill) - self.assertIn("File Provider", skill) - self.assertIn("Messages/iMessage collaboration", skill) - self.assertIn("Do not add a generic coordinator", skill) - self.assertIn("xcode-build-run-workflow", skill) - self.assertIn("xcode-testing-workflow", skill) - - def test_mailkit_skill_covers_each_handler_and_private_mail_boundary(self) -> None: - skill = self.read("skills/mailkit-workflow/SKILL.md") - reference = self.read("skills/mailkit-workflow/references/mailkit-capabilities-and-handler-boundaries.md") - - for handler in ( - "MEContentBlocker", - "MEMessageActionHandler", - "MEComposeSessionHandler", - "MEMessageSecurityHandler", - ): - self.assertIn(handler, skill) - self.assertIn(handler, reference) - self.assertIn("Do not retain or log raw message data", skill) - self.assertIn("enabled extensions", reference) - - def test_file_provider_and_finder_sync_have_non_overlapping_ownership(self) -> None: - skill = self.read("skills/file-provider-and-finder-sync-workflow/SKILL.md") - finder = self.read("skills/file-provider-and-finder-sync-workflow/references/finder-sync-boundaries.md") - - self.assertIn("File Provider for remote storage synchronization", skill) - self.assertIn("Finder Sync only", skill) - self.assertIn("Do not recommend Finder Sync as the implementation of remote storage synchronization", skill) - self.assertIn("does not provide a remote storage domain", finder) - self.assertIn("FIFinderSyncController", finder) - - def test_inventory_and_metadata_include_the_new_skills(self) -> None: - validator = self.read(".github/scripts/validate_repo_docs.sh") - readme = self.read("README.md") - manifest = self.read(".codex-plugin/plugin.json") - - for skill in ( - "app-extension-architecture-workflow", - "mailkit-workflow", - "file-provider-and-finder-sync-workflow", - ): - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - self.assertIn(f"`{skill}`", readme) - self.assertIn("mailkit", manifest) - self.assertIn("file-provider", manifest) - self.assertIn("Expected exactly 58 active skills", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_appkit_app_architecture_workflow.py b/plugins/apple-dev-skills/tests/test_appkit_app_architecture_workflow.py deleted file mode 100644 index d114f6e78..000000000 --- a/plugins/apple-dev-skills/tests/test_appkit_app_architecture_workflow.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class AppKitAppArchitectureWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_keeps_menu_bar_and_status_item_first_class(self) -> None: - skill_text = self.read("skills/appkit-app-architecture-workflow/SKILL.md") - status_text = self.read( - "skills/appkit-app-architecture-workflow/references/menu-bar-status-item-and-activation.md" - ) - - self.assertIn("NSStatusItem", skill_text) - self.assertIn("menu bar", skill_text) - self.assertIn("activation policy", status_text) - self.assertIn("SwiftUI `MenuBarExtra`", status_text) - - def test_skill_covers_restoration_archiving_and_observation(self) -> None: - restoration_text = self.read( - "skills/appkit-app-architecture-workflow/references/restoration-documents-and-workspaces.md" - ) - archiving_text = self.read( - "skills/appkit-app-architecture-workflow/references/archiving-persistence-and-migration.md" - ) - observation_text = self.read( - "skills/appkit-app-architecture-workflow/references/observation-and-appkit.md" - ) - - self.assertIn("NSWindowRestoration", restoration_text) - self.assertIn("restoration identifiers", restoration_text) - self.assertIn("NSSecureCoding", archiving_text) - self.assertIn("NSKeyedArchiver", archiving_text) - self.assertIn("@Observable", observation_text) - self.assertIn("Do not assume AppKit controls automatically re-render", observation_text) - - def test_skill_handoffs_stay_explicit(self) -> None: - skill_text = self.read("skills/appkit-app-architecture-workflow/SKILL.md") - prompt_text = self.read("skills/appkit-app-architecture-workflow/agents/openai.yaml") - - self.assertIn("Recommend `swiftui-app-architecture-workflow`", skill_text) - self.assertIn("Recommend `explore-apple-swift-docs`", skill_text) - self.assertIn("Recommend `apple-ui-accessibility-workflow`", skill_text) - self.assertIn("Recommend `xcode-build-run-workflow`", skill_text) - self.assertIn("Recommend `xcode-testing-workflow`", skill_text) - self.assertIn("$swiftui-app-architecture-workflow", prompt_text) - self.assertIn("$explore-apple-swift-docs", prompt_text) - self.assertIn("$xcode-build-run-workflow", prompt_text) - self.assertIn("$xcode-testing-workflow", prompt_text) - - def test_mixed_appkit_swiftui_reference_names_single_owner(self) -> None: - mixed_text = self.read( - "skills/appkit-app-architecture-workflow/references/mixed-appkit-swiftui-composition.md" - ) - anti_patterns_text = self.read( - "skills/appkit-app-architecture-workflow/references/anti-patterns-and-corrections.md" - ) - - self.assertIn("Name the owner first", mixed_text) - self.assertIn("NSHostingController", mixed_text) - self.assertIn("NSHostingView", mixed_text) - self.assertIn("Split Ownership Across Frameworks", anti_patterns_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_apple_developer_provisioning_workflow.py b/plugins/apple-dev-skills/tests/test_apple_developer_provisioning_workflow.py deleted file mode 100644 index 5cb0e0808..000000000 --- a/plugins/apple-dev-skills/tests/test_apple_developer_provisioning_workflow.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class AppleDeveloperProvisioningWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_distinguishes_supported_and_portal_only_operations(self) -> None: - skill = self.read("skills/apple-developer-provisioning-workflow/SKILL.md") - portal = self.read("skills/apple-developer-provisioning-workflow/references/portal-only-configuration.md") - - self.assertIn("registered bundle IDs, supported capabilities, certificates, devices, and provisioning profiles", skill) - self.assertIn("App Group registration or assignment", skill) - self.assertIn("CloudKit container registration or assignment", skill) - self.assertIn("Service ID registration", skill) - self.assertIn("not an invitation to reverse engineer the website", portal) - - def test_skill_requires_safe_credentials_planning_and_confirmation(self) -> None: - skill = self.read("skills/apple-developer-provisioning-workflow/SKILL.md") - provisioning = self.read("skills/apple-developer-provisioning-workflow/references/app-store-connect-provisioning.md") - - self.assertIn("individual API keys cannot use provisioning endpoints", skill) - self.assertIn("Enterprise Program accounts use Apple’s separate Enterprise Program API", skill) - self.assertIn("short-lived JWT", skill) - self.assertIn("requires an explicit confirmation immediately before every", skill) - self.assertIn("never place them in the repo", skill) - self.assertIn("team API key", provisioning) - self.assertIn("team API keys are unavailable in that program", provisioning) - - def test_cloudkit_paths_remain_local_and_token_safe(self) -> None: - skill = self.read("skills/apple-developer-provisioning-workflow/SKILL.md") - cloudkit = self.read("skills/apple-developer-provisioning-workflow/references/cloudkit-automation.md") - - self.assertIn("xcrun cktool save-token --type management", skill) - self.assertIn("@apple/cktool.database", skill) - self.assertIn("@apple/cktool.target.nodejs", cloudkit) - self.assertIn("pnpm add", cloudkit) - self.assertIn("never put the token in source", cloudkit) - - def test_inventory_and_metadata_are_updated(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - self.assertIn("apple-developer-provisioning-workflow", readme) - self.assertIn("Apple Developer provisioning", plugin) - self.assertIn("./skills/apple-developer-provisioning-workflow/SKILL.md", validator) - self.assertIn("Expected exactly 58 active skills", validator) - - def test_customization_cli_preserves_shared_apply_and_reset_verbs(self) -> None: - script = ROOT / "skills/apple-developer-provisioning-workflow/scripts/customization_config.py" - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - env["UV_CACHE_DIR"] = str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache") - override = Path(tmpdir) / "override.yaml" - override.write_text( - "schemaVersion: 1\nisCustomized: true\nsettings:\n preferredDiscoveryMode: rest-first\n", - encoding="utf-8", - ) - - apply = subprocess.run([str(script), "apply", "--input", str(override)], env=env, capture_output=True, text=True) - self.assertEqual(apply.returncode, 0, apply.stderr) - expected = Path(tmpdir) / "apple-developer-provisioning-workflow/customization.yaml" - self.assertEqual(Path(apply.stdout.strip()), expected) - self.assertTrue(expected.is_file()) - - effective = subprocess.run([str(script), "effective"], env=env, capture_output=True, text=True) - self.assertEqual(effective.returncode, 0, effective.stderr) - self.assertIn("preferredDiscoveryMode: rest-first", effective.stdout) - - reset = subprocess.run([str(script), "reset"], env=env, capture_output=True, text=True) - self.assertEqual(reset.returncode, 0, reset.stderr) - self.assertEqual(Path(reset.stdout.strip()), expected) - self.assertFalse(expected.exists()) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_apple_ui_accessibility_workflow.py b/plugins/apple-dev-skills/tests/test_apple_ui_accessibility_workflow.py deleted file mode 100644 index 251b07722..000000000 --- a/plugins/apple-dev-skills/tests/test_apple_ui_accessibility_workflow.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class AppleUIAccessibilityWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_keeps_swiftui_first_but_framework_broad(self) -> None: - skill_text = self.read("skills/apple-ui-accessibility-workflow/SKILL.md") - bridge_text = self.read("skills/apple-ui-accessibility-workflow/references/framework-bridging-uikit-appkit.md") - examples_text = self.read("skills/apple-ui-accessibility-workflow/references/worked-swiftui-accessibility-examples.md") - - self.assertIn("SwiftUI-first", skill_text) - self.assertIn("UIKit", skill_text) - self.assertIn("AppKit", skill_text) - self.assertIn("UIViewRepresentable", bridge_text) - self.assertIn("NSViewRepresentable", bridge_text) - self.assertIn("StepsBars", examples_text) - self.assertIn("RingView", examples_text) - - def test_skill_handoffs_stay_explicit(self) -> None: - skill_text = self.read("skills/apple-ui-accessibility-workflow/SKILL.md") - prompt_text = self.read("skills/apple-ui-accessibility-workflow/agents/openai.yaml") - - self.assertIn("Recommend `explore-apple-swift-docs`", skill_text) - self.assertIn("Recommend `xcode-testing-workflow`", skill_text) - self.assertIn("Recommend `swiftui-app-architecture-workflow`", skill_text) - self.assertIn("$explore-apple-swift-docs", prompt_text) - self.assertIn("$xcode-testing-workflow", prompt_text) - self.assertIn("$swiftui-app-architecture-workflow", prompt_text) - - def test_verification_reference_draws_the_boundary_clearly(self) -> None: - verification_text = self.read("skills/apple-ui-accessibility-workflow/references/verification-expectations.md") - - self.assertIn("VoiceOver", verification_text) - self.assertIn("Dynamic Type", verification_text) - self.assertIn("reduced motion", verification_text) - self.assertIn("xcode-testing-workflow", verification_text) - - def test_semantics_and_tree_shaping_references_include_worked_examples(self) -> None: - semantics_text = self.read("skills/apple-ui-accessibility-workflow/references/swiftui-accessibility-semantics.md") - tree_text = self.read("skills/apple-ui-accessibility-workflow/references/swiftui-accessibility-tree-shaping.md") - - self.assertIn("FavoriteButton", semantics_text) - self.assertIn("accessibilityValue", semantics_text) - self.assertIn("SettingsCard", tree_text) - self.assertIn("accessibilityElement(children: .combine)", tree_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_arkit_spatial_face_body_workflows.py b/plugins/apple-dev-skills/tests/test_arkit_spatial_face_body_workflows.py deleted file mode 100644 index ccb925a5f..000000000 --- a/plugins/apple-dev-skills/tests/test_arkit_spatial_face_body_workflows.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class ARKitSpatialFaceBodyWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_spatial_skill_covers_world_depth_mesh_maps_and_raycasting(self) -> None: - skill = self.read("skills/arkit-spatial-sensing-workflow/SKILL.md") - world = self.read( - "skills/arkit-spatial-sensing-workflow/references/world-tracking-depth-meshes-and-maps.md" - ) - for term in ( - "ARSession", - "ARWorldTrackingConfiguration", - "ARCamera.TrackingState", - "ray casting", - "sceneDepth", - "smoothedSceneDepth", - "supportsSceneReconstruction", - "ARMeshAnchor", - "ARMeshGeometry", - "ARWorldMap", - "relocalization", - "ARReferenceObject", - "geographic anchors", - ): - self.assertIn(term, skill + world) - - def test_spatial_skill_distinguishes_visionos_and_framework_handoffs(self) -> None: - skill = self.read("skills/arkit-spatial-sensing-workflow/SKILL.md") - providers = self.read( - "skills/arkit-spatial-sensing-workflow/references/visionos-providers-rendering-and-diagnostics.md" - ) - for term in ( - "ARKitSession", - "authorization", - "providers", - "Do not translate an iOS", - "RealityKit", - "RoomCaptureSession", - "RoomCaptureView", - "RoomBuilder", - "CapturedRoom", - "SceneKit", - "Metal", - ): - self.assertIn(term, skill + providers) - - def test_face_body_skill_covers_tracking_skeleton_and_authentication_boundary(self) -> None: - skill = self.read("skills/arkit-face-body-tracking-workflow/SKILL.md") - face = self.read( - "skills/arkit-face-body-tracking-workflow/references/face-geometry-blend-shapes-and-authentication-boundary.md" - ) - body = self.read( - "skills/arkit-face-body-tracking-workflow/references/body-skeleton-scale-rendering-and-diagnostics.md" - ) - for term in ( - "ARFaceTrackingConfiguration", - "ARFaceAnchor", - "ARFaceGeometry", - "blend shapes", - "eye transforms", - "supportsWorldTracking", - "LAContext", - "LAPolicy", - "not Face ID", - "ARBodyTrackingConfiguration", - "ARBodyAnchor", - "ARSkeleton3D", - "estimatedScaleFactor", - ): - self.assertIn(term, skill + face + body) - - def test_shared_privacy_inventory_and_evidence_boundaries_are_explicit(self) -> None: - privacy = self.read("shared/references/apple-spatial-data-privacy-contract.md") - for term in ( - "world maps", - "room structure", - "face geometry", - "body skeletons", - "ARKitSession", - "in-memory, session-scoped", - "retention", - "deletion", - "estimates", - "Do not describe them as exact", - "bystander", - ): - self.assertIn(term, privacy) - - def test_inventory_metadata_customization_and_cross_skill_handoffs_are_aligned(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - for skill in ("arkit-spatial-sensing-workflow", "arkit-face-body-tracking-workflow"): - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - self.assertIn(f"${skill}", self.read(f"skills/{skill}/agents/openai.yaml")) - self.assertIn( - f'SKILL_NAME = "{skill}"', - self.read(f"skills/{skill}/scripts/customization_config.py"), - ) - self.assertIn("ARKit", plugin) - self.assertIn("LiDAR", plugin) - self.assertIn("Expected exactly 58 active skills", validator) - self.assertIn("arkit-spatial-sensing-workflow", self.read("skills/camera-capture-depth-workflow/SKILL.md")) - self.assertIn("arkit-face-body-tracking-workflow", self.read("skills/vision-image-analysis-workflow/SKILL.md")) - self.assertIn("arkit-spatial-sensing-workflow", self.read("skills/apple-ui-accessibility-workflow/SKILL.md")) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_author_swift_docc_docs.py b/plugins/apple-dev-skills/tests/test_author_swift_docc_docs.py deleted file mode 100644 index f3a960668..000000000 --- a/plugins/apple-dev-skills/tests/test_author_swift_docc_docs.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/author-swift-docc-docs/scripts/run_workflow.py" - - -def write_config(tmpdir: str, skill: str, settings: dict) -> None: - target = Path(tmpdir) / skill / "customization.yaml" - target.parent.mkdir(parents=True, exist_ok=True) - lines = ["schemaVersion: 1", "isCustomized: true", "settings:"] - for key, value in settings.items(): - lines.append(f' {key}: "{value}"') - target.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -class DoccWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_infers_symbol_docs_for_package_component(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo = Path(tmpdir) - (repo / "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - (repo / "Sources" / "MyLib").mkdir(parents=True) - code, payload = self.run_script( - "--repo-path", - tmpdir, - "--request", - "Please write the symbol docs and parameter docs for this API", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["execution_surface"], "swiftpm") - self.assertEqual(payload["output"]["task_type"], "symbol-docs") - - def test_handoffs_docs_lookup_to_explore_skill(self) -> None: - code, payload = self.run_script("--request", "Search docs for the right DocC tutorial directive") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertEqual(payload["output"]["recommended_skill"], "explore-apple-swift-docs") - - def test_handoffs_xcode_generation_operation(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo = Path(tmpdir) - (repo / "Demo.xcodeproj").mkdir() - code, payload = self.run_script( - "--repo-path", - tmpdir, - "--request", - "Build documentation and export the doccarchive", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertEqual(payload["output"]["recommended_skill"], "xcode-build-run-workflow") - - def test_tutorial_review_respects_defer_policy(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - write_config(tmpdir, "author-swift-docc-docs", {"tutorialSupportLevel": "defer"}) - code, payload = self.run_script( - "--request", - "Review this DocC tutorial flow for clarity", - env=env, - ) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["task_type"], "tutorial-aware-review") - self.assertEqual(payload["output"]["tutorial_support_level"], "defer") - self.assertIn("fuller DocC references", payload["output"]["next_step"]) - - def test_blocks_when_task_cannot_be_inferred(self) -> None: - code, payload = self.run_script("--request", "Help with this") - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - self.assertEqual(payload["output"]["task_type_source"], "missing") - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_camera_capture_depth_workflow.py b/plugins/apple-dev-skills/tests/test_camera_capture_depth_workflow.py deleted file mode 100644 index f2f6f68c9..000000000 --- a/plugins/apple-dev-skills/tests/test_camera_capture_depth_workflow.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class CameraCaptureDepthWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_covers_discovery_controls_rotation_and_capabilities(self) -> None: - skill = self.read("skills/camera-capture-depth-workflow/SKILL.md") - controls = self.read( - "skills/camera-capture-depth-workflow/references/camera-discovery-controls-and-rotation.md" - ) - capability = self.read("shared/references/apple-camera-capability-contract.md") - for term in ( - "AVCaptureDevice.DiscoverySession", - "AVCaptureDevice.Format", - "supportedMultiCamDeviceSets", - "AVCaptureMultiCamSession.isMultiCamSupported", - "lockForConfiguration()", - "focus", - "exposure", - "white balance", - "zoom", - "torch", - "AVCaptureDevice.RotationCoordinator", - "videoRotationAngle", - "documented", - "discovered", - "device-verified", - ): - self.assertIn(term, skill + controls + capability) - - def test_skill_covers_photo_features_lifecycle_and_pressure(self) -> None: - skill = self.read("skills/camera-capture-depth-workflow/SKILL.md") - photo = self.read( - "skills/camera-capture-depth-workflow/references/photo-computational-capture-and-lifecycle.md" - ) - for term in ( - "AVCapturePhotoOutput", - "AVCapturePhotoSettings", - "RAW", - "Live Photo", - "quality prioritization", - "responsive capture", - "deferred photo delivery", - "spatial video", - "do not infer a spatial-photo API", - "cinematic", - "AVCapturePhoto", - "interruptions", - "media-services reset", - "system pressure", - ): - self.assertIn(term, skill + photo) - - def test_skill_covers_depth_calibration_sync_mattes_and_drops(self) -> None: - skill = self.read("skills/camera-capture-depth-workflow/SKILL.md") - depth = self.read( - "skills/camera-capture-depth-workflow/references/depth-calibration-and-synchronized-capture.md" - ) - for term in ( - "AVDepthData", - "activeDepthDataFormat", - "depthDataAccuracy", - "AVCameraCalibrationData", - "intrinsic matrix", - "lens-distortion", - "AVCaptureDataOutputSynchronizer", - "AVCaptureSynchronizedData", - "wasDataDropped", - "AVPortraitEffectsMatte", - "AVSemanticSegmentationMatte", - ): - self.assertIn(term, skill + depth) - - def test_inventory_metadata_customization_and_handoffs_are_aligned(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - skill = "camera-capture-depth-workflow" - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - self.assertIn(f"${skill}", self.read(f"skills/{skill}/agents/openai.yaml")) - self.assertIn( - f'SKILL_NAME = "{skill}"', - self.read(f"skills/{skill}/scripts/customization_config.py"), - ) - self.assertIn("camera", plugin.lower()) - self.assertIn("depth", plugin.lower()) - self.assertIn("Expected exactly 58 active skills", validator) - self.assertIn(skill, self.read("skills/avfoundation-media-pipeline-workflow/SKILL.md")) - self.assertIn(skill, self.read("skills/vision-image-analysis-workflow/SKILL.md")) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_core_animation_typography_workflows.py b/plugins/apple-dev-skills/tests/test_core_animation_typography_workflows.py deleted file mode 100644 index b25a996fc..000000000 --- a/plugins/apple-dev-skills/tests/test_core_animation_typography_workflows.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class CoreAnimationTypographyWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_core_animation_workflow_covers_layer_boundaries_and_handoffs(self) -> None: - skill_text = self.read("skills/core-animation-layer-workflow/SKILL.md") - ownership_text = self.read( - "skills/core-animation-layer-workflow/references/layer-ownership-and-animation-rules.md" - ) - performance_text = self.read( - "skills/core-animation-layer-workflow/references/model-presentation-and-performance.md" - ) - prompt_text = self.read("skills/core-animation-layer-workflow/agents/openai.yaml") - - for term in [ - "Apply the Apple docs gate", - "CALayer", - "CAAnimation", - "CATransaction", - "presentationLayer", - "swiftui-animation-workflow", - "xcode-build-run-workflow", - "references/snippets/apple-xcode-project-core.md", - ]: - self.assertIn(term, skill_text) - - for term in [ - "UIKit views own their backing layer", - "Do not replace the layer delegate", - "Update the model layer to the final value", - "CAShapeLayer", - "CAGradientLayer", - ]: - self.assertIn(term, ownership_text) - - for term in [ - "Treat the model layer as durable state", - "Treat the presentation layer as an in-flight visual snapshot", - "Screenshots can show final layout", - "Hand off to `xcode-build-run-workflow`", - ]: - self.assertIn(term, performance_text) - - self.assertIn("$core-animation-layer-workflow", prompt_text) - - def test_apple_typography_workflow_covers_dynamic_type_and_font_boundaries(self) -> None: - skill_text = self.read("skills/apple-typography-workflow/SKILL.md") - system_text = self.read("skills/apple-typography-workflow/references/system-typography-and-dynamic-type.md") - custom_text = self.read("skills/apple-typography-workflow/references/custom-fonts-and-licensing.md") - prompt_text = self.read("skills/apple-typography-workflow/agents/openai.yaml") - - for term in [ - "Apply the Apple docs gate", - "San Francisco", - "New York", - "Dynamic Type", - "UIFontMetrics", - "UIAppFonts", - "ATSApplicationFontsPath", - "xcode-build-run-workflow", - "references/snippets/apple-xcode-project-core.md", - ]: - self.assertIn(term, skill_text) - - for term in [ - "Treat San Francisco as the system default family", - "Use UIKit `UIFont.preferredFont(forTextStyle:)`", - "Use `UIFontDescriptor.SystemDesign`", - "Prefer semantic text styles", - ]: - self.assertIn(term, system_text) - - for term in [ - "Confirm the font license allows app embedding", - "Do not extract or bundle Apple system font files", - "state the licensing or redistribution concern once", - ]: - self.assertIn(term, custom_text) - - self.assertIn("$apple-typography-workflow", prompt_text) - - def test_second_slice_inventory_is_wired_into_metadata_and_validation(self) -> None: - readme = self.read("README.md") - validator = self.read(".github/scripts/validate_repo_docs.sh") - plugin = self.read(".codex-plugin/plugin.json") - - for skill in ["core-animation-layer-workflow", "apple-typography-workflow"]: - with self.subTest(skill=skill): - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - - self.assertIn("Core Animation", plugin) - self.assertIn("Apple typography", plugin) - self.assertIn("core-animation", plugin) - self.assertIn("typography", plugin) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_customization_cli.py b/plugins/apple-dev-skills/tests/test_customization_cli.py deleted file mode 100644 index f27e50035..000000000 --- a/plugins/apple-dev-skills/tests/test_customization_cli.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SKILL_SCRIPTS = { - "xcode-build-run-workflow": ROOT / "skills/xcode-build-run-workflow/scripts/customization_config.py", - "xcode-testing-workflow": ROOT / "skills/xcode-testing-workflow/scripts/customization_config.py", - "author-swift-docc-docs": ROOT / "skills/author-swift-docc-docs/scripts/customization_config.py", - "safari-mcp-workflow": ROOT / "skills/safari-mcp-workflow/scripts/customization_config.py", - "appkit-app-architecture-workflow": ROOT - / "skills/appkit-app-architecture-workflow/scripts/customization_config.py", - "swiftui-app-architecture-workflow": ROOT / "skills/swiftui-app-architecture-workflow/scripts/customization_config.py", - "explore-apple-swift-docs": ROOT / "skills/explore-apple-swift-docs/scripts/customization_config.py", - "structure-swift-sources": ROOT / "skills/structure-swift-sources/scripts/customization_config.py", - "swift-package-build-run-workflow": ROOT / "skills/swift-package-build-run-workflow/scripts/customization_config.py", - "swift-package-testing-workflow": ROOT / "skills/swift-package-testing-workflow/scripts/customization_config.py", -} - - -class CustomizationCliTests(unittest.TestCase): - def run_cli(self, script: Path, *args: str, env: dict | None = None) -> subprocess.CompletedProcess[str]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - return subprocess.run( - [str(script), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - - def test_effective_apply_and_reset_roundtrip(self) -> None: - override_text = 'isCustomized: true\nsettings:\n sampleKey: "sampleValue"\n' - - for skill_name, script in SKILL_SCRIPTS.items(): - with self.subTest(skill=skill_name): - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - - initial = self.run_cli(script, "effective", env=env) - self.assertEqual(initial.returncode, 0) - self.assertIn("schemaVersion: 1", initial.stdout) - self.assertIn("settings:", initial.stdout) - - override_path = Path(tmpdir) / "override.yaml" - override_path.write_text(override_text, encoding="utf-8") - - apply = self.run_cli(script, "apply", "--input", str(override_path), env=env) - self.assertEqual(apply.returncode, 0) - expected_path = Path(tmpdir) / skill_name / "customization.yaml" - self.assertEqual(Path(apply.stdout.strip()), expected_path) - self.assertTrue(expected_path.is_file()) - - updated = self.run_cli(script, "effective", env=env) - self.assertEqual(updated.returncode, 0) - self.assertIn("sampleKey: \"sampleValue\"", updated.stdout) - - reset = self.run_cli(script, "reset", env=env) - self.assertEqual(reset.returncode, 0) - self.assertEqual(Path(reset.stdout.strip()), expected_path) - self.assertFalse(expected_path.exists()) - - def test_apply_rejects_invalid_override_yaml(self) -> None: - invalid_text = "settings:\n nested:\n child: true\n" - - for skill_name, script in SKILL_SCRIPTS.items(): - with self.subTest(skill=skill_name): - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - override_path = Path(tmpdir) / "invalid.yaml" - override_path.write_text(invalid_text, encoding="utf-8") - - proc = self.run_cli(script, "apply", "--input", str(override_path), env=env) - self.assertEqual(proc.returncode, 1) - self.assertIn("settings values must be scalar", proc.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_customization_template_paths.py b/plugins/apple-dev-skills/tests/test_customization_template_paths.py deleted file mode 100644 index d15d933a9..000000000 --- a/plugins/apple-dev-skills/tests/test_customization_template_paths.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import io -import importlib.util -import tempfile -import unittest -from contextlib import redirect_stderr -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SKILL_MODULES = { - "xcode-build-run-workflow": ROOT / "skills/xcode-build-run-workflow/scripts/customization_config.py", - "xcode-testing-workflow": ROOT / "skills/xcode-testing-workflow/scripts/customization_config.py", - "author-swift-docc-docs": ROOT / "skills/author-swift-docc-docs/scripts/customization_config.py", - "safari-extension-control-workflow": ROOT / "skills/safari-extension-control-workflow/scripts/customization_config.py", - "safari-mcp-workflow": ROOT / "skills/safari-mcp-workflow/scripts/customization_config.py", - "appkit-app-architecture-workflow": ROOT - / "skills/appkit-app-architecture-workflow/scripts/customization_config.py", - "swiftui-app-architecture-workflow": ROOT / "skills/swiftui-app-architecture-workflow/scripts/customization_config.py", - "explore-apple-swift-docs": ROOT / "skills/explore-apple-swift-docs/scripts/customization_config.py", - "format-swift-sources": ROOT / "skills/format-swift-sources/scripts/customization_config.py", - "structure-swift-sources": ROOT / "skills/structure-swift-sources/scripts/customization_config.py", - "swift-package-build-run-workflow": ROOT / "skills/swift-package-build-run-workflow/scripts/customization_config.py", - "swift-package-testing-workflow": ROOT / "skills/swift-package-testing-workflow/scripts/customization_config.py", -} - - -def load_module(module_path: Path): - spec = importlib.util.spec_from_file_location(module_path.stem, module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load module from {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class CustomizationTemplatePathTests(unittest.TestCase): - def test_customization_templates_live_under_references(self) -> None: - for skill_name, module_path in SKILL_MODULES.items(): - with self.subTest(skill=skill_name): - module = load_module(module_path) - template_path = module.template_path() - expected = module_path.parents[1] / "references" / "customization.template.yaml" - self.assertEqual(template_path, expected) - self.assertTrue(template_path.is_file()) - loaded = module.load_template() - self.assertEqual(loaded["schemaVersion"], 1) - self.assertIn("settings", loaded) - - def test_partial_override_yaml_loads_and_merges(self) -> None: - override_text = 'isCustomized: true\nsettings:\n fallbackOrder: "url-service,http,mcp"\n' - for skill_name, module_path in SKILL_MODULES.items(): - with self.subTest(skill=skill_name): - module = load_module(module_path) - with tempfile.TemporaryDirectory() as tmpdir: - override_path = Path(tmpdir) / "override.yaml" - override_path.write_text(override_text, encoding="utf-8") - loaded = module.parse_yaml(override_path) - module.validate_config(loaded, allow_partial=True) - merged = module.merge_configs(module.load_template(), loaded) - self.assertEqual(merged["isCustomized"], True) - self.assertIn("settings", merged) - - def test_invalid_yaml_is_rejected(self) -> None: - invalid_text = "schemaVersion: [1\n" - for skill_name, module_path in SKILL_MODULES.items(): - with self.subTest(skill=skill_name): - module = load_module(module_path) - with tempfile.TemporaryDirectory() as tmpdir: - invalid_path = Path(tmpdir) / "invalid.yaml" - invalid_path.write_text(invalid_text, encoding="utf-8") - stderr = io.StringIO() - with self.assertRaises(SystemExit): - with redirect_stderr(stderr): - module.parse_yaml(invalid_path) - self.assertIn("Invalid YAML", stderr.getvalue()) - - def test_unknown_top_level_key_is_rejected(self) -> None: - for skill_name, module_path in SKILL_MODULES.items(): - with self.subTest(skill=skill_name): - module = load_module(module_path) - config = { - "schemaVersion": 1, - "isCustomized": True, - "settings": {}, - "unexpected": "value", - } - with self.assertRaises(SystemExit): - module.validate_config(config, allow_partial=False) - - def test_settings_must_be_a_mapping(self) -> None: - invalid_text = "schemaVersion: 1\nisCustomized: true\nsettings:\n - bad\n" - for skill_name, module_path in SKILL_MODULES.items(): - with self.subTest(skill=skill_name): - module = load_module(module_path) - with tempfile.TemporaryDirectory() as tmpdir: - invalid_path = Path(tmpdir) / "invalid.yaml" - invalid_path.write_text(invalid_text, encoding="utf-8") - loaded = module.parse_yaml(invalid_path) - with self.assertRaises(SystemExit): - module.validate_config(loaded, allow_partial=False) - - def test_nested_settings_values_are_rejected(self) -> None: - invalid_text = "schemaVersion: 1\nisCustomized: true\nsettings:\n nested:\n child: true\n" - for skill_name, module_path in SKILL_MODULES.items(): - with self.subTest(skill=skill_name): - module = load_module(module_path) - with tempfile.TemporaryDirectory() as tmpdir: - invalid_path = Path(tmpdir) / "invalid.yaml" - invalid_path.write_text(invalid_text, encoding="utf-8") - loaded = module.parse_yaml(invalid_path) - with self.assertRaises(SystemExit): - module.validate_config(loaded, allow_partial=False) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_design_animation_symbol_workflows.py b/plugins/apple-dev-skills/tests/test_design_animation_symbol_workflows.py deleted file mode 100644 index b33e862e5..000000000 --- a/plugins/apple-dev-skills/tests/test_design_animation_symbol_workflows.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class DesignAnimationSymbolWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_sf_symbols_workflow_covers_app_rendering_and_custom_symbols(self) -> None: - skill_text = self.read("skills/sf-symbols-workflow/SKILL.md") - selection_text = self.read("skills/sf-symbols-workflow/references/symbol-selection-and-rendering.md") - app_text = self.read("skills/sf-symbols-workflow/references/custom-symbols-and-app-inspection.md") - prompt_text = self.read("skills/sf-symbols-workflow/agents/openai.yaml") - - for term in [ - "Apply the Apple docs gate", - "SF Symbols 7.2 build 119", - "symbolRenderingMode", - "variableValue", - "symbolEffect", - "custom symbol", - "icon-composer-app-icon-workflow", - "xcode-build-run-workflow", - "references/snippets/apple-xcode-project-core.md", - ]: - self.assertIn(term, skill_text) - - for term in [ - "monochrome", - "hierarchical", - "palette", - "multicolor", - "variable value", - "Do not use color alone", - ]: - self.assertIn(term, selection_text) - - for term in [ - "/Applications/SF Symbols.app", - "Info, Format, and Animation", - "Do not save, overwrite, export, or mutate user collections", - ]: - self.assertIn(term, app_text) - - self.assertIn("$sf-symbols-workflow", prompt_text) - - def test_swiftui_animation_workflow_covers_motion_and_accessibility_boundaries(self) -> None: - skill_text = self.read("skills/swiftui-animation-workflow/SKILL.md") - decision_text = self.read("skills/swiftui-animation-workflow/references/animation-decision-rules.md") - accessibility_text = self.read( - "skills/swiftui-animation-workflow/references/transitions-effects-and-accessibility.md" - ) - prompt_text = self.read("skills/swiftui-animation-workflow/agents/openai.yaml") - - for term in [ - "Apply the Apple docs gate", - "withAnimation", - "animation(_:value:)", - "PhaseAnimator", - "KeyframeAnimator", - "reduce-motion", - "sf-symbols-workflow", - "swiftui-app-architecture-workflow", - "xcode-build-run-workflow", - "references/snippets/apple-xcode-project-core.md", - ]: - self.assertIn(term, skill_text) - - for term in [ - "Use `withAnimation`", - "Use `animation(_:value:)`", - "Use `PhaseAnimator`", - "Use `KeyframeAnimator`", - "If too much animates", - ]: - self.assertIn(term, decision_text) - - for term in [ - "Use insertion/removal transitions", - "Treat symbol effects as SwiftUI motion", - "Respect reduce-motion expectations", - "Screenshots cannot prove motion quality", - ]: - self.assertIn(term, accessibility_text) - - self.assertIn("$swiftui-animation-workflow", prompt_text) - - def test_design_animation_inventory_is_wired_into_metadata_and_validation(self) -> None: - readme = self.read("README.md") - validator = self.read(".github/scripts/validate_repo_docs.sh") - plugin = self.read(".codex-plugin/plugin.json") - - for skill in ["sf-symbols-workflow", "swiftui-animation-workflow"]: - with self.subTest(skill=skill): - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - - self.assertIn("SF Symbols", plugin) - self.assertIn("SwiftUI animation", plugin) - self.assertIn("sf-symbols", plugin) - self.assertIn("animation", plugin) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_devicecheck_app_attest_workflow.py b/plugins/apple-dev-skills/tests/test_devicecheck_app_attest_workflow.py deleted file mode 100644 index ba1416f92..000000000 --- a/plugins/apple-dev-skills/tests/test_devicecheck_app_attest_workflow.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class DeviceCheckAppAttestWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_separates_devicecheck_and_app_attest_paths(self) -> None: - skill_text = self.read("skills/devicecheck-app-attest-workflow/SKILL.md") - device_text = self.read("skills/devicecheck-app-attest-workflow/references/devicecheck-device-state.md") - client_text = self.read("skills/devicecheck-app-attest-workflow/references/app-attest-client-flow.md") - - self.assertIn("DeviceCheck two-bit state with `DCDevice`", skill_text) - self.assertIn("App Attest app-instance integrity with `DCAppAttestService`", skill_text) - self.assertIn("query, update, or validate the two bits", device_text) - self.assertIn("Do not use DeviceCheck as account identity", device_text) - self.assertIn("generateKey(completionHandler:)", client_text) - self.assertIn("attestKey(_:clientDataHash:completionHandler:)", client_text) - self.assertIn("generateAssertion(_:clientDataHash:completionHandler:)", client_text) - - def test_skill_requires_docs_gate_and_supported_apple_behavior(self) -> None: - skill_text = self.read("skills/devicecheck-app-attest-workflow/SKILL.md") - client_text = self.read("skills/devicecheck-app-attest-workflow/references/app-attest-client-flow.md") - entitlement_text = self.read("skills/devicecheck-app-attest-workflow/references/entitlements-app-id-and-validation.md") - - self.assertIn("Apply the Apple docs gate", skill_text) - self.assertIn("state the documented Apple behavior being relied on", skill_text) - self.assertIn("Action, extensible SSO, and watchOS extensions", client_text) - self.assertIn("sandbox and production", entitlement_text) - self.assertIn("macOS-specific App Attest validation", entitlement_text) - - def test_server_validation_stays_a_handoff_not_app_local_trust(self) -> None: - skill_text = self.read("skills/devicecheck-app-attest-workflow/SKILL.md") - server_text = self.read("skills/devicecheck-app-attest-workflow/references/app-attest-server-validation.md") - - self.assertIn("Do not pretend the app can validate its own integrity locally", skill_text) - self.assertIn("The server owns trust", server_text) - self.assertIn("decode the attestation object as CBOR", server_text) - self.assertIn("verify the assertion counter is greater than the previous stored counter", server_text) - self.assertIn("server-side Swift, OpenAPI, RPC, or backend-specific workflows", server_text) - - def test_skill_handoffs_and_metadata_are_explicit(self) -> None: - skill_text = self.read("skills/devicecheck-app-attest-workflow/SKILL.md") - prompt_text = self.read("skills/devicecheck-app-attest-workflow/agents/openai.yaml") - - self.assertIn("Recommend `explore-apple-swift-docs`", skill_text) - self.assertIn("Recommend `xcode-build-run-workflow`", skill_text) - self.assertIn("Recommend `xcode-testing-workflow`", skill_text) - self.assertIn("Recommend `swift-openapi-client-workflow`", skill_text) - self.assertIn("broader client auth and app-sync workflow", skill_text) - self.assertIn("references/snippets/apple-xcode-project-core.md", skill_text) - self.assertIn("$devicecheck-app-attest-workflow", prompt_text) - self.assertIn("$xcode-build-run-workflow", prompt_text) - self.assertIn("$xcode-testing-workflow", prompt_text) - self.assertIn("$swift-openapi-client-workflow", prompt_text) - self.assertIn("$explore-apple-swift-docs", prompt_text) - - def test_plugin_inventory_includes_devicecheck_workflow(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - self.assertIn("devicecheck-app-attest-workflow", readme) - self.assertIn("DeviceCheck", plugin) - self.assertIn("App Attest", plugin) - self.assertIn("./skills/devicecheck-app-attest-workflow/SKILL.md", validator) - self.assertIn("Expected exactly 58 active skills", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_explore_apple_swift_docs_workflow.py b/plugins/apple-dev-skills/tests/test_explore_apple_swift_docs_workflow.py deleted file mode 100644 index d1ed69d3d..000000000 --- a/plugins/apple-dev-skills/tests/test_explore_apple_swift_docs_workflow.py +++ /dev/null @@ -1,232 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from contextlib import contextmanager -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/explore-apple-swift-docs/scripts/run_workflow.py" - - -def write_config(tmpdir: str, skill: str, settings: dict) -> None: - target = Path(tmpdir) / skill / "customization.yaml" - target.parent.mkdir(parents=True, exist_ok=True) - lines = ["schemaVersion: 1", "isCustomized: true", "settings:"] - for key, value in settings.items(): - if isinstance(value, bool): - raw = "true" if value else "false" - elif isinstance(value, int): - raw = str(value) - else: - raw = f'"{value}"' - lines.append(f" {key}: {raw}") - target.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -@contextmanager -def fake_open_in_path(script_body: str): - with tempfile.TemporaryDirectory() as tmpdir: - bin_dir = Path(tmpdir) / "bin" - bin_dir.mkdir() - open_path = bin_dir / "open" - open_path.write_text(script_body, encoding="utf-8") - open_path.chmod(0o755) - env = dict(os.environ) - env["PATH"] = f"{bin_dir}:{env['PATH']}" - yield env - - -class ExploreAppleSwiftDocsWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_explore_uses_xcode_mcp_by_default(self) -> None: - code, payload = self.run_script("--mode", "explore", "--query", "SwiftUI", "--dry-run") - self.assertEqual(code, 0) - self.assertEqual(payload["source_used"], "xcode-mcp-docs") - self.assertEqual(payload["path_type"], "primary") - self.assertEqual(payload["configured_order"], ["xcode-mcp-docs", "dash", "dash-http", "source-repo", "official-web"]) - - def test_docs_guidance_names_the_two_local_mcp_sources_before_online_fallbacks(self) -> None: - skill = (ROOT / "skills/explore-apple-swift-docs/SKILL.md").read_text(encoding="utf-8") - guide = (ROOT / "skills/explore-apple-swift-docs/references/apple-framework-docs-guide.md").read_text(encoding="utf-8") - - self.assertIn("Xcode MCP `DocumentationSearch` first, Dash.app MCP second", skill) - self.assertIn("Dash.app MCP", guide) - self.assertIn("GitHub, and online docs only after those local MCP paths", guide) - - def test_explore_obeys_preferred_source_override(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - status_file = Path(tmpdir) / "dash-status.json" - status_file.write_text('{"health_ok": true, "schema_ok": true}\n', encoding="utf-8") - code, payload = self.run_script( - "--mode", - "explore", - "--query", - "Swift", - "--preferred-source", - "dash", - "--status-file", - str(status_file), - "--dry-run", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["source_used"], "dash") - - def test_explore_falls_back_to_source_repo_for_open_source_swift_queries(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - write_config( - tmpdir, - "explore-apple-swift-docs", - {"defaultSourceOrder": "xcode-mcp-docs,dash,dash-http,source-repo,official-web"}, - ) - code, payload = self.run_script( - "--mode", - "explore", - "--query", - "Swift Package Manager", - "--mcp-failure-reason", - "session-missing", - "--status-file", - str(Path(tmpdir) / "missing-status.json"), - "--dry-run", - env=env, - ) - self.assertEqual(code, 0) - self.assertEqual(payload["source_used"], "source-repo") - self.assertEqual(payload["path_type"], "fallback") - - def test_explore_skips_source_repo_for_apple_only_queries(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - write_config( - tmpdir, - "explore-apple-swift-docs", - {"defaultSourceOrder": "xcode-mcp-docs,dash,dash-http,source-repo,official-web"}, - ) - code, payload = self.run_script( - "--mode", - "explore", - "--query", - "UIKit", - "--mcp-failure-reason", - "session-missing", - "--status-file", - str(Path(tmpdir) / "missing-status.json"), - "--dry-run", - env=env, - ) - self.assertEqual(code, 0) - self.assertEqual(payload["source_used"], "official-web") - self.assertEqual(payload["path_type"], "fallback") - - def test_explore_allows_readable_official_web_when_explicitly_preferred(self) -> None: - code, payload = self.run_script( - "--mode", - "explore", - "--query", - "Foundation", - "--preferred-source", - "official-web", - "--dry-run", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["source_used"], "official-web") - self.assertIn("readable official", payload["next_step"]) - - def test_explore_keeps_snippets_enabled_by_default(self) -> None: - code, payload = self.run_script("--mode", "explore", "--query", "Swift", "--dry-run") - self.assertEqual(code, 0) - self.assertTrue(payload["search_snippets_enabled"]) - self.assertGreater(len(payload["matches"][0].keys()), 3) - - def test_dash_install_uses_built_in_priority_by_default(self) -> None: - code, payload = self.run_script( - "--mode", - "dash-install", - "--docset-request", - "Swift", - "--dry-run", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["selected_match"]["source"], "built_in") - - def test_dash_install_requires_explicit_approval(self) -> None: - code, payload = self.run_script("--mode", "dash-install", "--docset-request", "Swift") - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - - def test_dash_install_launches_open_when_approved(self) -> None: - script_body = """#!/bin/sh -printf '%s\n' "$1" > "${TMPDIR:-/tmp}/apple-dev-skills-open-arg.txt" -exit 0 -""" - with tempfile.TemporaryDirectory() as tmpdir, fake_open_in_path(script_body) as env: - env["TMPDIR"] = tmpdir - code, payload = self.run_script( - "--mode", - "dash-install", - "--docset-request", - "Swift", - "--yes", - env=env, - ) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["install_result"]["returncode"], 0) - self.assertTrue(payload["install_result"]["launched"]) - launched_url = Path(tmpdir, "apple-dev-skills-open-arg.txt").read_text(encoding="utf-8").strip() - self.assertTrue(launched_url.startswith("dash-install://?")) - - def test_dash_install_handoffs_to_generation_when_no_match_exists(self) -> None: - code, payload = self.run_script( - "--mode", - "dash-install", - "--docset-request", - "DefinitelyNotARealDocsetName", - "--dry-run", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("dash-generate", payload["next_step"]) - - def test_dash_generate_returns_structured_guidance(self) -> None: - code, payload = self.run_script("--mode", "dash-generate", "--docset-request", "Swift", "--dry-run") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertIn("guidance", payload) - self.assertEqual(payload["source_path"], "automation-guidance") - - def test_dash_generate_uses_automated_policy_default(self) -> None: - code, payload = self.run_script( - "--mode", - "dash-generate", - "--docset-request", - "Swift", - "--dry-run", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["path_type"], "primary") - self.assertEqual(payload["guidance"]["policy"], "automate-stable") - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_format_swift_sources_export.py b/plugins/apple-dev-skills/tests/test_format_swift_sources_export.py deleted file mode 100644 index 7a72af64c..000000000 --- a/plugins/apple-dev-skills/tests/test_format_swift_sources_export.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import importlib.util -import plistlib -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE_PATH = ROOT / "skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py" - - -def load_module(module_path: Path): - spec = importlib.util.spec_from_file_location(module_path.stem, module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load module from {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class FormatSwiftSourcesExportTests(unittest.TestCase): - def setUp(self) -> None: - self.module = load_module(MODULE_PATH) - - def test_infer_options_export_keeps_rules_and_explicit_swift_version(self) -> None: - payload = { - "infer-options": True, - "rules": { - "blankLinesBetweenScopes": True, - "redundantSelf": False, - "wrapArguments": True, - }, - "format-options": { - "indent": "4", - "swiftversion": "6.0", - "languagemode": "0", - }, - } - - lines = self.module.serialize_lines(payload) - rendered = "\n".join(lines) - - self.assertIn("--rules blankLinesBetweenScopes,wrapArguments", rendered) - self.assertIn("--swiftversion 6.0", rendered) - self.assertNotIn("--indent 4", rendered) - - def test_explicit_options_export_writes_sorted_option_lines(self) -> None: - payload = { - "infer-options": False, - "rules": {"wrap": True}, - "format-options": { - "indent": "2", - "allman": False, - "header": "", - }, - } - - lines = self.module.serialize_lines(payload) - - self.assertIn("--rules wrap", lines) - self.assertIn("--allman false", lines) - self.assertIn("--indent 2", lines) - self.assertNotIn("--header ", "\n".join(lines)) - self.assertLess(lines.index("--allman false"), lines.index("--indent 2")) - - def test_plist_input_path_loads_dictionary_payload(self) -> None: - payload = { - "infer-options": True, - "rules": {"wrap": True}, - "format-options": {"swiftversion": "5.10"}, - } - - with tempfile.TemporaryDirectory() as tmpdir: - plist_path = Path(tmpdir) / "swiftformat.plist" - with plist_path.open("wb") as handle: - plistlib.dump(payload, handle) - - loaded = self.module.load_plist(plist_path) - - self.assertEqual(loaded["rules"]["wrap"], True) - self.assertEqual(loaded["format-options"]["swiftversion"], "5.10") - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_imaging_foundation_workflows.py b/plugins/apple-dev-skills/tests/test_imaging_foundation_workflows.py deleted file mode 100644 index f3db79a0c..000000000 --- a/plugins/apple-dev-skills/tests/test_imaging_foundation_workflows.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class ImagingFoundationWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_core_image_skill_owns_processing_and_rendering(self) -> None: - skill = self.read("skills/core-image-processing-workflow/SKILL.md") - processing = self.read( - "skills/core-image-processing-workflow/references/core-image-processing-and-rendering.md" - ) - diagnostics = self.read( - "skills/core-image-processing-workflow/references/core-image-diagnostics-and-handoffs.md" - ) - - for term in ( - "CIImage", - "CIContext", - "CIFilter", - "CIRAWFilter", - "CIKernel", - "CVPixelBuffer", - "IOSurface", - "Metal", - "lazy", - "working color space", - "premultiplication", - "representative devices", - ): - self.assertIn(term, skill + processing + diagnostics) - - def test_representation_skill_preserves_source_meaning(self) -> None: - skill = self.read("skills/apple-image-representation-workflow/SKILL.md") - image_io = self.read( - "skills/apple-image-representation-workflow/references/image-io-decoding-encoding-and-metadata.md" - ) - bridging = self.read( - "skills/apple-image-representation-workflow/references/apple-image-representations-and-bridging.md" - ) - - for term in ( - "CGImageSource", - "CGImageDestination", - "CGImageDestinationFinalize", - "CGImageMetadata", - "NSImage", - "NSImageRep", - "NSBitmapImageRep", - "UIImage", - "orientation", - "scale", - "auxiliary data", - "incremental", - ): - self.assertIn(term, skill + image_io + bridging) - - def test_shared_type_ownership_requires_conversion_ledger(self) -> None: - ownership = self.read("shared/references/apple-image-type-ownership.md") - for term in ( - "Do not normalize Apple image values", - "Required Conversion Ledger", - "Name every intentional loss", - "CGImageSource", - "CIImage", - "NSImage", - "UIImage", - "CVPixelBuffer", - "Do not keep parallel framework and wrapper codepaths", - ): - self.assertIn(term, ownership) - - def test_inventory_metadata_and_customization_are_aligned(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - for skill in ( - "core-image-processing-workflow", - "apple-image-representation-workflow", - ): - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - self.assertIn(f"${skill}", self.read(f"skills/{skill}/agents/openai.yaml")) - self.assertIn( - f'SKILL_NAME = "{skill}"', - self.read(f"skills/{skill}/scripts/customization_config.py"), - ) - - self.assertIn("Core Image", plugin) - self.assertIn("Image I/O", plugin) - self.assertIn("Expected exactly 58 active skills", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_macos_platform_security_workflows.py b/plugins/apple-dev-skills/tests/test_macos_platform_security_workflows.py deleted file mode 100644 index bfead0df5..000000000 --- a/plugins/apple-dev-skills/tests/test_macos_platform_security_workflows.py +++ /dev/null @@ -1,67 +0,0 @@ -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def read(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8") - - -def test_privacy_workflow_preserves_permission_boundaries() -> None: - skill = read("skills/macos-privacy-permissions-workflow/SKILL.md") - matrix = read("skills/macos-privacy-permissions-workflow/references/permission-class-matrix.md") - reset = read("skills/macos-privacy-permissions-workflow/references/prompting-settings-reset-and-mdm.md") - attribution = read("skills/macos-privacy-permissions-workflow/references/responsible-code-and-attribution.md") - - for term in ( - "AXIsProcessTrustedWithOptions", - "AEDeterminePermissionToAutomateTarget", - "EPDeveloperTool.authorizationStatus", - "CGPreflightScreenCaptureAccess", - "Full Disk Access", - ): - assert term in matrix - assert "reset surface, not a general grant or status tool" in reset - assert "Terminal, an IDE, or an agent host" in attribution - assert "Do not edit, replace, copy back, or directly query a live TCC database" in skill - assert "explicit approval immediately before" in skill - - -def test_privacy_workflow_has_discovery_metadata() -> None: - name = "macos-privacy-permissions-workflow" - assert f"./skills/{name}/SKILL.md" in read(".github/scripts/validate_repo_docs.sh") - assert f"${name}" in read(f"skills/{name}/agents/openai.yaml") - - -def test_sandbox_file_workflow_preserves_authorization_lifetime() -> None: - skill = read("skills/macos-sandbox-file-access-workflow/SKILL.md") - lifecycle = read("skills/macos-sandbox-file-access-workflow/references/security-scoped-bookmark-lifecycle.md") - controls = read("skills/macos-sandbox-file-access-workflow/references/sandbox-and-filesystem-control-map.md") - boundaries = read("skills/macos-sandbox-file-access-workflow/references/helpers-groups-and-process-boundaries.md") - assert "startAccessingSecurityScopedResource()" in lifecycle - assert "stopAccessingSecurityScopedResource()" in lifecycle - assert "If stale" in lifecycle - for layer in ("POSIX and ACL", "App Sandbox", "TCC", "Data Vault/SIP"): - assert layer in controls - assert "Do not pass a path across IPC and assume authorization follows" in boundaries - assert "Do not claim a bookmark bypasses TCC" in skill - - -def test_entitlement_workflow_requires_five_state_evidence() -> None: - skill = read("skills/diagnose-apple-entitlements/SKILL.md") - comparison = read("skills/diagnose-apple-entitlements/references/five-state-entitlement-comparison.md") - classification = read("skills/diagnose-apple-entitlements/references/restricted-and-private-entitlements.md") - artifact = read("skills/diagnose-apple-entitlements/references/artifact-and-nested-code-inspection.md") - for state in ("Desired behavior", "Tracked source", "Account authorization", "Signed result", "Runtime result"): - assert state in comparison - assert "do not recommend them for an ordinary third-party product" in classification - assert "`codesign --deep` verification is not a substitute" in artifact - assert "Do not call an entitlement effective" in skill - - -def test_slice_two_skills_have_discovery_metadata() -> None: - validator = read(".github/scripts/validate_repo_docs.sh") - for name in ("macos-sandbox-file-access-workflow", "diagnose-apple-entitlements"): - assert f"./skills/{name}/SKILL.md" in validator - assert f"${name}" in read(f"skills/{name}/agents/openai.yaml") diff --git a/plugins/apple-dev-skills/tests/test_macos_virtualization_workflows.py b/plugins/apple-dev-skills/tests/test_macos_virtualization_workflows.py deleted file mode 100644 index b9f28f544..000000000 --- a/plugins/apple-dev-skills/tests/test_macos_virtualization_workflows.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent - - -def read(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8") - - -def assert_skill_contract(skill: str, *phrases: str) -> None: - contents = read(f"skills/{skill}/SKILL.md").lower() - missing = [phrase for phrase in phrases if phrase.lower() not in contents] - assert not missing, f"{skill} is missing virtualization contract phrases: {missing}" - - -def test_shape_router_selects_one_boundary_and_records_uncertainty() -> None: - assert_skill_contract( - "choose-macos-virtualization-shape", - "do not return an undecided product menu", - "apple `container`", - "persistent oci-backed linux environment", - "native macos security", - "secure enclave", - "virtualization shape record", - ) - - -def test_framework_workflow_keeps_guest_and_state_models_separate() -> None: - assert_skill_contract( - "virtualization-framework-workflow", - "macos or linux virtualization framework path", - "require the virtualization entitlement", - "`validate()` before start", - "do not call saved machine state a disk snapshot", - "announce before any visible or resource-intensive launch", - ) - - -def test_linux_workflow_separates_machine_adapters_and_full_vm() -> None: - assert_skill_contract( - "linux-development-vm-workflow", - "`container machine`", - "lima/colima adapter", - "full virtualization framework vm", - "development convenience is not a security boundary", - "nested virtualization", - ) - - -def test_macos_workflow_separates_identity_disk_state_and_evidence() -> None: - assert_skill_contract( - "macos-development-vm-workflow", - "restore images, identity, disks, saved state, clones", - "sip and relevant controls", - "do not conflate saved machine state with disk state", - "physical mac", - ) - - -def test_inventory_metadata_and_customization_contracts_include_all_four() -> None: - validator = read(".github/scripts/validate_repo_docs.sh") - readme = read("README.md") - manifest = read(".codex-plugin/plugin.json") - for skill in ( - "choose-macos-virtualization-shape", - "virtualization-framework-workflow", - "linux-development-vm-workflow", - "macos-development-vm-workflow", - ): - assert f"./skills/{skill}/SKILL.md" in validator - assert f"`{skill}`" in readme - assert (ROOT / "skills" / skill / "agents" / "openai.yaml").is_file() - assert (ROOT / "skills" / skill / "references" / "customization.template.yaml").is_file() - assert (ROOT / "skills" / skill / "scripts" / "customization_config.py").is_file() - assert "Expected exactly 58 active skills" in validator - assert "virtualization-framework" in manifest diff --git a/plugins/apple-dev-skills/tests/test_media_audio_workflows.py b/plugins/apple-dev-skills/tests/test_media_audio_workflows.py deleted file mode 100644 index e65ec3aff..000000000 --- a/plugins/apple-dev-skills/tests/test_media_audio_workflows.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -MEDIA_SKILLS = { - "avfaudio-session-workflow": [ - "AVAudioSession", - "AVAudioApplication.requestRecordPermission", - "AVAudioSession.RouteSharingPolicy", - "notifyOthersOnDeactivation", - "route changes", - "apple-media-type-ownership.md", - "xcode-build-run-workflow", - ], - "avaudio-engine-workflow": [ - "AVAudioEngine", - "AVAudioFormat", - "AVAudioPCMBuffer", - "manual rendering", - "real-time render callbacks", - "apple-media-type-ownership.md", - "coreaudio-modernization-repair-workflow", - ], - "avfoundation-media-pipeline-workflow": [ - "AVCaptureSession.startRunning()", - "AVAsyncProperty", - "loadValuesAsynchronously(forKeys:)", - "isReadyForMoreMediaData", - "AVAssetWriterInput", - "apple-media-type-ownership.md", - "coremedia-timing-samplebuffer-workflow", - ], - "coremedia-timing-samplebuffer-workflow": [ - "CMTime", - "CMClock", - "CMTimebase", - "CMFormatDescription", - "CMSampleBuffer", - "CMSampleTimingInfo", - "presentation timestamp", - "apple-media-type-ownership.md", - "AVSampleBufferRenderSynchronizer", - ], - "coreaudio-modernization-repair-workflow": [ - "AudioStreamBasicDescription", - "AudioComponentDescription", - "AudioBufferList", - "AudioQueue", - "AudioUnit", - "OSStatus", - "apple-media-type-ownership.md", - "archive docs only", - ], -} - - -class MediaAudioWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_media_audio_skills_have_required_structure_and_prompts(self) -> None: - for skill, expected_terms in MEDIA_SKILLS.items(): - with self.subTest(skill=skill): - skill_text = self.read(f"skills/{skill}/SKILL.md") - prompt_text = self.read(f"skills/{skill}/agents/openai.yaml") - - self.assertIn(f"name: {skill}", skill_text) - self.assertIn("Apply the Apple docs gate", skill_text) - self.assertIn("repair", skill_text.lower()) - self.assertIn("references/snippets/apple-xcode-project-core.md", skill_text) - self.assertIn(f"${skill}", prompt_text) - for term in expected_terms: - self.assertIn(term, skill_text) - - def test_reference_files_cover_repair_boundaries(self) -> None: - references = { - "skills/avfaudio-session-workflow/references/session-policy-and-repair.md": [ - "headphones disconnect", - "AVCaptureSession", - "AVAudioSession.RouteSharingPolicy", - "operation, category, mode, route, permission state", - ], - "skills/avaudio-engine-workflow/references/engine-graph-and-repair.md": [ - "AVAudioPCMBuffer", - "AudioStreamBasicDescription", - "conversion back to AVFAudio explicit", - ], - "skills/avaudio-engine-workflow/references/realtime-rendering-safety.md": [ - "allocation", - "await", - "main-actor hops", - ], - "skills/avfoundation-media-pipeline-workflow/references/media-pipeline-and-repair.md": [ - "AVAssetReaderOutput", - "AVAssetWriterInput", - "Use Core Media types for timing", - ], - "skills/avfoundation-media-pipeline-workflow/references/async-loading-and-backpressure.md": [ - "try await asset.load(.duration)", - "keep durations as `CMTime`", - "loadValuesAsynchronously(forKeys:)", - "isReadyForMoreMediaData", - ], - "skills/coremedia-timing-samplebuffer-workflow/references/diagnostics-and-handoffs.md": [ - "Keep diagnostics typed", - "CMSampleTimingInfo", - "CMFormatDescription", - ], - "skills/coremedia-timing-samplebuffer-workflow/references/time-samplebuffer-and-repair.md": [ - "presentation timestamp", - "decode timestamp", - "CMTime", - "CMTimebase", - "CMSampleTimingInfo", - ], - "skills/coreaudio-modernization-repair-workflow/references/coreaudio-modernization-and-repair.md": [ - "Framework choice", - "AudioConverterRef", - "OSStatus", - ], - "skills/coreaudio-modernization-repair-workflow/references/legacy-archive-boundary.md": [ - "historical or migration context", - "current AVFAudio documentation", - "Archive context does not justify replacing Apple media types", - "Audio Unit Programming Guide", - ], - } - - for path, terms in references.items(): - with self.subTest(path=path): - text = self.read(path) - for term in terms: - self.assertIn(term, text) - - def test_readme_and_validator_include_active_media_audio_skills(self) -> None: - readme = self.read("README.md") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - for skill in MEDIA_SKILLS: - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - - def test_shared_media_type_ownership_contract_is_strict(self) -> None: - text = self.read("shared/references/apple-media-type-ownership.md") - - for term in [ - "Use Apple and Swift media types as the default representation", - "only after naming the concrete reason", - "AVAudioSession.Category", - "AVAudioFormat", - "AVAssetWriterInput", - "CMTime", - "CMSampleBuffer", - "CMFormatDescription", - "AudioStreamBasicDescription", - "AudioBufferList", - "OSStatus", - "Do not convert `CMTime`", - "Do not model media type, route, category, mode", - "Do not keep duplicate AVFAudio and Core Audio codepaths", - ]: - self.assertIn(term, text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_media_expansion_audit.py b/plugins/apple-dev-skills/tests/test_media_expansion_audit.py deleted file mode 100644 index d1e090475..000000000 --- a/plugins/apple-dev-skills/tests/test_media_expansion_audit.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -import json -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class MediaExpansionAuditTests(unittest.TestCase): - skills = ( - "core-image-processing-workflow", - "apple-image-representation-workflow", - "vision-image-analysis-workflow", - "vision-coreml-recognition-workflow", - "camera-capture-depth-workflow", - "arkit-spatial-sensing-workflow", - "arkit-face-body-tracking-workflow", - "video-codec-processing-workflow", - "photos-library-editing-workflow", - ) - - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_every_media_skill_has_the_complete_workflow_contract(self) -> None: - required_headings = ( - "## Purpose", - "## When To Use", - "## Single-Path Workflow", - "## Inputs", - "## Outputs", - "## Guards and Stop Conditions", - "## Fallbacks and Handoffs", - "## Customization", - "## References", - ) - for name in self.skills: - skill = self.read(f"skills/{name}/SKILL.md") - references = "\n".join( - path.read_text(encoding="utf-8") - for path in (ROOT / "skills" / name / "references").glob("*.md") - ) - for heading in required_headings: - self.assertIn(heading, skill, f"{name} is missing {heading}") - self.assertIn("Apple docs", skill) - self.assertTrue( - "availability" in (skill + references).lower() - or "capability" in (skill + references).lower(), - f"{name} must gate availability or capability claims", - ) - - def test_framework_owners_and_handoffs_remain_distinct(self) -> None: - contracts = { - "core-image-processing-workflow": ("CIImage", "CIFilter", "CIContext"), - "apple-image-representation-workflow": ("CGImageSource", "CGImageDestination", "NSImage"), - "vision-image-analysis-workflow": ("Vision", "observations", "coordinates"), - "vision-coreml-recognition-workflow": ("Core ML", "model", "Vision"), - "camera-capture-depth-workflow": ("AVCapture", "AVDepthData", "calibration"), - "arkit-spatial-sensing-workflow": ("ARKit", "scene depth", "mesh"), - "arkit-face-body-tracking-workflow": ("Face ID", "face tracking", "body"), - "video-codec-processing-workflow": ("VideoToolbox", "CVPixelBuffer", "compressed"), - "photos-library-editing-workflow": ("PhotosUI", "PhotoKit", "nondestructive"), - } - for name, terms in contracts.items(): - content = self.read(f"skills/{name}/SKILL.md") - references = "\n".join( - path.read_text(encoding="utf-8") - for path in (ROOT / "skills" / name / "references").glob("*.md") - ) - for term in terms: - self.assertIn(term, content + references, f"{name} lost ownership term {term}") - - def test_privacy_device_evidence_and_direct_framework_paths_are_explicit(self) -> None: - combined = "\n".join( - self.read(f"skills/{name}/SKILL.md") for name in self.skills - ) - for term in ( - "physical device", - "runtime evidence", - "privacy", - "permission", - "bystander", - "Face ID", - "limited", - "Do not mirror", - "generic image managers", - "generic tracking manager", - "Photos repository", - ): - self.assertIn(term, combined) - - def test_public_inventory_and_plugin_metadata_cover_the_shipped_family(self) -> None: - readme = self.read("README.md") - plugin = json.loads(self.read(".codex-plugin/plugin.json")) - manifest_text = json.dumps(plugin) - for name in self.skills: - self.assertIn(f"`{name}`", readme) - for term in ( - "Core Image", - "Image I/O", - "Vision", - "Core ML", - "camera", - "depth", - "ARKit", - "VideoToolbox", - "Core Video", - "PhotosUI", - "PhotoKit", - ): - self.assertIn(term, manifest_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_milestone24_system_ui_workflows.py b/plugins/apple-dev-skills/tests/test_milestone24_system_ui_workflows.py deleted file mode 100644 index 60a060ca6..000000000 --- a/plugins/apple-dev-skills/tests/test_milestone24_system_ui_workflows.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class Milestone24SystemUIWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_app_intents_workflow_preserves_narrow_domain_and_system_boundaries(self) -> None: - skill = self.read("skills/app-intents-workflow/SKILL.md") - references = self.read("skills/app-intents-workflow/references/intent-entity-and-shortcut-shapes.md") - prompt = self.read("skills/app-intents-workflow/agents/openai.yaml") - customization = self.read("skills/app-intents-workflow/scripts/customization_config.py") - - for term in ("AppIntent", "AppEntity", "AppShortcutsProvider", "Spotlight", "widgets", "controls", "Live Activities"): - self.assertIn(term, skill + references) - self.assertIn("do not invent a parallel intent-only repository", skill) - self.assertIn("xcode-build-run-workflow", skill) - self.assertIn("$app-intents-workflow", prompt) - self.assertIn('SKILL_NAME = "app-intents-workflow"', customization) - - def test_liquid_glass_workflow_requires_native_composition_and_fallbacks(self) -> None: - skill = self.read("skills/swiftui-liquid-glass/SKILL.md") - references = self.read("skills/swiftui-liquid-glass/references/glass-composition-and-fallbacks.md") - prompt = self.read("skills/swiftui-liquid-glass/agents/openai.yaml") - customization = self.read("skills/swiftui-liquid-glass/scripts/customization_config.py") - - for term in ("glassEffect", "GlassEffectContainer", "glassEffectID", "interactive glass", "availability"): - self.assertIn(term, skill + references) - self.assertIn("Do not substitute custom blur stacks", skill) - self.assertIn("oldest supported OS fallback", skill) - availability = self.read("skills/swiftui-liquid-glass/references/os26-os27-beta-availability.md") - self.assertIn("core custom Liquid Glass surface remains the OS 26 baseline", availability) - self.assertIn("ToolbarItemVisibilityPriority", availability) - self.assertIn("ToolbarOverflowMenu", availability) - self.assertIn("$swiftui-liquid-glass", prompt) - self.assertIn('SKILL_NAME = "swiftui-liquid-glass"', customization) - - def test_runtime_and_distribution_workflows_preserve_evidence_boundaries(self) -> None: - performance = self.read("skills/swiftui-performance-audit/SKILL.md") - forensics = self.read("skills/ios-runtime-forensics-workflow/SKILL.md") - distribution = self.read("skills/macos-distribution-workflow/SKILL.md") - - self.assertIn("code-level suspicion or trace-backed evidence", performance) - self.assertIn("performance-trace", forensics) - self.assertIn("memory-graph", forensics) - distribution_reference = self.read("skills/macos-distribution-workflow/references/artifact-inspection-and-classification.md") - self.assertIn("codesign --verify --deep --strict --verbose=2 <artifact>", distribution_reference) - self.assertIn("spctl -a -vv <artifact>", distribution_reference) - self.assertIn("Do not call notarization necessary for a normal local Debug run", distribution) - - def test_tips_helpviewer_workflow_requires_a_local_match_and_owner_aware_fallback(self) -> None: - skill = self.read("skills/tips-helpviewer-workflow/SKILL.md") - reference = self.read("skills/tips-helpviewer-workflow/references/catalog-and-fallback-contract.md") - prompt = self.read("skills/tips-helpviewer-workflow/agents/openai.yaml") - - self.assertIn("com.apple.helpviewer", skill) - self.assertIn("com.apple.tips", skill) - self.assertIn("installed-version capture", skill) - self.assertIn("local-helpviewer", skill) - self.assertIn("Do not modify app settings", skill) - self.assertIn("Compressor export movie", reference) - self.assertIn("explore-apple-swift-docs", reference) - self.assertIn("$tips-helpviewer-workflow", prompt) - - def test_feedback_assistant_workflow_keeps_submission_and_api_boundaries_explicit(self) -> None: - skill = self.read("skills/feedback-assistant-workflow/SKILL.md") - app_reference = self.read("skills/feedback-assistant-workflow/references/live-app-and-api-boundaries.md") - foundation_reference = self.read("skills/feedback-assistant-workflow/references/foundation-models-feedback-attachments.md") - prompt = self.read("skills/feedback-assistant-workflow/agents/openai.yaml") - customization = self.read("skills/feedback-assistant-workflow/scripts/customization_config.py") - - for term in ("com.apple.appleseed.FeedbackAssistant", "one issue", "attachment manifest", "Immediately before submission"): - self.assertIn(term, skill) - self.assertIn("No Apple-documented general API", app_reference) - self.assertIn("LanguageModelSession.logFeedbackAttachment", foundation_reference) - self.assertIn("does not create or submit", foundation_reference) - self.assertIn("$feedback-assistant-workflow", prompt) - self.assertIn('SKILL_NAME = "feedback-assistant-workflow"', customization) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_photos_library_editing_workflow.py b/plugins/apple-dev-skills/tests/test_photos_library_editing_workflow.py deleted file mode 100644 index 28144c7df..000000000 --- a/plugins/apple-dev-skills/tests/test_photos_library_editing_workflow.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class PhotosLibraryEditingWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_picker_first_and_authorization_matrix_are_explicit(self) -> None: - skill = self.read("skills/photos-library-editing-workflow/SKILL.md") - selection = self.read( - "skills/photos-library-editing-workflow/references/photosui-selection-and-authorization.md" - ) - for term in ( - "PhotosPicker", - "PHPickerViewController", - "PhotosPickerItem", - "loadTransferable", - "privacy-preserving", - "PHAccessLevel.addOnly", - ".readWrite", - ".notDetermined", - ".restricted", - ".denied", - ".limited", - ".authorized", - "narrowest access", - "full-library album", - ): - self.assertIn(term, skill + selection) - - def test_assets_requests_resources_changes_and_cloud_delivery_are_typed(self) -> None: - skill = self.read("skills/photos-library-editing-workflow/SKILL.md") - assets = self.read( - "skills/photos-library-editing-workflow/references/assets-fetches-requests-resources-and-changes.md" - ) - for term in ( - "PHAsset", - "PHAssetCollection", - "PHFetchResult", - "PHPhotoLibraryChangeObserver", - "PHChange", - "fetchResultAfterChanges", - "PHImageManager", - "PHCachingImageManager", - "PHImageRequestID", - "degraded", - "iCloud", - "PHAssetResource", - "PHAssetResourceManager", - "Live Photo", - "RAW-plus-processed", - ): - self.assertIn(term, skill + assets) - - def test_creation_and_nondestructive_editing_are_transactional(self) -> None: - skill = self.read("skills/photos-library-editing-workflow/SKILL.md") - editing = self.read( - "skills/photos-library-editing-workflow/references/creation-collections-and-nondestructive-editing.md" - ) - for term in ( - "PHPhotoLibrary.performChanges", - "PHAssetCreationRequest", - "PHAssetCollectionChangeRequest", - "placeholders", - "PHContentEditingInput", - "PHContentEditingOutput", - "PHAdjustmentData", - "nondestructive", - "transaction", - "adjustment-version", - "authorizationStatus(for: .readWrite) == .authorized", - ): - self.assertIn(term, skill + editing) - - def test_no_repository_inventory_metadata_customization_and_handoffs_are_aligned(self) -> None: - skill = self.read("skills/photos-library-editing-workflow/SKILL.md") - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - name = "photos-library-editing-workflow" - self.assertIn("Do not mirror the entire library", skill) - self.assertIn("Photos repository", skill) - self.assertIn(f"`{name}`", readme) - self.assertIn(f"./skills/{name}/SKILL.md", validator) - self.assertIn(f"${name}", self.read(f"skills/{name}/agents/openai.yaml")) - self.assertIn( - f'SKILL_NAME = "{name}"', - self.read(f"skills/{name}/scripts/customization_config.py"), - ) - self.assertIn("PhotosUI", plugin) - self.assertIn("PhotoKit", plugin) - self.assertIn("Expected exactly 58 active skills", validator) - self.assertIn(name, self.read("skills/apple-image-representation-workflow/SKILL.md")) - self.assertIn(name, self.read("skills/core-image-processing-workflow/SKILL.md")) - self.assertIn(name, self.read("skills/avfoundation-media-pipeline-workflow/SKILL.md")) - self.assertIn(name, self.read("skills/swiftui-app-architecture-workflow/SKILL.md")) - self.assertIn(name, self.read("skills/appkit-app-architecture-workflow/SKILL.md")) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_safari_extension_control_workflow.py b/plugins/apple-dev-skills/tests/test_safari_extension_control_workflow.py deleted file mode 100644 index 0cd8838da..000000000 --- a/plugins/apple-dev-skills/tests/test_safari_extension_control_workflow.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class SafariExtensionControlWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_separates_safari_extension_shapes(self) -> None: - skill_text = self.read("skills/safari-extension-control-workflow/SKILL.md") - decision_text = self.read("skills/safari-extension-control-workflow/references/extension-shape-decision.md") - - self.assertIn("Safari Web Extension", skill_text) - self.assertIn("Safari Web Inspector Extension", skill_text) - self.assertIn("Safari App Extension", skill_text) - self.assertIn("Content blocker", skill_text) - self.assertIn("ASWebAuthenticationSession", decision_text) - self.assertIn("Safari App Extensions are macOS-only", decision_text) - self.assertIn(".safariextz", decision_text) - self.assertIn("no JavaScript execution path", decision_text) - - def test_web_inspector_extension_path_is_first_class(self) -> None: - skill_text = self.read("skills/safari-extension-control-workflow/SKILL.md") - inspector_text = self.read("skills/safari-extension-control-workflow/references/web-inspector-extensions.md") - prompt_text = self.read("skills/safari-extension-control-workflow/agents/openai.yaml") - - self.assertIn("web-inspector-extensions.md", skill_text) - self.assertIn("developer tools", inspector_text) - self.assertIn("Safari Web Inspector", inspector_text) - self.assertIn("inspected-page", inspector_text) - self.assertIn("Safari Web Inspector Extensions", prompt_text) - - def test_skill_keeps_control_surfaces_bounded(self) -> None: - skill_text = self.read("skills/safari-extension-control-workflow/SKILL.md") - control_text = self.read("skills/safari-extension-control-workflow/references/safari-services-control-surfaces.md") - - self.assertIn("Do not claim a Mac app can freely inspect or control arbitrary Safari", skill_text) - self.assertIn("SFSafariApplication.openWindow", control_text) - self.assertIn("SFSafariExtensionManager", control_text) - self.assertIn("external automation", control_text) - - def test_messaging_reference_names_contexts_and_privacy(self) -> None: - messaging_text = self.read("skills/safari-extension-control-workflow/references/messaging-shared-data-and-permissions.md") - - self.assertIn("containing macOS app", messaging_text) - self.assertIn("native app extension", messaging_text) - self.assertIn("WebExtension JavaScript", messaging_text) - self.assertIn("app groups", messaging_text) - self.assertIn("browsing history, cookies, tokens", messaging_text) - - def test_skill_handoffs_stay_explicit(self) -> None: - skill_text = self.read("skills/safari-extension-control-workflow/SKILL.md") - prompt_text = self.read("skills/safari-extension-control-workflow/agents/openai.yaml") - - self.assertIn("Recommend `explore-apple-swift-docs`", skill_text) - self.assertIn("Recommend `xcode-build-run-workflow`", skill_text) - self.assertIn("Recommend `xcode-testing-workflow`", skill_text) - self.assertIn("Recommend `swiftui-app-architecture-workflow`", skill_text) - self.assertIn("$explore-apple-swift-docs", prompt_text) - self.assertIn("$xcode-build-run-workflow", prompt_text) - self.assertIn("$xcode-testing-workflow", prompt_text) - self.assertIn("$swiftui-app-architecture-workflow", prompt_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_safari_mcp_workflow.py b/plugins/apple-dev-skills/tests/test_safari_mcp_workflow.py deleted file mode 100644 index 0a7085bf8..000000000 --- a/plugins/apple-dev-skills/tests/test_safari_mcp_workflow.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class SafariMCPWorkflowTests(unittest.TestCase): - def text(self, path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - def test_skill_owns_runtime_validation_not_extension_architecture(self) -> None: - skill = self.text("skills/safari-mcp-workflow/SKILL.md") - self.assertIn("Safari Technology Preview", skill) - self.assertIn("safari-extension-control-workflow", skill) - self.assertIn("not Safari extension architecture", skill) - self.assertIn("Safari-specific claims only", skill) - - def test_skill_requires_scoped_authorized_evidence(self) -> None: - skill = self.text("skills/safari-mcp-workflow/SKILL.md") - reference = self.text("skills/safari-mcp-workflow/references/evidence-and-validation.md") - self.assertIn("Do not inspect unrelated tabs", skill) - self.assertIn("fresh user confirmation", skill) - self.assertIn("screenshot alone", skill) - self.assertIn("get_page_content", skill) - self.assertIn("page_interactions", skill) - self.assertIn("observed facts from inference", reference) - - def test_setup_reference_keeps_registration_and_data_boundaries_explicit(self) -> None: - setup = self.text("skills/safari-mcp-workflow/references/setup-and-privacy.md") - prompt = self.text("skills/safari-mcp-workflow/agents/openai.yaml") - customization = self.text("skills/safari-mcp-workflow/scripts/customization_config.py") - self.assertIn("codex mcp add safari-mcp-stp", setup) - self.assertIn("explicit approval", setup) - self.assertIn("Allow remote automation", setup) - self.assertIn("uid", setup) - self.assertIn("expression", setup) - self.assertIn("close_tab", setup) - self.assertIn("AutoFill", setup) - self.assertIn("$safari-mcp-workflow", prompt) - self.assertIn('SKILL_NAME = "safari-mcp-workflow"', customization) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_structure_swift_sources_file_headers.py b/plugins/apple-dev-skills/tests/test_structure_swift_sources_file_headers.py deleted file mode 100644 index 0a72b1821..000000000 --- a/plugins/apple-dev-skills/tests/test_structure_swift_sources_file_headers.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -from datetime import date -import importlib.util -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE_PATH = ROOT / "skills/structure-swift-sources/scripts/normalize_swift_file_headers.py" -TEMPLATE_PATH = ROOT / "skills/structure-swift-sources/references/file-header-inventory.template.yaml" - - -def load_module(module_path: Path): - spec = importlib.util.spec_from_file_location(module_path.stem, module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load module from {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class StructureSwiftSourcesFileHeaderTests(unittest.TestCase): - def setUp(self) -> None: - self.module = load_module(MODULE_PATH) - - def test_report_counts_compliant_missing_and_malformed_headers(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Sources").mkdir() - (root / "Sources" / "Compliant.swift").write_text( - "/*\nSampleProject\nCompliant.swift\n© Gale Williams 2026\n\nConcern: Entry-point state and setup.\nPurpose: Explains the feature entry point.\nKey Types: FeatureView, FeatureState\nSee Also: GEAFeatureService.swift\n*/\n\nimport Foundation\n", - encoding="utf-8", - ) - (root / "Sources" / "Missing.swift").write_text("import Foundation\n", encoding="utf-8") - (root / "Sources" / "Malformed.swift").write_text( - "/*\nSampleProject\nMalformed.swift\n© Gale Williams 2026\n\nPurpose: Missing the concern field.\n*/\n\nimport Foundation\n", - encoding="utf-8", - ) - - payload = self.module.report_headers(root) - - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["files_scanned"], 3) - self.assertEqual(payload["counts"]["compliant"], 1) - self.assertEqual(payload["counts"]["missing-header"], 1) - self.assertEqual(payload["counts"]["malformed-header"], 1) - - def test_apply_inventory_preserves_license_block_and_adds_structured_header(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - source_dir = root / "Sources" - source_dir.mkdir() - target = source_dir / "Feature.swift" - target.write_text( - "/*\nCopyright 2026 Example.\n*/\n\nimport Foundation\n", - encoding="utf-8", - ) - inventory = root / "headers.yaml" - inventory.write_text( - "\n".join( - [ - "entries:", - " - path: Sources/Feature.swift", - ' purpose: "Defines the feature entry point in plain terms."', - ' concern: "Feature startup state and setup."', - ] - ) - + "\n", - encoding="utf-8", - ) - - payload = self.module.apply_inventory(root, inventory) - rewritten = target.read_text(encoding="utf-8") - - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["created_headers"], 1) - self.assertIn("Copyright 2026 Example.", rewritten) - self.assertIn(root.name, rewritten) - self.assertIn("Feature.swift", rewritten) - self.assertIn(f"© Gale Williams {date.today().year}", rewritten) - self.assertIn("Concern: Feature startup state and setup.", rewritten) - self.assertIn("Purpose: Defines the feature entry point in plain terms.", rewritten) - - def test_apply_inventory_replaces_existing_structured_header(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - source_dir = root / "Sources" - source_dir.mkdir() - target = source_dir / "Feature.swift" - target.write_text( - "/*\nOldProject\nFeature.swift\n© Gale Williams 2022\n\nConcern: Old concern.\nPurpose: Old purpose.\n*/\n\nimport Foundation\n", - encoding="utf-8", - ) - inventory = root / "headers.yaml" - inventory.write_text( - "\n".join( - [ - "entries:", - " - path: Sources/Feature.swift", - ' purpose: "Defines the new feature entry point."', - ' concern: "Feature state and wiring."', - ' key_types: "FeatureView, FeatureState"', - ' see_also: "GEAFeatureService.swift, GEAFeatureViewModifier.swift"', - ] - ) - + "\n", - encoding="utf-8", - ) - - payload = self.module.apply_inventory(root, inventory) - rewritten = target.read_text(encoding="utf-8") - - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["updated_headers"], 1) - self.assertIn(root.name, rewritten) - self.assertIn("© Gale Williams 2022", rewritten) - self.assertIn("Concern: Feature state and wiring.", rewritten) - self.assertIn("Purpose: Defines the new feature entry point.", rewritten) - self.assertIn("Key Types: FeatureView, FeatureState", rewritten) - self.assertIn("See Also: GEAFeatureService.swift, GEAFeatureViewModifier.swift", rewritten) - self.assertNotIn("Old purpose", rewritten) - - def test_checked_in_inventory_template_matches_loader_contract(self) -> None: - entries = self.module.load_inventory(TEMPLATE_PATH) - - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["path"], "Sources/Feature.swift") - self.assertTrue(entries[0]["concern"]) - self.assertTrue(entries[0]["purpose"]) - self.assertEqual(entries[0]["key_types"], ["FeatureView", "FeatureState"]) - self.assertEqual( - entries[0]["see_also"], - ["GEAFeatureService.swift", "GEAFeatureViewModifier.swift"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_structure_swift_sources_todo_fixme_ledgers.py b/plugins/apple-dev-skills/tests/test_structure_swift_sources_todo_fixme_ledgers.py deleted file mode 100644 index ea3a3f09d..000000000 --- a/plugins/apple-dev-skills/tests/test_structure_swift_sources_todo_fixme_ledgers.py +++ /dev/null @@ -1,228 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -MODULE_PATH = ROOT / "skills/structure-swift-sources/scripts/normalize_todo_fixme_ledgers.py" - - -def load_module(module_path: Path): - spec = importlib.util.spec_from_file_location(module_path.stem, module_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load module from {module_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class StructureSwiftSourcesTodoFixmeLedgerTests(unittest.TestCase): - def setUp(self) -> None: - self.module = load_module(MODULE_PATH) - - def write_roadmap(self, root: Path) -> None: - (root / "ROADMAP.md").write_text( - "\n".join( - [ - "## Milestone 29: Swift Cleanup Automation Exploration", - "", - "Tickets:", - "", - "- [ ] Evaluate a `codex exec`-friendly maintainer wrapper for sequential formatting and structure passes.", - "", - "## Milestone 30: Expand TODO and FIXME Ledger Normalization", - "", - "Tickets:", - "", - "- [ ] Extend source discovery beyond `.swift` to include Objective-C source files such as `.h`, `.m`, and `.mm`.", - ] - ) - + "\n", - encoding="utf-8", - ) - - def test_report_mode_counts_supported_syntaxes_and_unresolved_plan_refs(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - self.write_roadmap(root) - - (root / "Feature.swift").write_text( - "\n".join( - [ - "struct Feature {", - " // TODO: [M30] add caching", - "}", - ] - ) - + "\n", - encoding="utf-8", - ) - (root / "Legacy.h").write_text( - "\n".join( - [ - "#warning FIXME: [PLAN:docs/maintainers/missing-plan.md] patch legacy API", - ] - ) - + "\n", - encoding="utf-8", - ) - (root / "Compiler.swift").write_text( - '#warning("FIXME: FIXME-0002")\n', - encoding="utf-8", - ) - - payload = self.module.report_normalization(root) - - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["files_scanned"], 3) - self.assertEqual(payload["counts"]["TODO"], 1) - self.assertEqual(payload["counts"]["FIXME"], 2) - self.assertEqual(payload["textual_comments"]["TODO"], 1) - self.assertEqual(payload["existing_ids"]["FIXME"], 1) - self.assertEqual(payload["source_counts"]["line-comment"], 1) - self.assertEqual(payload["source_counts"]["objc-warning"], 1) - self.assertEqual(payload["source_counts"]["swift-warning"], 1) - self.assertEqual(payload["linked_roadmap_comments"], 1) - self.assertEqual(payload["linked_plan_comments"], 0) - self.assertEqual(len(payload["unresolved_references"]), 1) - self.assertIn("missing-plan.md", payload["unresolved_references"][0]["reason"]) - - def test_apply_mode_rewrites_swift_and_objc_and_renders_links(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - self.write_roadmap(root) - - plan_dir = root / "docs" / "maintainers" - plan_dir.mkdir(parents=True) - (plan_dir / "todo-plan.md").write_text("# Todo plan\n", encoding="utf-8") - (plan_dir / "fix-plan.md").write_text("# Fix plan\n", encoding="utf-8") - - swift_source = root / "Sources" / "Feature.swift" - swift_source.parent.mkdir(parents=True) - swift_source.write_text( - "\n".join( - [ - "struct Feature {", - " // TODO: [M30] [PLAN:docs/maintainers/todo-plan.md] add telemetry around retries", - "}", - ] - ) - + "\n", - encoding="utf-8", - ) - objc_source = root / "Sources" / "Legacy.m" - objc_source.write_text( - "#warning FIXME: [M29-T2] [PLAN:docs/maintainers/fix-plan.md] replace unsafe pointer dance\n", - encoding="utf-8", - ) - compiler_source = root / "Headers" / "Legacy.h" - compiler_source.parent.mkdir(parents=True) - compiler_source.write_text( - '#warning("FIXME: FIXME-0002")\n', - encoding="utf-8", - ) - - (root / "FIXME.md").write_text( - "\n".join( - [ - "# FIXME Ledger", - "", - "Track normalized FIXME tickets extracted from Swift sources.", - "", - "## FIXME-0002: Existing fix", - "- Status: open", - "- File: `Legacy.swift`", - "- Line: `9`", - "- Detail: Existing fix detail", - ] - ) - + "\n", - encoding="utf-8", - ) - - payload = self.module.apply_normalization(root) - - rewritten_swift = swift_source.read_text(encoding="utf-8") - rewritten_objc = objc_source.read_text(encoding="utf-8") - rewritten_warning = compiler_source.read_text(encoding="utf-8") - todo_ledger = (root / "TODO.md").read_text(encoding="utf-8") - fixme_ledger = (root / "FIXME.md").read_text(encoding="utf-8") - - self.assertEqual(payload["status"], "success") - self.assertIn("TODO-0001", payload["created_entries"]) - self.assertIn("FIXME-0003", payload["created_entries"]) - self.assertIn("FIXME-0002", payload["refreshed_entries"]) - self.assertEqual(payload["source_counts"]["line-comment"], 1) - self.assertEqual(payload["source_counts"]["objc-warning"], 1) - self.assertEqual(payload["source_counts"]["swift-warning"], 1) - - self.assertIn("// TODO: TODO-0001", rewritten_swift) - self.assertIn("#warning FIXME: FIXME-0003", rewritten_objc) - self.assertIn('#warning("FIXME: FIXME-0002")', rewritten_warning) - - self.assertIn("## TODO-0001: add telemetry around retries", todo_ledger) - self.assertIn("- File: `Sources/Feature.swift`", todo_ledger) - self.assertIn("- Line: `2`", todo_ledger) - self.assertIn("- Source: `line-comment`", todo_ledger) - self.assertIn("- Detail: add telemetry around retries", todo_ledger) - self.assertIn( - "- Roadmap: [Milestone 30](ROADMAP.md#milestone-30-expand-todo-and-fixme-ledger-normalization)", - todo_ledger, - ) - self.assertIn( - "- Plans: [docs/maintainers/todo-plan.md](docs/maintainers/todo-plan.md)", - todo_ledger, - ) - - self.assertIn("## FIXME-0003: replace unsafe pointer dance", fixme_ledger) - self.assertIn("- Source: `objc-warning`", fixme_ledger) - self.assertIn("- Detail: replace unsafe pointer dance", fixme_ledger) - self.assertIn( - "- Roadmap: [M29-T2](ROADMAP.md#milestone-29-swift-cleanup-automation-exploration)", - fixme_ledger, - ) - self.assertIn( - "- Plans: [docs/maintainers/fix-plan.md](docs/maintainers/fix-plan.md)", - fixme_ledger, - ) - self.assertIn("## FIXME-0002: Existing fix", fixme_ledger) - self.assertIn("- File: `Headers/Legacy.h`", fixme_ledger) - self.assertIn("- Line: `1`", fixme_ledger) - self.assertIn("- Source: `swift-warning`", fixme_ledger) - self.assertIn("- Detail: Existing fix detail", fixme_ledger) - self.assertIn("- Roadmap: none", fixme_ledger) - self.assertIn("- Plans: none", fixme_ledger) - - def test_cli_apply_prints_json_summary_for_objective_cplusplus(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - self.write_roadmap(root) - (root / "Mixed.mm").write_text( - "// TODO: [M30] wire analytics bridge\n", - encoding="utf-8", - ) - - proc = subprocess.run( - ["uv", "run", str(MODULE_PATH), "--root", str(root), "--apply"], - cwd=ROOT, - capture_output=True, - text=True, - check=False, - ) - - payload = json.loads(proc.stdout) - - self.assertEqual(proc.returncode, 0, msg=proc.stderr) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["comment_count"], 1) - self.assertEqual(payload["files_scanned"], 1) - self.assertIn("TODO-0001", payload["created_entries"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_structure_swift_sources_workflow.py b/plugins/apple-dev-skills/tests/test_structure_swift_sources_workflow.py deleted file mode 100644 index ff27c6fa2..000000000 --- a/plugins/apple-dev-skills/tests/test_structure_swift_sources_workflow.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/structure-swift-sources/scripts/run_workflow.py" - - -def write_config(tmpdir: str, settings: dict) -> None: - target = Path(tmpdir) / "structure-swift-sources" / "customization.yaml" - target.parent.mkdir(parents=True, exist_ok=True) - lines = ["schemaVersion: 1", "isCustomized: true", "settings:"] - for key, value in settings.items(): - if isinstance(value, int): - lines.append(f" {key}: {value}") - else: - lines.append(f' {key}: "{value}"') - target.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -class StructureWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_infers_header_cleanup_for_swift_component(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo = Path(tmpdir) - (repo / "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - (repo / "Sources" / "Demo").mkdir(parents=True) - (repo / "Sources" / "Demo" / "Feature.swift").write_text("import Foundation\n", encoding="utf-8") - - code, payload = self.run_script( - "--repo-path", - tmpdir, - "--request", - "Normalize the block-comment file headers for these Swift files", - ) - - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertNotIn("repository_kind", payload["output"]) - self.assertEqual(payload["output"]["cleanup_kind"], "file-header-normalization") - self.assertIn("scripts/normalize_swift_file_headers.py", payload["output"]["helper_scripts"]) - - def test_handoffs_docc_requests_to_dedicated_skill(self) -> None: - code, payload = self.run_script("--request", "Add DocC symbol docs to these files") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertEqual(payload["output"]["recommended_skill"], "author-swift-docc-docs") - - def test_handoffs_xcode_membership_requests(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo = Path(tmpdir) - (repo / "App.xcodeproj").mkdir() - code, payload = self.run_script( - "--repo-path", - tmpdir, - "--request", - "Move these files and update target membership afterward", - ) - - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertEqual(payload["output"]["recommended_skill"], "xcode-build-run-workflow") - - def test_runtime_customization_changes_header_policy_and_thresholds(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - write_config( - tmpdir, - { - "fileHeaderMode": "required", - "fileHeaderStyle": "project-banner", - "fileHeaderCopyrightOwner": "Gale Williams", - "splitSoftLimit": 250, - "splitHardLimit": 600, - }, - ) - code, payload = self.run_script( - "--request", - "Split this oversized file and normalize the file headers", - env=env, - ) - - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["header_policy"]["mode"], "required") - self.assertEqual(payload["output"]["header_policy"]["style"], "project-banner") - self.assertEqual(payload["output"]["header_policy"]["copyright_owner"], "Gale Williams") - self.assertEqual(payload["output"]["split_thresholds"]["soft_limit"], 250) - self.assertEqual(payload["output"]["split_thresholds"]["hard_limit"], 600) - - def test_swiftui_structure_requires_one_view_and_preview_per_file(self) -> None: - skill_text = (ROOT / "skills/structure-swift-sources/SKILL.md").read_text(encoding="utf-8") - source_rules_text = (ROOT / "skills/structure-swift-sources/references/source-organization-rules.md").read_text( - encoding="utf-8" - ) - layout_rules_text = (ROOT / "skills/structure-swift-sources/references/layout-rules.md").read_text( - encoding="utf-8" - ) - - self.assertIn("require exactly one SwiftUI `View` component per file", skill_text) - self.assertIn("keep that component's Xcode SwiftUI preview in the same file", skill_text) - self.assertIn("Hand SwiftUI component", source_rules_text) - self.assertIn("swiftui-app-architecture-workflow", layout_rules_text) - - def test_swiftui_view_model_structure_is_per_view_only(self) -> None: - skill_text = (ROOT / "skills/structure-swift-sources/SKILL.md").read_text(encoding="utf-8") - source_rules_text = (ROOT / "skills/structure-swift-sources/references/source-organization-rules.md").read_text( - encoding="utf-8" - ) - layout_rules_text = (ROOT / "skills/structure-swift-sources/references/layout-rules.md").read_text( - encoding="utf-8" - ) - - self.assertIn("do not use `+` filenames", skill_text) - self.assertIn("concatenated filename grammar", source_rules_text) - self.assertIn("GEASettingsSheetToggleCard.swift", layout_rules_text) - self.assertIn("GEAWhateverModel.swift", layout_rules_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_swift_cleanup_skill_boundaries.py b/plugins/apple-dev-skills/tests/test_swift_cleanup_skill_boundaries.py deleted file mode 100644 index 45c3f3b8b..000000000 --- a/plugins/apple-dev-skills/tests/test_swift_cleanup_skill_boundaries.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class SwiftCleanupSkillBoundaryTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_structure_skill_hands_docc_work_to_dedicated_skill(self) -> None: - skill_text = self.read("skills/structure-swift-sources/SKILL.md") - prompt_text = self.read("skills/structure-swift-sources/agents/openai.yaml") - rules_text = self.read("skills/structure-swift-sources/references/source-organization-rules.md") - - self.assertIn("not the DocC authoring authority", skill_text) - self.assertIn("author-swift-docc-docs", skill_text) - self.assertNotIn("DocC coverage pass", skill_text) - self.assertNotIn("DocC-compliant documentation comments", skill_text) - self.assertIn("hand off to $author-swift-docc-docs", prompt_text) - self.assertIn("Documentation Boundary", rules_text) - self.assertNotIn("## DocC Rule", rules_text) - - def test_format_skill_routes_docc_work_to_dedicated_skill(self) -> None: - skill_text = self.read("skills/format-swift-sources/SKILL.md") - - self.assertIn("Recommend `author-swift-docc-docs`", skill_text) - self.assertNotIn("DocC coverage", skill_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_swift_package_build_run_workflow.py b/plugins/apple-dev-skills/tests/test_swift_package_build_run_workflow.py deleted file mode 100644 index 2a7877dbe..000000000 --- a/plugins/apple-dev-skills/tests/test_swift_package_build_run_workflow.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/swift-package-build-run-workflow/scripts/run_workflow.py" - - -class SwiftPackageBuildRunWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_succeeds_for_package_build(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--operation-type", "build", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["planned_commands"], ["swift build"]) - - def test_handoffs_test_requests_to_testing_skill(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--request", "run the package tests", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("swift-package-testing-workflow", payload["output"]["next_step"]) - - def test_handoffs_package_extensions_to_extension_skill(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.2\n", encoding="utf-8") - code, payload = self.run_script("--request", "inspect the package traits", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("swift-package-extension-workflow", payload["output"]["next_step"]) - - def test_xcode_files_do_not_change_swiftpm_build_execution(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - Path(tmpdir, "Demo.xcodeproj").mkdir() - code, payload = self.run_script("--operation-type", "build", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["planned_commands"], ["swift build"]) - self.assertNotIn("mixed_root", payload["output"]["package_context"]) - - def test_infers_nested_package_root_and_target_context(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - package_root = Path(tmpdir, "WorkspaceRoot", "Packages", "DemoPkg") - (package_root / "Sources" / "DemoTool").mkdir(parents=True) - (package_root / "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--operation-type", "run", "--repo-root", str(package_root / "Sources" / "DemoTool")) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["package_context"]["reason"], "package-root-inferred") - self.assertEqual(payload["output"]["inferred_context"]["primary_target"], "DemoTool") - self.assertEqual(payload["output"]["planned_commands"][0], "swift run DemoTool") - - def test_handoffs_metal_compilation_requests_to_xcode_when_metal_sources_exist(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - package_root = Path(tmpdir) - (package_root / "Sources" / "DemoPkg").mkdir(parents=True) - (package_root / "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - (package_root / "Sources" / "DemoPkg" / "Shaders.metal").write_text("// metal\n", encoding="utf-8") - code, payload = self.run_script("--request", "build the metal shaders for this package", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertTrue(payload["output"]["inferred_context"]["has_metal_sources"]) - self.assertIn("Metal", payload["output"]["next_step"]) - - def test_resource_focused_request_adds_resource_validation_commands(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - package_root = Path(tmpdir) - (package_root / "Sources" / "DemoPkg").mkdir(parents=True) - (package_root / "Tests" / "DemoPkgTests").mkdir(parents=True) - (package_root / "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--request", "verify package resources and Bundle.module loading", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertTrue(payload["output"]["inferred_context"]["resource_request"]) - joined = "\n".join(payload["output"]["planned_commands"]) - self.assertIn("swift package dump-package", joined) - self.assertIn("Bundle.module", joined) - - def test_blocks_when_no_operation_or_request_is_provided(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--repo-root", tmpdir) - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_swift_package_extension_workflow.py b/plugins/apple-dev-skills/tests/test_swift_package_extension_workflow.py deleted file mode 100644 index ca2d2186a..000000000 --- a/plugins/apple-dev-skills/tests/test_swift_package_extension_workflow.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SKILL = ROOT / "skills/swift-package-extension-workflow" -SCRIPT = SKILL / "scripts/run_workflow.py" - - -class SwiftPackageExtensionWorkflowTests(unittest.TestCase): - def run_script(self, *args: str) -> tuple[int, dict]: - env = dict(os.environ) - env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def package(self, root: str) -> None: - Path(root, "Package.swift").write_text("// swift-tools-version: 6.2\n", encoding="utf-8") - - def test_trait_plan_covers_both_toolchains_and_support_floor(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - self.package(tmpdir) - code, payload = self.run_script("--extension-type", "traits", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - commands = "\n".join(payload["output"]["planned_commands"]) - self.assertIn("swiftly use --print-location", commands) - self.assertIn("xcrun swift --version", commands) - self.assertIn("swift package show-traits --format json", commands) - self.assertIn("xcrun swift test --disable-default-traits", commands) - self.assertEqual(payload["output"]["support_window"]["minimum"], "6.2") - - def test_infers_macro_work(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - self.package(tmpdir) - code, payload = self.run_script("--request", "diagnose this macro expansion", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["output"]["extension_type"], "macro") - self.assertIn("swift package init --type macro", payload["output"]["planned_commands"]) - - def test_xcode_files_do_not_change_package_extension_execution(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - self.package(tmpdir) - Path(tmpdir, "Demo.xcodeproj").mkdir() - code, payload = self.run_script("--extension-type", "command-plugin", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertIn("swift package plugin --list", payload["output"]["planned_commands"]) - self.assertNotIn("mixed_root", payload["output"]["package_context"]) - - def test_skill_contains_planned_reference_set_and_toolchain_boundary(self) -> None: - skill_text = (SKILL / "SKILL.md").read_text(encoding="utf-8") - references = { - "package-plugins-build-command-and-xcode.md", - "plugin-permissions-sandbox-and-outputs.md", - "swift-macros-package-shape.md", - "package-traits-feature-flags.md", - "generated-source-and-build-products.md", - "cli-command-matrix.md", - } - for reference in references: - self.assertTrue((SKILL / "references" / reference).is_file()) - self.assertIn(reference, skill_text) - shared_routing = ROOT / "shared/execution-surface-routing.md" - self.assertTrue(shared_routing.is_file()) - self.assertIn("co-located Xcode files never change package routing", skill_text) - self.assertIn("swiftly use --print-location", skill_text) - self.assertIn("xcrun swift --version", skill_text) - - def test_existing_package_skills_route_extension_work(self) -> None: - for name in ( - "swift-package-build-run-workflow", - "swift-package-testing-workflow", - ): - text = (ROOT / "skills" / name / "SKILL.md").read_text(encoding="utf-8") - self.assertIn("swift-package-extension-workflow", text) -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_swift_package_testing_workflow.py b/plugins/apple-dev-skills/tests/test_swift_package_testing_workflow.py deleted file mode 100644 index ae267ae05..000000000 --- a/plugins/apple-dev-skills/tests/test_swift_package_testing_workflow.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/swift-package-testing-workflow/scripts/run_workflow.py" - - -class SwiftPackageTestingWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_succeeds_for_package_tests(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--operation-type", "test", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["planned_commands"][0], "swift test") - - def test_handoffs_build_requests_to_build_run_skill(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--request", "build the release artifact", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("swift-package-build-run-workflow", payload["output"]["next_step"]) - - def test_handoffs_macro_test_shape_to_extension_skill(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.2\n", encoding="utf-8") - code, payload = self.run_script("--request", "test the macro expansion", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("swift-package-extension-workflow", payload["output"]["next_step"]) - - def test_xcode_files_do_not_change_swiftpm_test_execution(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - Path(tmpdir, "Demo.xcodeproj").mkdir() - code, payload = self.run_script("--operation-type", "test", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["output"]["planned_commands"][0], "swift test") - self.assertNotIn("mixed_root", payload["output"]["package_context"]) - - def test_infers_test_plan_and_scheme_context(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - package_root = Path(tmpdir) - (package_root / "Tests" / "DemoPkgTests").mkdir(parents=True) - (package_root / "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - (package_root / "DemoPkg.xctestplan").write_text("{}", encoding="utf-8") - code, payload = self.run_script("--operation-type", "test", "--repo-root", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertTrue(payload["output"]["inferred_context"]["has_xcode_test_plan"]) - self.assertEqual(payload["output"]["inferred_context"]["xcode_scheme_hint"], "DemoPkg") - joined = "\n".join(payload["output"]["planned_commands"]) - self.assertIn("-showTestPlans", joined) - self.assertIn("-testPlan DemoPkg", joined) - - def test_blocks_when_no_operation_or_request_is_provided(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - code, payload = self.run_script("--repo-root", tmpdir) - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - - def test_skill_keeps_accessibility_boundary_as_handoff(self) -> None: - skill_text = (ROOT / "skills/swift-package-testing-workflow/SKILL.md").read_text(encoding="utf-8") - reference_text = ( - ROOT / "skills/swift-package-testing-workflow/references/package-resources-testing-and-builds.md" - ).read_text(encoding="utf-8") - - self.assertIn("apple-ui-accessibility-workflow", skill_text) - self.assertIn("runtime UI accessibility verification", reference_text) - self.assertIn("semantic formatting", reference_text) - - def test_skill_documents_heavy_model_test_scheduling(self) -> None: - reference_text = ( - ROOT / "skills/swift-package-testing-workflow/references/package-resources-testing-and-builds.md" - ).read_text(encoding="utf-8") - snippet_text = ( - ROOT / "skills/swift-package-testing-workflow/references/snippets/apple-swift-package-core.md" - ).read_text(encoding="utf-8") - - for text in (reference_text, snippet_text): - self.assertIn("normal SwiftPM parallel test execution", text) - self.assertIn("over 500 million parameters", text) - self.assertIn("sequentially, one at a time", text) - self.assertIn("unload_models", text) - self.assertIn("reload_models", text) - - def test_skill_documents_swiftpm_coverage_collection_and_discovery(self) -> None: - skill_text = (ROOT / "skills/swift-package-testing-workflow/SKILL.md").read_text(encoding="utf-8") - coverage_text = (ROOT / "skills/swift-package-testing-workflow/references/code-coverage.md").read_text(encoding="utf-8") - - self.assertIn("references/code-coverage.md", skill_text) - self.assertIn("swift test --enable-code-coverage", coverage_text) - self.assertIn("swift test --show-codecov-path", coverage_text) - self.assertIn("separate report-location query", coverage_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_swiftui_app_architecture_workflow.py b/plugins/apple-dev-skills/tests/test_swiftui_app_architecture_workflow.py deleted file mode 100644 index f78b24c7c..000000000 --- a/plugins/apple-dev-skills/tests/test_swiftui_app_architecture_workflow.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class SwiftUIAppArchitectureWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_keeps_focus_as_first_class_architecture_surface(self) -> None: - skill_text = self.read("skills/swiftui-app-architecture-workflow/SKILL.md") - focus_text = self.read("skills/swiftui-app-architecture-workflow/references/focus-and-focused-context.md") - - self.assertIn("focus and focused context", skill_text) - self.assertIn("focused object", skill_text) - self.assertIn("FocusState", focus_text) - self.assertIn("focusedSceneObject", focus_text) - self.assertIn("focusScope", focus_text) - - def test_skill_handoffs_stay_explicit(self) -> None: - skill_text = self.read("skills/swiftui-app-architecture-workflow/SKILL.md") - prompt_text = self.read("skills/swiftui-app-architecture-workflow/agents/openai.yaml") - - self.assertIn("Recommend `explore-apple-swift-docs`", skill_text) - self.assertIn("Recommend `apple-ui-accessibility-workflow`", skill_text) - self.assertIn("Recommend `xcode-build-run-workflow`", skill_text) - self.assertIn("Recommend `xcode-testing-workflow`", skill_text) - self.assertIn("$apple-ui-accessibility-workflow", prompt_text) - self.assertIn("$explore-apple-swift-docs", prompt_text) - self.assertIn("$xcode-build-run-workflow", prompt_text) - self.assertIn("$xcode-testing-workflow", prompt_text) - - def test_splitview_and_inspector_reference_is_first_class(self) -> None: - skill_text = self.read("skills/swiftui-app-architecture-workflow/SKILL.md") - splitview_text = self.read( - "skills/swiftui-app-architecture-workflow/references/navigation-splitview-sidebar-and-inspector.md" - ) - - self.assertIn("navigation-splitview-sidebar-and-inspector.md", skill_text) - self.assertIn("NavigationSplitView", splitview_text) - self.assertIn("InspectorCommands", splitview_text) - self.assertIn("sidebarToggle", splitview_text) - self.assertIn("List(selection:)", splitview_text) - - def test_desktop_scene_coverage_mentions_utility_window(self) -> None: - scene_text = self.read("skills/swiftui-app-architecture-workflow/references/app-and-scene-structure.md") - - self.assertIn("UtilityWindow", scene_text) - self.assertIn("FocusedValues", scene_text) - - def test_view_components_require_file_local_previews(self) -> None: - skill_text = self.read("skills/swiftui-app-architecture-workflow/SKILL.md") - anti_patterns_text = self.read( - "skills/swiftui-app-architecture-workflow/references/anti-patterns-and-corrections.md" - ) - - self.assertIn("complex enough to edit or preview independently", skill_text) - self.assertIn("small private helper views may remain", skill_text) - self.assertIn("Grouped SwiftUI View Files", anti_patterns_text) - self.assertIn("keep that component's Xcode SwiftUI preview in the same file", anti_patterns_text) - - def test_swiftui_components_do_not_use_external_view_models(self) -> None: - skill_text = self.read("skills/swiftui-app-architecture-workflow/SKILL.md") - anti_patterns_text = self.read( - "skills/swiftui-app-architecture-workflow/references/anti-patterns-and-corrections.md" - ) - shared_snippet_text = self.read("shared/agents-snippets/apple-xcode-project-core.md") - - self.assertIn("Do not make an external ViewModel", skill_text) - self.assertIn("Never use `+` filenames", skill_text) - self.assertIn("External SwiftUI View Models And Collaborators", anti_patterns_text) - self.assertIn("memberwise initializer", anti_patterns_text) - self.assertIn("self-contained, reactive, flexible, reusable", shared_snippet_text) - self.assertIn("Do not inject external ViewModels", shared_snippet_text) - self.assertIn("direct, concrete feature services", shared_snippet_text) - self.assertIn("Umbrella Service And Forwarding Chains", anti_patterns_text) - self.assertIn("direct concrete feature service", skill_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_swiftui_component_audit_workflow.py b/plugins/apple-dev-skills/tests/test_swiftui_component_audit_workflow.py deleted file mode 100644 index 455f1333d..000000000 --- a/plugins/apple-dev-skills/tests/test_swiftui_component_audit_workflow.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class SwiftUIComponentAuditWorkflowTests(unittest.TestCase): - def test_audit_requires_declarative_component_boundaries(self) -> None: - skill_text = (ROOT / "skills/swiftui-component-audit-workflow/SKILL.md").read_text(encoding="utf-8") - examples_text = ( - ROOT / "skills/swiftui-component-audit-workflow/references/component-rules-and-examples.md" - ).read_text(encoding="utf-8") - - self.assertIn("external ViewModels", skill_text) - self.assertIn("memberwise initializer", skill_text) - self.assertIn("custom environment value or action", skill_text) - self.assertIn("preference keys", skill_text) - self.assertIn("@Query", skill_text) - self.assertIn("GEAItemRowViewModel", examples_text) - self.assertIn("onToggle", examples_text) - self.assertIn("GEADownloadService", examples_text) - self.assertIn("umbrella app service", skill_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_tipkit_workflow.py b/plugins/apple-dev-skills/tests/test_tipkit_workflow.py deleted file mode 100644 index 7b17025bf..000000000 --- a/plugins/apple-dev-skills/tests/test_tipkit_workflow.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class TipKitWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_owns_tipkit_setup_and_presentations(self) -> None: - skill = self.read("skills/tipkit-workflow/SKILL.md") - presentation = self.read("skills/tipkit-workflow/references/presentation-and-platform-patterns.md") - - for term in ("Tips.configure", "TipView", "popoverTip", "TipUIView", "TipUIPopoverViewController", "TipNSView", "TipNSPopover"): - self.assertIn(term, skill + presentation) - self.assertIn("Prefer an inline tip whenever practical", presentation) - self.assertIn("attach `popoverTip` to the exact feature", presentation) - - def test_skill_covers_eligibility_lifecycle_testing_and_diagnosis(self) -> None: - skill = self.read("skills/tipkit-workflow/SKILL.md") - lifecycle = self.read("skills/tipkit-workflow/references/eligibility-lifecycle-and-testing.md") - - for term in ("@Parameter", "Tips.Event", "#Rule", "donate()", "sendDonation", "invalidate(reason:)", "showAllTipsForTesting", "hideAllTipsForTesting", "resetDatastore"): - self.assertIn(term, skill + lifecycle) - self.assertIn("Multiple entries in `rules` combine with logical AND", lifecycle) - self.assertIn("Call `Tips.resetDatastore()` before `Tips.configure()`", lifecycle) - self.assertIn("Never leave unconditional datastore resets", lifecycle) - - def test_skill_requires_docs_and_explicit_handoffs(self) -> None: - skill = self.read("skills/tipkit-workflow/SKILL.md") - prompt = self.read("skills/tipkit-workflow/agents/openai.yaml") - customization = self.read("skills/tipkit-workflow/scripts/customization_config.py") - - self.assertIn("Apply the Apple docs gate", skill) - self.assertIn("State the documented TipKit behavior", skill) - self.assertIn("Recommend `explore-apple-swift-docs`", skill) - self.assertIn("Recommend `xcode-build-run-workflow`", skill) - self.assertIn("Recommend `xcode-testing-workflow`", skill) - self.assertIn("$tipkit-workflow", prompt) - self.assertIn('SKILL_NAME = "tipkit-workflow"', customization) - - def test_inventory_and_metadata_include_tipkit(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - self.assertIn("tipkit-workflow", readme) - self.assertIn("TipKit", plugin) - self.assertIn("./skills/tipkit-workflow/SKILL.md", validator) - self.assertIn("Expected exactly 58 active skills", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_tvos_workflows.py b/plugins/apple-dev-skills/tests/test_tvos_workflows.py deleted file mode 100644 index 028bc5717..000000000 --- a/plugins/apple-dev-skills/tests/test_tvos_workflows.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class TVOSWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_app_experience_workflow_owns_focus_without_overclaiming_ai(self) -> None: - skill = self.read("skills/tvos-app-experience-workflow/SKILL.md") - platform = self.read("skills/tvos-app-experience-workflow/references/platform-beta-and-migration.md") - - for term in ( - "SwiftUI is the primary implementation path", - "directional focus remains under user control", - "UIFocusGuide", - "TVMLKit has been deprecated since tvOS 18", - "Do not claim direct Core AI", - "model-lab-skills:choose-apple-model-runtime", - "xcode-testing-workflow", - ): - with self.subTest(term=term): - self.assertIn(term, skill) - self.assertIn("do not name tvOS as a direct app runtime target", platform) - - def test_app_experience_references_cover_remote_large_text_and_device_evidence(self) -> None: - focus = self.read("skills/tvos-app-experience-workflow/references/focus-layout-and-input.md") - validation = self.read("skills/tvos-app-experience-workflow/references/validation-expectations.md") - - for term in ("Siri Remote", "focusSection()", "Large Text", "Preserve `Menu`/Back"): - self.assertIn(term, focus) - for term in ("VoiceOver", "Apple TV model", "real Apple TV", "xcode-testing-workflow"): - self.assertIn(term, validation) - - def test_media_workflow_keeps_system_player_and_command_matrix_explicit(self) -> None: - skill = self.read("skills/tvos-media-playback-workflow/SKILL.md") - commands = self.read("skills/tvos-media-playback-workflow/references/system-player-and-remote-commands.md") - validation = self.read("skills/tvos-media-playback-workflow/references/playback-validation-and-handoffs.md") - - for term in ( - "AVPlayerViewController", - "MPRemoteCommandCenter", - "MPNowPlayingInfoCenter", - "custom-player justification", - "tvos-app-experience-workflow", - "avfoundation-media-pipeline-workflow", - ): - with self.subTest(term=term): - self.assertIn(term, skill) - for term in ("Menu/Back", "previous/next", "focus to an intentional browse control"): - self.assertIn(term, validation) - self.assertIn("A visual preference alone is not a", commands) - self.assertIn("sufficient custom-player justification", commands) - - def test_discovery_and_portability_surfaces_list_both_workflows(self) -> None: - readme = self.read("README.md") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - for skill in ("tvos-app-experience-workflow", "tvos-media-playback-workflow"): - with self.subTest(skill=skill): - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_video_codec_processing_workflow.py b/plugins/apple-dev-skills/tests/test_video_codec_processing_workflow.py deleted file mode 100644 index e854907cb..000000000 --- a/plugins/apple-dev-skills/tests/test_video_codec_processing_workflow.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class VideoCodecProcessingWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_covers_compression_decompression_properties_and_lifecycle(self) -> None: - skill = self.read("skills/video-codec-processing-workflow/SKILL.md") - lifecycle = self.read( - "skills/video-codec-processing-workflow/references/compression-decompression-and-session-lifecycle.md" - ) - for term in ( - "VTCompressionSession", - "VTCompressionSessionCreate", - "VTCompressionSessionPrepareToEncodeFrames", - "VTCompressionSessionCompleteFrames", - "VTDecompressionSession", - "VTDecompressionSessionFinishDelayedFrames", - "VTMultiPassStorage", - "supported properties", - "UsingHardwareAcceleratedVideoEncoder", - "UsingHardwareAcceleratedVideoDecoder", - "Invalidate", - "OSStatus", - ): - self.assertIn(term.lower(), (skill + lifecycle).lower()) - - def test_pixel_buffer_interop_color_and_hdr_are_preserved(self) -> None: - skill = self.read("skills/video-codec-processing-workflow/SKILL.md") - pixels = self.read( - "skills/video-codec-processing-workflow/references/pixel-buffers-metal-color-and-hdr.md" - ) - for term in ( - "CVPixelBuffer", - "CVPixelBufferPool", - "CVPixelBufferLockBaseAddress", - "planes", - "CVMetalTextureCacheCreateTextureFromImage", - "CVMetalTexture", - "IOSurface", - "zero-copy", - "color primaries", - "transfer function", - "YCbCr matrix", - "clean aperture", - "pixel aspect ratio", - "HDR", - "alpha", - ): - self.assertIn(term, skill + pixels) - - def test_compressed_samples_diagnostics_and_performance_are_explicit(self) -> None: - skill = self.read("skills/video-codec-processing-workflow/SKILL.md") - diagnostics = self.read( - "skills/video-codec-processing-workflow/references/compressed-samples-diagnostics-and-performance.md" - ) - for term in ( - "CMVideoFormatDescription", - "parameter sets", - "CMSampleBuffer", - "presentation", - "decode timestamps", - "dependency flags", - "callback status", - "first-frame latency", - "end-to-end frame age", - "representative devices", - "Bound in-flight frames", - ): - self.assertIn(term, skill + diagnostics) - - def test_avfoundation_preference_inventory_metadata_and_handoffs_are_aligned(self) -> None: - skill = self.read("skills/video-codec-processing-workflow/SKILL.md") - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - name = "video-codec-processing-workflow" - self.assertIn("prefer AVFoundation", skill) - self.assertIn(f"`{name}`", readme) - self.assertIn(f"./skills/{name}/SKILL.md", validator) - self.assertIn(f"${name}", self.read(f"skills/{name}/agents/openai.yaml")) - self.assertIn( - f'SKILL_NAME = "{name}"', - self.read(f"skills/{name}/scripts/customization_config.py"), - ) - self.assertIn("VideoToolbox", plugin) - self.assertIn("Core Video", plugin) - self.assertIn("Expected exactly 58 active skills", validator) - self.assertIn(name, self.read("skills/avfoundation-media-pipeline-workflow/SKILL.md")) - self.assertIn(name, self.read("skills/coremedia-timing-samplebuffer-workflow/SKILL.md")) - - def test_shared_media_contract_includes_video_codec_types(self) -> None: - media = self.read("shared/references/apple-media-type-ownership.md") - for term in ( - "CVPixelBuffer", - "CVPixelBufferPool", - "CVMetalTextureCache", - "VTCompressionSession", - "VTDecompressionSession", - "VTMultiPassStorage", - "Choose VideoToolbox only when AVFoundation cannot express", - "Do not replace `CVPixelBuffer`", - "supported properties", - ): - self.assertIn(term, media) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_vision_recognition_workflows.py b/plugins/apple-dev-skills/tests/test_vision_recognition_workflows.py deleted file mode 100644 index 14382420a..000000000 --- a/plugins/apple-dev-skills/tests/test_vision_recognition_workflows.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class VisionRecognitionWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_builtin_vision_workflow_covers_requests_sequences_and_analysis(self) -> None: - skill = self.read("skills/vision-image-analysis-workflow/SKILL.md") - requests = self.read( - "skills/vision-image-analysis-workflow/references/vision-requests-observations-and-sequences.md" - ) - live = self.read( - "skills/vision-image-analysis-workflow/references/vision-coordinates-live-frames-and-diagnostics.md" - ) - for term in ( - "ImageRequestHandler", - "VNImageRequestHandler", - "VNSequenceRequestHandler", - "request revisions", - "text", - "barcode", - "face", - "pose", - "segmentation", - "feature prints", - "bounded newest-frame", - "source frame identity", - ): - self.assertIn(term, skill + requests + live) - - def test_coreml_workflow_covers_provenance_models_outputs_and_evaluation(self) -> None: - skill = self.read("skills/vision-coreml-recognition-workflow/SKILL.md") - integration = self.read( - "skills/vision-coreml-recognition-workflow/references/vision-coreml-model-integration.md" - ) - evaluation = self.read( - "skills/vision-coreml-recognition-workflow/references/model-evaluation-performance-and-diagnostics.md" - ) - for term in ( - "CoreMLRequest", - "VNCoreMLModel", - "VNCoreMLRequest", - "MLModelDescription", - "MLModelConfiguration", - "computeUnits", - "classification", - "object detection", - "segmentation", - "immutable provenance", - "regression", - "representative devices", - ): - self.assertIn(term, skill + integration + evaluation) - - def test_shared_contract_covers_coordinates_confidence_identity_and_staleness(self) -> None: - contract = self.read("shared/references/apple-vision-analysis-contract.md") - for term in ( - "source identity", - "source orientation", - "preprocessing transform", - "observation coordinate space", - "normalized coordinates", - "It is not automatically a calibrated probability", - "TrueDepth face geometry is ARKit sensing", - "Face ID and Touch ID authentication belong to Local Authentication", - "Bound in-flight work", - "older result", - ): - self.assertIn(term, contract) - - def test_inventory_metadata_customization_and_handoffs_are_aligned(self) -> None: - readme = self.read("README.md") - plugin = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - for skill in ("vision-image-analysis-workflow", "vision-coreml-recognition-workflow"): - self.assertIn(f"`{skill}`", readme) - self.assertIn(f"./skills/{skill}/SKILL.md", validator) - self.assertIn(f"${skill}", self.read(f"skills/{skill}/agents/openai.yaml")) - self.assertIn( - f'SKILL_NAME = "{skill}"', - self.read(f"skills/{skill}/scripts/customization_config.py"), - ) - self.assertIn("Apple Vision", plugin) - self.assertIn("Core ML", plugin) - self.assertIn("Expected exactly 58 active skills", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_build_run_workflow.py b/plugins/apple-dev-skills/tests/test_xcode_build_run_workflow.py deleted file mode 100644 index a1e61c8c0..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_build_run_workflow.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/xcode-build-run-workflow/scripts/run_workflow.py" - - -def write_config(tmpdir: str, skill: str, settings: dict) -> None: - target = Path(tmpdir) / skill / "customization.yaml" - target.parent.mkdir(parents=True, exist_ok=True) - lines = ["schemaVersion: 1", "isCustomized: true", "settings:"] - for key, value in settings.items(): - raw = str(value) if isinstance(value, int) else f'"{value}"' - lines.append(f" {key}: {raw}") - target.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -class XcodeBuildRunWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_handoffs_test_requests_to_xcode_testing_workflow(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Demo.xcodeproj").mkdir() - code, payload = self.run_script("--request", "run the UI tests", "--workspace-path", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("xcode-testing-workflow", payload["output"]["next_step"]) - - def test_build_fallback_includes_swift_build_by_default(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// test\n", encoding="utf-8") - code, payload = self.run_script( - "--operation-type", - "build", - "--workspace-path", - tmpdir, - "--mcp-failure-reason", - "timeout", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["path_type"], "fallback") - self.assertIn("swift build", payload["output"]["fallback_commands"]) - - def test_customization_can_use_xcode_only_fallback_profile(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Package.swift").write_text("// test\n", encoding="utf-8") - write_config( - tmpdir, - "xcode-build-run-workflow", - {"mcpRetryCount": 2, "fallbackCommandMappingProfile": "xcode-only"}, - ) - env = dict(os.environ) - env["APPLE_DEV_SKILLS_CONFIG_HOME"] = tmpdir - code, payload = self.run_script( - "--operation-type", - "build", - "--workspace-path", - tmpdir, - "--mcp-failure-reason", - "xcode-mcp-unavailable", - env=env, - ) - self.assertEqual(code, 0) - self.assertEqual(payload["output"]["retry_count"], 2) - self.assertNotIn("swift build", payload["output"]["fallback_commands"]) - - def test_direct_pbxproj_edit_requires_explicit_opt_in(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "project.pbxproj").write_text("// !$*UTF8*$!\n", encoding="utf-8") - code, payload = self.run_script( - "--operation-type", - "mutation", - "--workspace-path", - tmpdir, - "--direct-pbxproj-edit", - ) - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - self.assertTrue(payload["output"]["guard_result"]["direct_pbxproj_edit_warning_required"]) - - def test_infers_workspace_state_and_scheme_hint_from_nested_path(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo_root = Path(tmpdir) - (repo_root / "App" / "Demo.xcodeproj").mkdir(parents=True) - (repo_root / "App" / "Demo.xctestplan").write_text("{}", encoding="utf-8") - code, payload = self.run_script( - "--operation-type", - "build", - "--workspace-path", - str(repo_root / "App" / "Sources"), - "--mcp-failure-reason", - "timeout", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["path_type"], "fallback") - self.assertEqual(payload["output"]["inferred_context"]["scheme_hint"], "Demo") - self.assertTrue(payload["output"]["inferred_context"]["has_xcode_test_plan"]) - self.assertTrue(payload["output"]["workspace_state"]["project"].endswith("Demo.xcodeproj")) - joined = "\n".join(payload["output"]["fallback_commands"]) - self.assertIn("-scheme Demo", joined) - - def test_skill_documents_xcodegen_project_maintenance(self) -> None: - skill_text = (ROOT / "skills/xcode-build-run-workflow/SKILL.md").read_text(encoding="utf-8") - reference_text = ( - ROOT / "skills/xcode-build-run-workflow/references/xcodegen-project-maintenance.md" - ).read_text(encoding="utf-8") - - self.assertIn("xcodegen-project-maintenance.md", skill_text) - self.assertIn("project.yml", reference_text) - self.assertIn("xcodegen generate", reference_text) - self.assertIn("generated `.xcodeproj` and `.pbxproj` files as output", reference_text) - - def test_skill_routes_coding_intelligence_setup_to_owner(self) -> None: - skill_text = (ROOT / "skills/xcode-build-run-workflow/SKILL.md").read_text(encoding="utf-8") - - self.assertIn("xcode-coding-intelligence-workflow", skill_text) - self.assertIn("Xcode Intelligence setup", skill_text) - self.assertIn("external-agent access through `xcrun mcpbridge`", skill_text) - - def test_mutation_policy_requires_committing_tracked_pbxproj_output(self) -> None: - reference_text = ( - ROOT / "skills/xcode-build-run-workflow/references/mutation-risk-policy.md" - ).read_text(encoding="utf-8") - - self.assertIn("treat that diff as critical project state", reference_text) - self.assertIn("commit it with the branch before any push, merge, release", reference_text) - - def test_plain_python_invocation_can_recover_missing_pyyaml_through_uv_script_metadata(self) -> None: - script_text = SCRIPT.read_text(encoding="utf-8") - - self.assertIn("APPLE_DEV_SKILLS_UV_SCRIPT_REEXEC", script_text) - self.assertIn('"uv"', script_text) - self.assertIn('"run", "--script"', script_text) - self.assertIn("inline PyYAML dependency", script_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_coding_intelligence_workflow.py b/plugins/apple-dev-skills/tests/test_xcode_coding_intelligence_workflow.py deleted file mode 100644 index 39b21e8c6..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_coding_intelligence_workflow.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class XcodeCodingIntelligenceWorkflowTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_skill_owns_setup_not_execution(self) -> None: - skill_text = self.read("skills/xcode-coding-intelligence-workflow/SKILL.md") - prompt_text = self.read("skills/xcode-coding-intelligence-workflow/agents/openai.yaml") - - self.assertIn("Xcode Intelligence setup", skill_text) - self.assertIn("xcrun mcpbridge", skill_text) - self.assertIn("Xcode plug-in import inspection", skill_text) - self.assertIn("command and tool permissions", skill_text) - self.assertIn("Recommend `xcode-build-run-workflow`", skill_text) - self.assertIn("Recommend `xcode-testing-workflow`", skill_text) - self.assertIn("$xcode-build-run-workflow", prompt_text) - self.assertIn("$xcode-testing-workflow", prompt_text) - - def test_beta_claims_are_dated_and_bounded(self) -> None: - skill_text = self.read("skills/xcode-coding-intelligence-workflow/SKILL.md") - evidence_text = self.read("skills/xcode-coding-intelligence-workflow/references/source-evidence.md") - - self.assertIn("Xcode 27-only behavior remains beta-specific", skill_text) - self.assertIn("Do not claim Xcode 27 beta behavior is stable Xcode behavior.", skill_text) - self.assertIn("Observed beta Xcode version: Xcode 27.0, build 27A5194q.", evidence_text) - self.assertIn("Earlier default-developer-dir check observed Xcode 26.5, build 17F42.", evidence_text) - self.assertIn("Local Xcode 27 Beta Plug-in Import Probe", evidence_text) - - def test_beta_path_guidance_uses_system_wide_app_locations(self) -> None: - skill_text = self.read("skills/xcode-coding-intelligence-workflow/SKILL.md") - bridge_text = self.read("skills/xcode-coding-intelligence-workflow/references/mcpbridge-and-external-agents.md") - setup_text = self.read("skills/xcode-coding-intelligence-workflow/references/setup-and-agent-surfaces.md") - evidence_text = self.read("skills/xcode-coding-intelligence-workflow/references/source-evidence.md") - - for text in (bridge_text, setup_text): - self.assertIn("do not override it with `DEVELOPER_DIR`", text) - self.assertNotIn("/Users/galew/Applications/Betas", text) - - self.assertIn("use the Xcode CLI toolchain Gale selected through `xcode-select`", skill_text) - self.assertIn("Do not set `DEVELOPER_DIR` unless", skill_text) - - self.assertIn("Current path note", evidence_text) - self.assertIn("Observed beta Xcode version: Xcode 27.0, build 27A5209h.", evidence_text) - self.assertIn("historical evidence, not current guidance", evidence_text) - self.assertIn("Historical evidence only", evidence_text) - - def test_external_agent_reference_documents_mcpbridge_preconditions(self) -> None: - bridge_text = self.read("skills/xcode-coding-intelligence-workflow/references/mcpbridge-and-external-agents.md") - - self.assertIn("codex mcp add xcode -- xcrun mcpbridge", bridge_text) - self.assertIn("MCP_XCODE_PID", bridge_text) - self.assertIn("xcrun mcpbridge run-agent --dry-run <agent-name>", bridge_text) - self.assertIn("Plug-in Import Is Not A Bridge Subcommand", bridge_text) - self.assertIn("External-agent access must be enabled", bridge_text) - self.assertIn("Workspace-independent", bridge_text) - self.assertIn("xcrun mcp-server status", bridge_text) - self.assertIn("actual agent executable", bridge_text) - self.assertIn("not recommended for at-desk use", bridge_text) - - def test_beta5_headless_service_has_bounded_permissions(self) -> None: - skill_text = self.read("skills/xcode-coding-intelligence-workflow/SKILL.md") - setup_text = self.read("skills/xcode-coding-intelligence-workflow/references/setup-and-agent-surfaces.md") - permissions_text = self.read("skills/xcode-coding-intelligence-workflow/references/permissions-and-artifacts.md") - - self.assertIn("Xcode 27 Beta 5 headless service", skill_text) - self.assertIn("27A5237l", setup_text) - self.assertIn("headlessly opened project", setup_text) - self.assertIn("24 hours", permissions_text) - self.assertIn("unsafe all-agent", permissions_text) - - def test_agent_surface_reference_keeps_acp_and_plugins_research_first(self) -> None: - setup_text = self.read("skills/xcode-coding-intelligence-workflow/references/setup-and-agent-surfaces.md") - evidence_text = self.read("skills/xcode-coding-intelligence-workflow/references/source-evidence.md") - - self.assertIn("xcode-hosted", setup_text) - self.assertIn("external-mcp", setup_text) - self.assertIn("plugin", setup_text) - self.assertIn("acp", setup_text) - self.assertIn("Do not claim Apple-documented ACP setup", setup_text) - self.assertIn("Add from URL", evidence_text) - - def test_permissions_reference_requires_reviewable_artifacts(self) -> None: - permissions_text = self.read("skills/xcode-coding-intelligence-workflow/references/permissions-and-artifacts.md") - - self.assertIn("Do not grant broader permissions to work around unclear setup.", permissions_text) - self.assertIn("Plan-First Bias", permissions_text) - self.assertIn("Reviewable Artifacts", permissions_text) - self.assertIn("Do not store provider API keys", permissions_text) - - def test_xcode27_mcp_additions_stay_capability_based(self) -> None: - skill_text = self.read("skills/xcode-coding-intelligence-workflow/SKILL.md") - evidence_text = self.read("skills/xcode-coding-intelligence-workflow/references/source-evidence.md") - - self.assertIn("discover the live Xcode MCP tool inventory", skill_text) - self.assertIn("Documented Xcode 27 MCP Additions", evidence_text) - self.assertIn("do not document a dedicated MCP code-coverage reporting tool", evidence_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_device_window_telemetry_debugger_workflows.py b/plugins/apple-dev-skills/tests/test_xcode_device_window_telemetry_debugger_workflows.py deleted file mode 100644 index 083e6d957..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_device_window_telemetry_debugger_workflows.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class XcodeDeviceWindowTelemetryDebuggerWorkflowTests(unittest.TestCase): - def read(self, path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - def test_device_hub_preserves_native_device_and_agentdeck_boundaries(self) -> None: - skill = self.read("skills/xcode-device-hub-workflow/SKILL.md") - evidence = self.read("skills/xcode-device-hub-workflow/references/device-hub-scope-and-evidence.md") - prompt = self.read("skills/xcode-device-hub-workflow/agents/openai.yaml") - - for term in ("simulated and physical", "Liquid Glass", "Text Size", "screenshots", "diagnostics"): - self.assertIn(term, skill + evidence) - self.assertIn("future AgentDeck runtime", skill) - self.assertIn("Do not erase, remove, unpair, or reset", skill) - self.assertIn("xcrun devicectl", skill) - self.assertIn("$xcode-device-hub-workflow", prompt) - - def test_window_workflow_keeps_native_chrome_and_restoration_contracts(self) -> None: - skill = self.read("skills/macos-window-management-workflow/SKILL.md") - reference = self.read("skills/macos-window-management-workflow/references/window-scene-and-chrome-rules.md") - - for term in ("WindowGroup", "WindowDragGesture", "allowsWindowActivationEvents", "restoration", "Window-menu"): - self.assertIn(term, skill + reference) - self.assertIn("Do not use borderless or hidden-title-bar styling", skill) - self.assertIn("appkit-app-architecture-workflow", skill) - - def test_runtime_telemetry_is_private_and_evidence_oriented(self) -> None: - skill = self.read("skills/apple-runtime-telemetry-workflow/SKILL.md") - logger_reference = self.read("skills/apple-runtime-telemetry-workflow/references/logger-privacy-and-evidence.md") - signpost_reference = self.read("skills/apple-runtime-telemetry-workflow/references/signposts-and-runtime-capture.md") - - for term in ("Logger", "OSSignposter", "private by default", "com.apple.logging.local-store", "Instruments"): - self.assertIn(term, skill + logger_reference + signpost_reference) - self.assertIn("Do not create a logging manager", skill) - self.assertIn("ios-runtime-forensics-workflow", skill) - - def test_debugger_workflow_records_beta3_loader_boundary(self) -> None: - skill = self.read("skills/xcode-debugger-mcp-workflow/SKILL.md") - evidence = self.read("skills/xcode-debugger-mcp-workflow/references/beta3-capability-evidence.md") - contract = self.read("skills/xcode-debugger-mcp-workflow/references/active-session-debugging-contract.md") - - for term in ("Xcode 27.0 Beta 3", "27A5218g", "lib_CompilerSwiftIDEUtils.dylib", "InvokeDebuggerCommand", "active debugging session", "mcpbridge", "lldb_command", "does not document a way to select"): - self.assertIn(term, skill + evidence + contract) - self.assertIn("Do not work around this", skill) - self.assertIn("xcode-build-run-workflow", skill) - self.assertIn("Physical-device debugging", skill) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_localization_workflow.py b/plugins/apple-dev-skills/tests/test_xcode_localization_workflow.py deleted file mode 100644 index 800eb6ab9..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_localization_workflow.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class XcodeLocalizationWorkflowTests(unittest.TestCase): - def read(self, path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") - - def test_catalog_first_workflow_covers_source_translation_and_validation(self) -> None: - skill = self.read("skills/xcode-localization-workflow/SKILL.md") - foundations = self.read("skills/xcode-localization-workflow/references/string-catalog-foundations.md") - source = self.read("skills/xcode-localization-workflow/references/source-apis-and-translator-context.md") - validation = self.read("skills/xcode-localization-workflow/references/translation-review-and-validation.md") - - for term in ( - "String Catalogs (`.xcstrings`)", - "String(localized:table:bundle:locale:comment:)", - "LocalizedStringResource", - "Vary by Plural", - "XLIFF", - "right-to-left", - "human-review", - ): - self.assertIn(term, skill + foundations + source + validation) - - self.assertIn("xcode-build-run-workflow", skill) - self.assertIn("xcode-testing-workflow", skill) - self.assertIn("xcode-device-hub-workflow", skill) - self.assertIn("apple-ui-accessibility-workflow", skill) - self.assertIn("xcode-coding-intelligence-workflow", skill) - - def test_agent_translation_is_optional_and_beta_scoped(self) -> None: - skill = self.read("skills/xcode-localization-workflow/SKILL.md") - agent = self.read("skills/xcode-localization-workflow/references/agent-assisted-translation.md") - - self.assertIn("optional beta-era acceleration", skill) - self.assertIn("Do not present agent output as human-reviewed translation.", skill) - self.assertIn("optional beta-era workflow", agent) - self.assertIn("machine-translation provenance", agent) - - def test_inventory_and_metadata_name_the_shipped_skill(self) -> None: - readme = self.read("README.md") - manifest = self.read(".codex-plugin/plugin.json") - validator = self.read(".github/scripts/validate_repo_docs.sh") - - for text in (readme, validator): - self.assertIn("xcode-localization-workflow", text) - self.assertIn("String Catalog localization", manifest) - self.assertIn('"string-catalog"', manifest) - self.assertIn("Expected exactly 58 active skills", validator) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_testing_workflow.py b/plugins/apple-dev-skills/tests/test_xcode_testing_workflow.py deleted file mode 100644 index c0f3aabff..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_testing_workflow.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "skills/xcode-testing-workflow/scripts/run_workflow.py" - - -class XcodeTestingWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_handoffs_build_requests_to_xcode_build_run_workflow(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Demo.xcodeproj").mkdir() - code, payload = self.run_script("--request", "build the release artifact", "--workspace-path", tmpdir) - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "handoff") - self.assertIn("xcode-build-run-workflow", payload["output"]["next_step"]) - - def test_test_fallback_prefers_workspace_commands_and_test_plans(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "Demo.xcworkspace").mkdir() - code, payload = self.run_script( - "--operation-type", - "test", - "--workspace-path", - tmpdir, - "--mcp-failure-reason", - "timeout", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["path_type"], "fallback") - joined = "\n".join(payload["output"]["fallback_commands"]) - self.assertIn("xcodebuild test -workspace", joined) - self.assertIn("-showTestPlans", joined) - - def test_mutation_still_warns_for_direct_pbxproj_edit(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "project.pbxproj").write_text("// !$*UTF8*$!\n", encoding="utf-8") - code, payload = self.run_script( - "--operation-type", - "mutation", - "--workspace-path", - tmpdir, - "--direct-pbxproj-edit", - ) - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - self.assertTrue(payload["output"]["guard_result"]["direct_pbxproj_edit_warning_required"]) - - def test_mutation_policy_requires_committing_tracked_pbxproj_output(self) -> None: - reference_text = ( - ROOT / "skills/xcode-testing-workflow/references/mutation-risk-policy.md" - ).read_text(encoding="utf-8") - - self.assertIn("treat that diff as critical project state", reference_text) - self.assertIn("commit it with the branch before any push, merge, release", reference_text) - - def test_infers_test_plan_and_ui_test_context_from_workspace(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - repo_root = Path(tmpdir) - (repo_root / "Demo.xcworkspace").mkdir() - (repo_root / "Demo.xctestplan").write_text("{}", encoding="utf-8") - (repo_root / "Tests" / "DemoUITests").mkdir(parents=True) - code, payload = self.run_script( - "--operation-type", - "test", - "--workspace-path", - tmpdir, - "--mcp-failure-reason", - "timeout", - ) - self.assertEqual(code, 0) - self.assertEqual(payload["path_type"], "fallback") - self.assertEqual(payload["output"]["inferred_context"]["scheme_hint"], "Demo") - self.assertTrue(payload["output"]["inferred_context"]["has_xcode_test_plan"]) - self.assertIn("DemoUITests", payload["output"]["inferred_context"]["ui_test_targets"]) - joined = "\n".join(payload["output"]["fallback_commands"]) - self.assertIn("-testPlan Demo", joined) - self.assertIn("-scheme Demo", joined) - - def test_skill_documents_accessibility_and_ui_automation_references(self) -> None: - skill_text = (ROOT / "skills/xcode-testing-workflow/SKILL.md").read_text(encoding="utf-8") - plan_text = ( - ROOT / "skills/xcode-testing-workflow/references/xctestplan-configurations-and-matrix.md" - ).read_text(encoding="utf-8") - ui_text = ( - ROOT / "skills/xcode-testing-workflow/references/xcuitest-and-xcuiautomation.md" - ).read_text(encoding="utf-8") - accessibility_text = ( - ROOT / "skills/xcode-testing-workflow/references/ui-accessibility-verification.md" - ).read_text(encoding="utf-8") - permission_text = ( - ROOT / "skills/xcode-testing-workflow/references/macos-ui-test-permission-ownership.md" - ).read_text(encoding="utf-8") - - self.assertIn("xctestplan-configurations-and-matrix.md", skill_text) - self.assertIn("xcuitest-and-xcuiautomation.md", skill_text) - self.assertIn("ios-ui-automation-destinations.md", skill_text) - self.assertIn("ui-accessibility-verification.md", skill_text) - self.assertIn("macos-ui-test-permission-ownership.md", skill_text) - self.assertIn("-only-test-configuration", plan_text) - self.assertIn("waitForExistence(timeout:)", ui_text) - self.assertIn("apple-ui-accessibility-workflow", accessibility_text) - for term in ( - "responsible executable", - "do not use `open`, `nsworkspace`, or a wrapper script to restore a separately installed app", - "opt-in prompt-heavy", - "macos-privacy-permissions-workflow", - ): - self.assertIn(term, permission_text.lower()) - - def test_skill_documents_ios_simulator_and_physical_device_boundaries(self) -> None: - destination_text = ( - ROOT / "skills/xcode-testing-workflow/references/ios-ui-automation-destinations.md" - ).read_text(encoding="utf-8") - - for term in ( - "Simulator-first coverage", - "Physical-device coverage", - "`.xcresult`", - "xcode-device-hub-workflow", - "xcode-debugger-mcp-workflow", - "hardware performance", - ): - self.assertIn(term, destination_text) - - def test_skill_documents_xcodegen_test_project_maintenance(self) -> None: - skill_text = (ROOT / "skills/xcode-testing-workflow/SKILL.md").read_text(encoding="utf-8") - reference_text = ( - ROOT / "skills/xcode-testing-workflow/references/xcodegen-project-maintenance.md" - ).read_text(encoding="utf-8") - - self.assertIn("xcodegen-project-maintenance.md", skill_text) - self.assertIn("scheme test actions", reference_text) - self.assertIn(".xctestplan", reference_text) - self.assertIn("xcodegen generate", reference_text) - - def test_skill_routes_coding_intelligence_setup_to_owner(self) -> None: - skill_text = (ROOT / "skills/xcode-testing-workflow/SKILL.md").read_text(encoding="utf-8") - - self.assertIn("xcode-coding-intelligence-workflow", skill_text) - self.assertIn("Xcode Intelligence setup", skill_text) - self.assertIn("external-agent access through `xcrun mcpbridge`", skill_text) - - def test_skill_documents_heavy_model_test_scheduling(self) -> None: - reference_text = ( - ROOT / "skills/xcode-testing-workflow/references/testing-plans-file-membership-and-configurations.md" - ).read_text(encoding="utf-8") - self.assertIn("normal Xcode and XCTest parallel execution", reference_text) - self.assertIn("over 500 million parameters", reference_text) - self.assertIn("sequentially, one at a time", reference_text) - self.assertIn("unload_models", reference_text) - self.assertIn("reload_models", reference_text) - - def test_skill_documents_xcode_coverage_and_mcp_boundary(self) -> None: - skill_text = (ROOT / "skills/xcode-testing-workflow/SKILL.md").read_text(encoding="utf-8") - coverage_text = (ROOT / "skills/xcode-testing-workflow/references/code-coverage.md").read_text(encoding="utf-8") - mcp_text = (ROOT / "skills/xcode-testing-workflow/references/mcp-tool-matrix.md").read_text(encoding="utf-8") - - self.assertIn("references/code-coverage.md", skill_text) - for term in ("-enableCodeCoverage YES", "-resultBundlePath", "xccov view --report --json", "does not document a coverage-report MCP tool"): - self.assertIn(term, coverage_text) - self.assertIn("Xcode 27 Beta Additions", mcp_text) - self.assertIn("does not document a code-coverage MCP tool", mcp_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_toolchain_selection_guidance.py b/plugins/apple-dev-skills/tests/test_xcode_toolchain_selection_guidance.py deleted file mode 100644 index 1b993a6d9..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_toolchain_selection_guidance.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -class XcodeToolchainSelectionGuidanceTests(unittest.TestCase): - def read(self, relative_path: str) -> str: - return (ROOT / relative_path).read_text(encoding="utf-8") - - def test_xcode_workflows_require_xcode_select_and_permission_for_exceptions(self) -> None: - references = [ - "skills/xcode-build-run-workflow/references/toolchain-management.md", - "skills/xcode-testing-workflow/references/toolchain-management.md", - ] - - for reference in references: - with self.subTest(reference=reference): - text = self.read(reference) - - self.assertIn("currently selected by `xcode-select`", text) - self.assertIn("Do not override it per command", text) - self.assertIn("Never set `DEVELOPER_DIR` by default", text) - self.assertIn("obtain Gale's explicit permission", text) - self.assertNotIn("DEVELOPER_DIR=", text) - self.assertIn("Xcode > Settings > Locations", text) - self.assertIn("Command Line Tools", text) - self.assertIn("Touch ID or administrator prompt", text) - self.assertNotIn("sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer", text) - self.assertNotIn("sudo xcode-select --switch /Applications/Betas/Xcode-beta.app/Contents/Developer", text) - self.assertIn("verify with `xcode-select -p`", text) - self.assertIn("restore a previous path only when the user asked for a temporary switch", text) - self.assertIn("Do not use `xcode-select --install` as an Xcode app switch", text) - - def test_icon_composer_checks_system_wide_beta_paths(self) -> None: - text = self.read("skills/icon-composer-app-icon-workflow/SKILL.md") - - self.assertIn("/Applications/Xcode-beta.app/Contents/Applications/Icon Composer.app", text) - self.assertIn("/Applications/Betas/Xcode-beta.app/Contents/Applications/Icon Composer.app", text) - self.assertIn("/Applications/Xcode-beta.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool", text) - self.assertIn( - "/Applications/Betas/Xcode-beta.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool", - text, - ) - self.assertNotIn("/Users/galew/Applications/Betas", text) - - def test_icon_composer_documents_stable_1_6_and_beta_2_0_boundaries(self) -> None: - text = self.read("skills/icon-composer-app-icon-workflow/SKILL.md") - - for term in [ - "Icon Composer 1.6", - "bundle-version` `99.1", - "Icon Composer 2.0", - "Do not mix stable and beta behavior", - "--light-angle", - "--tint-color", - "--tint-strength", - "--design-generation", - ]: - with self.subTest(term=term): - self.assertIn(term, text) - - -if __name__ == "__main__": - unittest.main() diff --git a/plugins/apple-dev-skills/tests/test_xcode_workspace_workflows.py b/plugins/apple-dev-skills/tests/test_xcode_workspace_workflows.py deleted file mode 100644 index 3dada6ef5..000000000 --- a/plugins/apple-dev-skills/tests/test_xcode_workspace_workflows.py +++ /dev/null @@ -1,555 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -BOOTSTRAP = ROOT / "skills/bootstrap-xcode-workspace/scripts/run_workflow.py" - - -def run_script(script: Path, *args: str) -> tuple[int, dict]: - env = dict(os.environ) - env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "apple-dev-skills-uv-cache")) - with tempfile.TemporaryDirectory(prefix="fake-xcodegen-") as tool_dir: - fake_xcodegen = Path(tool_dir) / "xcodegen" - fake_xcodegen.write_text( - """#!/usr/bin/env python3 -from pathlib import Path -import re -import sys - -arguments = sys.argv[1:] -root = Path.cwd() -spec = root / "project.yml" -if "--spec" in arguments: - spec = Path(arguments[arguments.index("--spec") + 1]) - if not spec.is_absolute(): - spec = root / spec -name_match = re.search(r"^name:\\s*([^\\s]+)", spec.read_text(encoding="utf-8"), re.MULTILINE) -if name_match is None: - raise SystemExit("fake xcodegen could not read the project name") -destination = root -if "--project" in arguments: - destination = Path(arguments[arguments.index("--project") + 1]) - if not destination.is_absolute(): - destination = root / destination -project = destination / f"{name_match.group(1)}.xcodeproj" -project.mkdir(parents=True, exist_ok=True) -project.joinpath("project.pbxproj").write_text("// generated test project\\n", encoding="utf-8") -""", - encoding="utf-8", - ) - fake_xcodegen.chmod(0o755) - env["PATH"] = f"{tool_dir}{os.pathsep}{env['PATH']}" - process = subprocess.run( - ["uv", "run", str(script), *args], - capture_output=True, - check=False, - env=env, - text=True, - ) - return process.returncode, json.loads(process.stdout) - - -class XcodeWorkspaceWorkflowTests(unittest.TestCase): - def test_bootstrap_defaults_to_one_root_project_and_workspace(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--destination", tmpdir, "--dry-run") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["normalized_inputs"]["platforms"], ["ios", "macos"]) - self.assertIn("root XcodeGen project", " ".join(payload["actions"])) - self.assertTrue(payload["workspace_path"].endswith("Product.xcworkspace")) - self.assertTrue(payload["project_path"].endswith("Product.xcodeproj")) - - def test_bootstrap_supports_package_first_without_a_second_entrypoint(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script( - BOOTSTRAP, - "--name", "Product", - "--destination", tmpdir, - "--component-kind", "library", - "--component-name", "ProductAnalytics", - "--dry-run", - ) - self.assertEqual(code, 0, payload) - self.assertEqual(payload["normalized_inputs"]["platforms"], []) - self.assertIn("Packages/ProductAnalytics", " ".join(payload["actions"])) - - def test_bootstrap_creates_one_project_target_specs_shared_layers_and_package(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--file-prefix", "PRD", "--destination", tmpdir, "--skip-validation") - self.assertEqual(code, 0, payload) - root = Path(payload["workspace_root"]) - self.assertTrue((root / "Product.xcworkspace/contents.xcworkspacedata").is_file()) - self.assertTrue((root / "Product.xcodeproj/project.pbxproj").is_file()) - self.assertTrue((root / "project.yml").is_file()) - self.assertTrue((root / "Apps/apps-shared.yml").is_file()) - self.assertTrue((root / "Apps/Apps-shared.xcconfig").is_file()) - self.assertTrue((root / "Apps/AGENTS.md").is_file()) - self.assertTrue((root / "Packages/packages-shared.yml").is_file()) - self.assertTrue((root / "Packages/AGENTS.md").is_file()) - self.assertTrue((root / "Packages/ProductCore/Package.swift").is_file()) - self.assertTrue((root / "Services/services-shared.yml").is_file()) - self.assertTrue((root / "Services/AGENTS.md").is_file()) - self.assertTrue((root / "AGENTS.md").is_file()) - self.assertTrue((root / "CONTRIBUTING.md").is_file()) - self.assertTrue((root / "Justfile").is_file()) - self.assertTrue((root / ".githooks/pre-commit").is_file()) - root_spec = (root / "project.yml").read_text(encoding="utf-8") - self.assertIn("Apps/apps-shared.yml", root_spec) - self.assertIn("Packages/packages-shared.yml", root_spec) - self.assertIn("Services/services-shared.yml", root_spec) - self.assertIn("projectFormat: xcode16_3", root_spec) - self.assertIn("AppStore: release", root_spec) - self.assertIn('iOS: "26.1"', root_spec) - for target in ("ProductiOS", "ProductmacOS"): - self.assertTrue((root / f"Apps/{target}/target.yml").is_file()) - self.assertTrue((root / f"Apps/{target}/Configurations/App.xcconfig").is_file()) - self.assertTrue((root / f"Apps/{target}/Configurations/Version.xcconfig").is_file()) - self.assertTrue((root / f"Apps/{target}/Resources/Info.plist").is_file()) - self.assertTrue((root / f"Apps/{target}/Resources/{target}.entitlements").is_file()) - self.assertTrue((root / f"Apps/{target}Tests/Sources/{target}Tests.swift").is_file()) - self.assertTrue((root / f"Apps/{target}UITests/Sources/{target}UITests.swift").is_file()) - self.assertFalse((root / f"Apps/{target}Tests/Configurations").exists()) - target_spec = (root / f"Apps/{target}/target.yml").read_text(encoding="utf-8") - self.assertIn(f"{target} All Tests", target_spec) - self.assertIn("SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor", target_spec) - manifest = (root / "Packages/ProductCore/Package.swift").read_text(encoding="utf-8") - self.assertIn("swift-tools-version: 6.2", manifest) - self.assertIn("ProductDomain", manifest) - self.assertIn("ProductServices", manifest) - self.assertTrue((root / "scripts/repo-maintenance/validate-all.sh").is_file()) - - def test_bootstrap_discovers_repository_runner_from_versioned_plugin_cache(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - cache_root = Path(tmpdir) / "cache" / "socket" - apple_root = cache_root / "apple-dev-skills" / "9.34.0" - repository_root = cache_root / "repository-skills" / "9.34.0" - shutil.copytree( - ROOT / "skills/bootstrap-xcode-workspace", - apple_root / "skills/bootstrap-xcode-workspace", - ) - shutil.copytree( - ROOT.parent / "repository-skills", - repository_root, - ignore=shutil.ignore_patterns(".venv", ".pytest_cache", ".ruff_cache", "__pycache__"), - ) - cache_script = apple_root / "skills/bootstrap-xcode-workspace/scripts/run_workflow.py" - destination = Path(tmpdir) / "products" - code, payload = run_script( - cache_script, - "--name", - "CachedProduct", - "--file-prefix", - "CCH", - "--destination", - str(destination), - "--skip-validation", - ) - self.assertEqual(code, 0, payload) - self.assertTrue((destination / "CachedProduct/scripts/repo-maintenance/validate-all.sh").is_file()) - - def test_bootstrap_rejects_existing_file_root(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - (Path(tmpdir) / "Product").write_text("occupied", encoding="utf-8") - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--destination", tmpdir) - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - self.assertIn("already contains files", payload["stderr"]) - - def test_bootstrap_rejects_noncanonical_platform_or_prefix(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--destination", tmpdir, "--platforms", "watchos", "--dry-run") - self.assertEqual(code, 0) - self.assertEqual(payload["normalized_inputs"]["platforms"], ["watchos"]) - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--destination", tmpdir, "--file-prefix", "no") - self.assertEqual(code, 1) - self.assertIn("three uppercase", payload["stderr"]) - - def test_existing_workspace_alignment_preserves_local_content_and_adds_just_contract(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Product.xcworkspace").mkdir() - (root / "Product.xcodeproj").mkdir() - (root / "project.yml").write_text("name: Product\ninclude:\noptions:\n createIntermediateGroups: true\n", encoding="utf-8") - (root / "Apps/ProductiOS").mkdir(parents=True) - (root / "Apps/apps-shared.yml").write_text("targetTemplates: {}\n", encoding="utf-8") - (root / "Apps/Apps-shared.xcconfig").write_text("SWIFT_VERSION = 6.0\n", encoding="utf-8") - (root / "Apps/ProductiOS/target.yml").write_text("targets: {}\n", encoding="utf-8") - (root / "Packages/ProductCore").mkdir(parents=True) - (root / "Packages/packages-shared.yml").write_text("packages: {}\n", encoding="utf-8") - (root / "Packages/ProductCore/Package.swift").write_text("// swift-tools-version: 6.0\n", encoding="utf-8") - (root / "AGENTS.md").write_text("# Local\n\nKeep this.\n", encoding="utf-8") - (root / "Justfile").write_text("local:\n echo local\n", encoding="utf-8") - code, payload = run_script(BOOTSTRAP, "--operation", "align", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - self.assertIn("Justfile", " ".join(payload["actions"])) - self.assertIn(".socket/managed/align.sh", " ".join(payload["actions"])) - self.assertIn("Keep this.", (root / "AGENTS.md").read_text(encoding="utf-8")) - self.assertIn("socket-managed:begin", (root / "AGENTS.md").read_text(encoding="utf-8")) - self.assertIn("local:", (root / "Justfile").read_text(encoding="utf-8")) - self.assertIn("socket-managed:begin just-recipes", (root / "Justfile").read_text(encoding="utf-8")) - self.assertTrue((root / ".socket/managed/align.sh").is_file()) - self.assertTrue((root / ".githooks/pre-commit").is_file()) - self.assertTrue((root / "Services/services-shared.yml").is_file()) - self.assertIn("Services/services-shared.yml", (root / "project.yml").read_text(encoding="utf-8")) - aligned = { - path: (root / path).read_text(encoding="utf-8") - for path in ("project.yml", "AGENTS.md", "Justfile", "Services/services-shared.yml") - } - code, payload = run_script(BOOTSTRAP, "--operation", "align", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - self.assertEqual( - aligned, - { - path: (root / path).read_text(encoding="utf-8") - for path in aligned - }, - ) - - def test_docs_record_single_root_project_contract(self) -> None: - bootstrap = (ROOT / "skills/bootstrap-xcode-workspace/SKILL.md").read_text(encoding="utf-8") - self.assertIn("one root XcodeGen project", bootstrap) - self.assertIn("Apps-shared.xcconfig", bootstrap) - self.assertIn("packages-shared.yml", bootstrap) - self.assertIn("--operation align", bootstrap) - self.assertIn("--operation add-component", bootstrap) - self.assertIn("Services/services-shared.yml", bootstrap) - - def test_add_component_does_not_require_repo_conversion(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--file-prefix", "PRD", "--destination", tmpdir, "--skip-validation") - self.assertEqual(code, 0, payload) - root = Path(payload["workspace_root"]) - code, payload = run_script( - BOOTSTRAP, - "--operation", "add-component", - "--repo-root", str(root), - "--component-kind", "library", - "--component-name", "ProductAnalytics", - ) - self.assertEqual(code, 0, payload) - self.assertTrue((root / "Packages/ProductAnalytics/Package.swift").is_file()) - self.assertIn("ProductAnalytics", (root / "Packages/packages-shared.yml").read_text(encoding="utf-8")) - - def test_add_app_component_rejects_invalid_file_prefix_before_writes(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script( - BOOTSTRAP, - "--name", "Product", - "--file-prefix", "PRD", - "--destination", tmpdir, - "--skip-validation", - ) - self.assertEqual(code, 0, payload) - root = Path(payload["workspace_root"]) - - code, payload = run_script( - BOOTSTRAP, - "--operation", "add-component", - "--repo-root", str(root), - "--component-kind", "app", - "--component-name", "ProductAdmin", - "--platform", "macos", - "--file-prefix", "../../Outside", - ) - - self.assertEqual(code, 1) - self.assertIn("three uppercase", payload["stderr"]) - self.assertFalse((root / "Apps/ProductAdmin").exists()) - self.assertFalse((Path(tmpdir) / "OutsideApp.swift").exists()) - - def test_extension_is_an_apps_peer_with_explicit_host_embedding(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--file-prefix", "PRD", "--destination", tmpdir, "--skip-validation") - self.assertEqual(code, 0, payload) - root = Path(payload["workspace_root"]) - code, payload = run_script( - BOOTSTRAP, - "--operation", "add-component", - "--repo-root", str(root), - "--component-kind", "extension", - "--component-name", "ProductShareExtension", - "--platform", "ios", - "--host-target", "ProductiOS", - "--extension-product-type", "app-extension", - "--extension-point-identifier", "com.apple.share-services", - ) - self.assertEqual(code, 0, payload) - self.assertTrue((root / "Apps/ProductShareExtension/target.yml").is_file()) - self.assertFalse((root / "Extensions").exists()) - host_spec = (root / "Apps/ProductiOS/target.yml").read_text(encoding="utf-8") - self.assertIn("- target: ProductShareExtension", host_spec) - self.assertIn("embed: true", host_spec) - extension_spec = (root / "Apps/ProductShareExtension/target.yml").read_text(encoding="utf-8") - self.assertIn("type: app-extension", extension_spec) - self.assertIn("Apps/ProductShareExtension/Sources", extension_spec) - - def test_extension_addition_blocks_without_explicit_host_and_extension_point(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script(BOOTSTRAP, "--name", "Product", "--destination", tmpdir, "--skip-validation") - self.assertEqual(code, 0, payload) - code, payload = run_script( - BOOTSTRAP, - "--operation", "add-component", - "--repo-root", payload["workspace_root"], - "--component-kind", "extension", - "--component-name", "ProductShareExtension", - "--platform", "ios", - ) - self.assertEqual(code, 1) - self.assertIn("--host-target", payload["stderr"]) - - def test_adopt_inventories_swiftpm_library_without_xcode_prerequisites(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Sources/Library").mkdir(parents=True) - (root / "Sources/Library/Library.swift").write_text("public enum Library {}\n", encoding="utf-8") - (root / "Package.swift").write_text( - '// swift-tools-version: 6.2\nimport PackageDescription\nlet package = Package(name: "Library", products: [.library(name: "Library", targets: ["Library"])], targets: [.target(name: "Library")])\n', - encoding="utf-8", - ) - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - self.assertEqual(payload["components"][0]["kind"], "library") - self.assertEqual(payload["components"][0]["proposed_destination"], "Packages/Library") - self.assertFalse((root / "project.yml").exists(), "inventory must not mutate") - self.assertNotIn("repo_shape", json.dumps(payload)) - - def test_adopt_stages_reviewed_library_map_without_deleting_original_project_state(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Sources/Library").mkdir(parents=True) - (root / "Sources/Library/Library.swift").write_text("public enum Library {}\n", encoding="utf-8") - (root / "Package.swift").write_text( - '// swift-tools-version: 6.2\nimport PackageDescription\nlet package = Package(name: "Library", products: [.library(name: "Library", targets: ["Library"])], targets: [.target(name: "Library")])\n', - encoding="utf-8", - ) - code, inventory = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, inventory) - mapping_path = root / "reviewed-adoption.json" - mapping_path.write_text(json.dumps(inventory["adoption_map"]), encoding="utf-8") - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir, "--adoption-map", str(mapping_path), "--apply") - self.assertEqual(code, 0, payload) - self.assertTrue((root / "Packages/Library/Package.swift").is_file()) - self.assertTrue((root / "Packages/Library/Sources/Library/Library.swift").is_file()) - self.assertTrue((root / ".socket/adoption/original-inventory.json").is_file()) - self.assertTrue((root / ".socket/adoption/equivalence-report.json").is_file()) - self.assertTrue((root / ".socket/adoption-candidate/Library.xcodeproj/project.pbxproj").is_file()) - - def test_adopt_discovers_hummingbird_and_vapor_services_independently(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - for name, dependency in (("API", "Hummingbird"), ("Worker", "Vapor")): - package = root / name - (package / f"Sources/{name}").mkdir(parents=True) - (package / f"Sources/{name}/main.swift").write_text("print(\"service\")\n", encoding="utf-8") - (package / "Package.swift").write_text( - f'// swift-tools-version: 6.2\nimport PackageDescription\n// {dependency}\nlet package = Package(name: "{name}", products: [.executable(name: "{name}", targets: ["{name}"])], targets: [.executableTarget(name: "{name}")])\n', - encoding="utf-8", - ) - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - services = {item["name"]: item for item in payload["components"]} - self.assertEqual(services["API"]["proposed_destination"], "Services/API") - self.assertEqual(services["API"]["product_type"], "hummingbird") - self.assertEqual(services["Worker"]["product_type"], "vapor") - - def test_adopt_blocks_ambiguous_extension_host_before_writes(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - project = root / "Legacy.xcodeproj" - project.mkdir() - project.joinpath("project.pbxproj").write_text( - """A = { isa = PBXNativeTarget; name = FirstApp; productType = \"com.apple.product-type.application\"; }; -B = { isa = PBXNativeTarget; name = SecondApp; productType = \"com.apple.product-type.application\"; }; -C = { isa = PBXNativeTarget; name = ShareExtension; productType = \"com.apple.product-type.app-extension\"; }; -SDKROOT = iphoneos; -""", - encoding="utf-8", - ) - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 1) - self.assertIn("extension host target", " ".join(payload["unresolved"])) - self.assertFalse((root / "project.yml").exists()) - - def test_adopt_inventories_hand_managed_app_settings_resources_and_schemes(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - project = root / "Legacy.xcodeproj" - (project / "xcshareddata/xcschemes").mkdir(parents=True) - project.joinpath("project.pbxproj").write_text( - """A = { - isa = PBXNativeTarget; - name = LegacyApp; - productType = "com.apple.product-type.application"; -}; -SDKROOT = iphoneos; -PRODUCT_BUNDLE_IDENTIFIER = com.example.legacy; -CODE_SIGN_ENTITLEMENTS = Sources/Legacy.entitlements; -SWIFT_VERSION = 6.0; -""", - encoding="utf-8", - ) - (project / "xcshareddata/xcschemes/LegacyApp.xcscheme").write_text("<Scheme/>\n", encoding="utf-8") - (root / "Sources/Resources/Assets.xcassets").mkdir(parents=True) - (root / "Sources/Resources/Info.plist").write_text("<plist/>\n", encoding="utf-8") - (root / "Sources/Legacy.entitlements").write_text("<plist/>\n", encoding="utf-8") - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - app = next(item for item in payload["components"] if item["name"] == "LegacyApp") - self.assertEqual(app["proposed_destination"], "Apps/LegacyApp") - self.assertEqual(app["platform"], "ios") - self.assertIn("PRODUCT_BUNDLE_IDENTIFIER", payload["inventory"]["pbx_settings_to_promote"]) - self.assertIn("Sources/Resources/Assets.xcassets", payload["inventory"]["asset_catalogs"]) - self.assertIn("Legacy.xcodeproj/xcshareddata/xcschemes/LegacyApp.xcscheme", payload["inventory"]["schemes"]) - - def test_adopt_inventories_old_xcodegen_flat_app_without_repo_classification(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Sources").mkdir() - (root / "Sources/App.swift").write_text("import SwiftUI\n", encoding="utf-8") - (root / "project.yml").write_text( - """name: Legacy -targets: - LegacyApp: - type: application - platform: iOS - sources: - - Sources -""", - encoding="utf-8", - ) - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - self.assertEqual(payload["components"][0]["name"], "LegacyApp") - self.assertEqual(payload["components"][0]["owned_paths"], ["Sources"]) - serialized = json.dumps(payload) - self.assertNotIn("repo_shape", serialized) - self.assertNotIn("migration_path", serialized) - self.assertFalse((root / "Apps").exists()) - - def test_adopt_inventories_mixed_components_independently(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - project = root / "Product.xcodeproj" - project.mkdir() - project.joinpath("project.pbxproj").write_text( - """A = { isa = PBXNativeTarget; name = ProductApp; productType = "com.apple.product-type.application"; }; -SDKROOT = macosx; -""", - encoding="utf-8", - ) - for name, executable in (("ProductCore", False), ("ProductAPI", True)): - package = root / "LegacyComponents" / name - (package / f"Sources/{name}").mkdir(parents=True) - product = f'.executable(name: "{name}", targets: ["{name}"])' if executable else f'.library(name: "{name}", targets: ["{name}"])' - target = f'.executableTarget(name: "{name}")' if executable else f'.target(name: "{name}")' - (package / "Package.swift").write_text( - f'// swift-tools-version: 6.2\nimport PackageDescription\nlet package = Package(name: "{name}", products: [{product}], targets: [{target}])\n', - encoding="utf-8", - ) - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - destinations = {item["name"]: item["proposed_destination"] for item in payload["components"]} - self.assertEqual(destinations["ProductApp"], "Apps/ProductApp") - self.assertEqual(destinations["ProductCore"], "Packages/ProductCore") - self.assertEqual(destinations["ProductAPI"], "Services/ProductAPI") - - def test_adopt_preserves_test_target_and_test_plan_evidence(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - project = root / "Product.xcodeproj" - project.mkdir() - project.joinpath("project.pbxproj").write_text( - """A = { isa = PBXNativeTarget; name = ProductApp; productType = "com.apple.product-type.application"; }; -B = { isa = PBXNativeTarget; name = ProductAppTests; productType = "com.apple.product-type.bundle.unit-test"; }; -SDKROOT = iphoneos; -""", - encoding="utf-8", - ) - (root / "Product.xctestplan").write_text('{"testTargets": []}\n', encoding="utf-8") - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", tmpdir) - self.assertEqual(code, 0, payload) - tests = next(item for item in payload["components"] if item["kind"] == "test") - self.assertEqual(tests["proposed_destination"], "Apps/ProductAppTests") - self.assertIn("Product.xctestplan", payload["inventory"]["test_plans"]) - - def test_adopt_reports_already_canonical_workspace_without_migration(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, created = run_script(BOOTSTRAP, "--name", "Product", "--destination", tmpdir, "--skip-validation") - self.assertEqual(code, 0, created) - code, payload = run_script(BOOTSTRAP, "--operation", "adopt", "--repo-root", created["workspace_root"]) - self.assertEqual(code, 0, payload) - self.assertFalse(payload["migration_required"]) - - def test_service_component_routes_through_server_adapter(self) -> None: - adapter = ROOT.parent / "server-side-swift/skills/workspace-service-component/scripts/run_workflow.py" - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Services").mkdir() - (root / "project.yml").write_text("name: Product\n", encoding="utf-8") - (root / "Services/services-shared.yml").write_text("packages: {}\n", encoding="utf-8") - code, payload = run_script(adapter, "--repo-root", tmpdir, "--name", "ProductAPI", "--framework", "hummingbird", "--dry-run") - self.assertEqual(code, 0, payload) - self.assertIn("brew", payload["output"]["next_step"].lower() if payload["output"]["next_step"] else "") - - def test_service_package_is_visible_from_permanent_workspace(self) -> None: - if not shutil.which("xcodegen") or not shutil.which("xcodebuild"): - self.skipTest("xcodegen and xcodebuild are required") - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = run_script( - BOOTSTRAP, - "--name", "Product", - "--file-prefix", "PRD", - "--destination", tmpdir, - "--component-kind", "library", - "--skip-validation", - ) - self.assertEqual(code, 0, payload) - root = Path(payload["workspace_root"]) - service = root / "Services/ProductAPI" - (service / "Sources/ProductAPI").mkdir(parents=True) - (service / "Package.swift").write_text( - "// swift-tools-version: 6.2\n" - "import PackageDescription\n" - "let package = Package(name: \"ProductAPI\", products: [.executable(name: \"ProductAPI\", targets: [\"ProductAPI\"])], targets: [.executableTarget(name: \"ProductAPI\")])\n", - encoding="utf-8", - ) - (service / "Sources/ProductAPI/main.swift").write_text("print(\"ProductAPI\")\n", encoding="utf-8") - (root / "Services/services-shared.yml").write_text( - "packages:\n ProductAPI:\n path: Services/ProductAPI\n", - encoding="utf-8", - ) - generated = subprocess.run( - ["xcodegen", "generate"], cwd=root, capture_output=True, text=True, check=False - ) - self.assertEqual(generated.returncode, 0, generated.stderr or generated.stdout) - listed = subprocess.run( - ["xcodebuild", "-list", "-workspace", str(root / "Product.xcworkspace")], - cwd=root, - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(listed.returncode, 0, listed.stderr or listed.stdout) - self.assertIn("ProductAPI", listed.stdout) - service_manifest = (service / "Package.swift").read_text(encoding="utf-8") - code, payload = run_script( - BOOTSTRAP, - "--operation", "add-component", - "--repo-root", str(root), - "--component-kind", "app", - "--component-name", "Product", - "--platform", "ios", - "--file-prefix", "PRD", - ) - self.assertEqual(code, 0, payload) - self.assertTrue((root / "Apps/ProductiOS/target.yml").is_file()) - self.assertEqual(service_manifest, (service / "Package.swift").read_text(encoding="utf-8")) diff --git a/plugins/cybersecurity-skills/tests/test_macos_security_handoffs.py b/plugins/cybersecurity-skills/tests/test_macos_security_handoffs.py deleted file mode 100644 index 602d52c6d..000000000 --- a/plugins/cybersecurity-skills/tests/test_macos_security_handoffs.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def read(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8") - - -def test_macos_threat_workflows_route_app_and_private_control_questions() -> None: - assess = read("skills/assess-macos-threat/SKILL.md") - runtime = read("skills/inspect-macos-runtime-activity/SKILL.md") - layers = read("skills/assess-macos-threat/references/macos-security-layers.md") - for text in (assess, runtime, layers): - assert "macos-privacy-permissions-workflow" in text - assert "research-macos-security-control" in text - assert "not automatically proof of prior execution" in layers - assert "telemetry gap, not evidence" in runtime - - -def test_hardening_recovery_and_isolation_preserve_platform_controls() -> None: - harden = read("skills/harden-macos/SKILL.md") - recover = read("skills/contain-and-recover-macos/SKILL.md") - select = read("skills/select-analysis-isolation/SKILL.md") - lab = read("skills/prepare-isolated-analysis-lab/SKILL.md") - assert "developer prompt/request implementation" in harden - assert "Do not reset, disable, or weaken TCC" in recover - assert "research-macos-security-control" in select - assert "research-macos-security-control" in lab diff --git a/plugins/professional-skills/skills/dice-job-search-workflow/tests/test_dice_job_search_workflow.py b/plugins/professional-skills/skills/dice-job-search-workflow/tests/test_dice_job_search_workflow.py deleted file mode 100644 index 8d1f82fd7..000000000 --- a/plugins/professional-skills/skills/dice-job-search-workflow/tests/test_dice_job_search_workflow.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import yaml - - -SKILL_ROOT = Path(__file__).resolve().parents[1] - - -def read(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def frontmatter(text: str) -> dict[str, str]: - assert text.startswith("---\n") - _empty, raw_yaml, _body = text.split("---", 2) - return yaml.safe_load(raw_yaml) - - -def test_skill_metadata_targets_dice_job_search() -> None: - metadata = frontmatter(read(SKILL_ROOT / "SKILL.md")) - - assert metadata["name"] == "dice-job-search-workflow" - description = metadata["description"] - for required in [ - "Dice.com job search", - "MCP setup guidance", - "resume-to-listing comparison", - "search_jobs", - ]: - assert required in description - - -def test_skill_body_preserves_read_only_external_mcp_boundary() -> None: - body = read(SKILL_ROOT / "SKILL.md") - - assert "https://mcp.dice.com/mcp" in body - assert "plugins/professional-skills/.mcp.json" in body - assert "read-only job-search data source" in body - assert "Do not apply to jobs" in body - assert "Do not build a local MCP server" in body - assert "Respect rate limits" in body - assert "Keep personal job-search preferences" in body - - -def test_skill_lists_documented_search_parameters() -> None: - body = read(SKILL_ROOT / "SKILL.md") - - for parameter in [ - "`keyword`", - "`location`", - "`radius`", - "`radius_unit`", - "`workplace_types`", - "`employment_types`", - "`employer_types`", - "`posted_date`", - "`willing_to_sponsor`", - "`easy_apply`", - "`jobs_per_page`", - "`page_number`", - "`fields`", - ]: - assert parameter in body - - -def test_openai_interface_metadata_matches_skill() -> None: - metadata = yaml.safe_load(read(SKILL_ROOT / "agents" / "openai.yaml")) - interface = metadata["interface"] - - assert interface["display_name"] == "Dice Job Search Workflow" - assert "remote MCP server" in interface["short_description"] - assert "$dice-job-search-workflow" in interface["default_prompt"] - assert "search_jobs" in interface["default_prompt"] - assert "ask before applying" in interface["default_prompt"] - - -def test_reference_records_official_dice_surfaces() -> None: - reference = read(SKILL_ROOT / "references" / "dice-mcp-source-notes.md") - - for official_link in [ - "https://www.dice.com/about/mcp", - "https://www.dice.com/career-advice/how-to-connect-the-dice-mcp-server-to-your-ai-assistant", - "https://www.dice.com/career-advice/dice-launches-mcp-server-for-ai-powered-job-search", - ]: - assert official_link in reference - - assert "The documented MCP tool is `search_jobs`" in reference - assert '"mcpServers": "./.mcp.json"' in reference - assert '"url": "https://mcp.dice.com/mcp"' in reference - assert "should bundle only the remote MCP config" in reference diff --git a/plugins/python-skills/tests/test_build_python_agent_service_skill.py b/plugins/python-skills/tests/test_build_python_agent_service_skill.py deleted file mode 100644 index 2b8e0bbd6..000000000 --- a/plugins/python-skills/tests/test_build_python_agent_service_skill.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import yaml - - -SKILL_ROOT = Path(__file__).resolve().parents[1] / "skills" / "build-python-agent-service" - - -def test_agent_service_skill_has_local_first_framework_and_safety_contract() -> None: - skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") - - for required in [ - "OpenAI Agents SDK", - "LangGraph", - "LlamaIndex", - "Pydantic AI", - "Google ADK Python", - "AutoGen", - "CrewAI", - "capability gate", - "read-only", - "auto-with-escalation", - "attempted versus executed side effects", - ]: - assert required in skill - - -def test_agent_service_interface_mentions_exact_model_and_approval() -> None: - metadata = yaml.safe_load((SKILL_ROOT / "agents" / "openai.yaml").read_text(encoding="utf-8")) - interface = metadata["interface"] - - assert interface["display_name"] == "Build Python Agent Service" - assert "exact model" in interface["default_prompt"] - assert "approval gate" in interface["default_prompt"] - - -def test_agent_service_uses_uv_without_unrestricted_python_tool_access() -> None: - skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") - - assert "Bash(python:*)" not in skill - assert "Bash(uv:*)" in skill diff --git a/plugins/python-skills/tests/test_plugin_smoke.py b/plugins/python-skills/tests/test_plugin_smoke.py deleted file mode 100644 index e5084b7bd..000000000 --- a/plugins/python-skills/tests/test_plugin_smoke.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] - - -def run_command(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: - completed = subprocess.run( - args, - cwd=cwd or REPO_ROOT, - text=True, - capture_output=True, - timeout=300, - ) - if completed.returncode != 0: - raise AssertionError( - "Command failed.\n" - f"args={args!r}\n" - f"cwd={str(cwd or REPO_ROOT)!r}\n" - f"stdout=\n{completed.stdout}\n" - f"stderr=\n{completed.stderr}" - ) - return completed - - -def test_plugin_manifest_and_marketplace_contract() -> None: - manifest = json.loads((REPO_ROOT / ".codex-plugin" / "plugin.json").read_text()) - agents_text = (REPO_ROOT / "AGENTS.md").read_text() - - assert manifest["name"] == "python-skills" - assert manifest["skills"] == "./skills/" - assert manifest["homepage"] == "https://github.com/gaelic-ghost/socket/tree/main/plugins/python-skills" - assert manifest["repository"] == "https://github.com/gaelic-ghost/socket" - assert manifest["interface"]["displayName"] == "Python Skills" - assert manifest["interface"]["category"] == "Developer Tools" - assert manifest["interface"]["websiteURL"] == "https://github.com/gaelic-ghost/socket/tree/main/plugins/python-skills" - - assert ".codex-plugin/plugin.json" in agents_text - assert "Root [`skills/`](./skills/) is the authored workflow surface" in agents_text - - -def test_fastmcp_docs_tool_is_host_provided_not_packaged_dependency() -> None: - for skill_name in ("bootstrap-python-mcp-service", "integrate-fastapi-fastmcp"): - skill_root = REPO_ROOT / "skills" / skill_name - metadata = (skill_root / "agents" / "openai.yaml").read_text() - skill = (skill_root / "SKILL.md").read_text() - - assert "fastmcp_docs" not in metadata - assert "does not package that server" in skill - - -def test_service_and_testing_inventory_is_complete() -> None: - expected = { - "fastapi-service-workflow", - "fastmcp-service-workflow", - "python-testing-workflow", - } - actual = {path.parent.name for path in (REPO_ROOT / "skills").glob("*/SKILL.md")} - - assert expected <= actual - - for skill_name in expected: - assert (REPO_ROOT / "skills" / skill_name / "agents" / "openai.yaml").is_file() - - -def test_bootstrap_skills_share_one_contract_reference() -> None: - contract = REPO_ROOT / "shared" / "bootstrap-contract.md" - assert contract.is_file() - - for skill_name in ( - "bootstrap-uv-python-workspace", - "bootstrap-python-service", - "bootstrap-python-mcp-service", - ): - skill = (REPO_ROOT / "skills" / skill_name / "SKILL.md").read_text() - assert "../../shared/bootstrap-contract.md" in skill - - -def test_python_testing_scripts_use_the_shipped_profile_name() -> None: - scripts_root = REPO_ROOT / "skills" / "python-testing-workflow" / "scripts" - for script_name in ("bootstrap_pytest_uv.sh", "run_pytest_uv.sh"): - script = (scripts_root / script_name).read_text() - assert 'SKILL_NAME="python-testing-workflow"' in script - - -def test_fastapi_scaffold_smoke(tmp_path: Path) -> None: - target = tmp_path / "demo-api" - run_command( - "zsh", - "skills/bootstrap-python-service/scripts/init_python_service.sh", - "--name", - "demo-api", - "--path", - str(target), - "--no-git-init", - "--bypassing-all-profiles", - ) - - assert (target / ".env").is_file() - assert (target / ".env.local").is_file() - assert "pydantic-settings" in (target / "pyproject.toml").read_text() - - -def test_fastmcp_scaffold_smoke(tmp_path: Path) -> None: - target = tmp_path / "demo-mcp" - run_command( - "zsh", - "skills/bootstrap-python-mcp-service/scripts/init_fastmcp_service.sh", - "--name", - "demo-mcp", - "--path", - str(target), - "--no-git-init", - "--bypassing-all-profiles", - ) - - assert (target / ".env").is_file() - assert (target / ".env.local").is_file() - assert "pydantic-settings" in (target / "pyproject.toml").read_text() - - -def test_workspace_scaffold_smoke(tmp_path: Path) -> None: - target = tmp_path / "demo-workspace" - run_command( - "zsh", - "skills/bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh", - "--name", - "demo-workspace", - "--path", - str(target), - "--members", - "core-lib,api-service", - "--profile-map", - "core-lib=package,api-service=service", - "--no-git-init", - "--bypassing-all-profiles", - ) - - service_root = target / "packages" / "api-service" - assert (service_root / ".env").is_file() - assert (service_root / ".env.local").is_file() - assert "pydantic-settings" in (service_root / "pyproject.toml").read_text() diff --git a/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx b/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx index 901a8a1c9..57e9207aa 100644 --- a/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx +++ b/plugins/repository-skills/shared/project-docs/DocsCoordinator.fsx @@ -67,6 +67,10 @@ let private renderMarkdown report = lines.Add("") for document in report.Documents do lines.Add($"- `{document.Document}`: {document.Findings.Length} finding(s), {document.Fixes.Length} fix(es), changed `{document.Changed.ToString().ToLowerInvariant()}`") + for finding in document.Findings do + lines.Add($" - `{finding.Severity}` `{finding.Id}`: {finding.Message}") + for fix in document.Fixes do + lines.Add($" - `fix` `{fix.Id}`: {fix.Message}") lines.Add("") lines.Add("## Responsibility issues") lines.Add("") diff --git a/plugins/reverse-engineering-skills/tests/test_research_macos_security_control.py b/plugins/reverse-engineering-skills/tests/test_research_macos_security_control.py deleted file mode 100644 index 0f1584227..000000000 --- a/plugins/reverse-engineering-skills/tests/test_research_macos_security_control.py +++ /dev/null @@ -1,33 +0,0 @@ -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def read(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8") - - -def test_research_workflow_separates_public_private_and_runtime_evidence() -> None: - skill = read("skills/research-macos-security-control/SKILL.md") - hierarchy = read("skills/research-macos-security-control/references/source-and-evidence-hierarchy.md") - matrix = read("skills/research-macos-security-control/references/control-research-matrix.md") - note = read("skills/research-macos-security-control/references/technical-note-contract.md") - - assert "Do not present private symbols" in skill - assert "exact macOS version/build" in skill - assert "A private symbol or schema proves only" in hierarchy - for control in ("TCC/privacy", "App Sandbox/files", "Execution/distribution", "Malware protection", "System integrity"): - assert control in matrix - for heading in ("Public Contract", "Direct Observations", "Private Implementation Evidence", "Hypotheses And Tests"): - assert heading in note - - -def test_research_workflow_requires_bounded_probe_and_handoffs() -> None: - probe = read("skills/research-macos-security-control/references/exact-build-probe-design.md") - skill = read("skills/research-macos-security-control/SKILL.md") - assert "Disposable SIP-enabled macOS guest" in probe - assert "Do not prompt or mutate Gale's active Mac" in probe - for owner in ("macos-privacy-permissions-workflow", "diagnose-apple-entitlements", "assess-macos-threat"): - assert owner in skill - assert "$research-macos-security-control" in read("skills/research-macos-security-control/agents/openai.yaml") diff --git a/plugins/swiftasb-skills/.codex-plugin/plugin.json b/plugins/swiftasb-skills/.codex-plugin/plugin.json deleted file mode 100644 index 8bb3c0aeb..000000000 --- a/plugins/swiftasb-skills/.codex-plugin/plugin.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "swiftasb-skills", - "version": "10.0.2", - "description": "Codex skills for explaining SwiftASB and building SwiftUI, AppKit, and Swift package integrations on top of it.", - "author": { - "name": "Gale", - "email": "mail@galewilliams.com", - "url": "https://github.com/gaelic-ghost" - }, - "homepage": "https://github.com/gaelic-ghost/socket/tree/main/plugins/swiftasb-skills", - "repository": "https://github.com/gaelic-ghost/socket", - "license": "Apache-2.0", - "keywords": [ - "codex", - "skills", - "swift", - "swiftui", - "appkit", - "swiftasb", - "app-server" - ], - "skills": "./skills/", - "interface": { - "displayName": "SwiftASB Skills", - "shortDescription": "SwiftASB explanation and integration workflows for Swift apps and packages.", - "longDescription": "Guide Codex agents through explaining SwiftASB, choosing the right integration shape, and building SwiftUI, AppKit, and Swift package surfaces on top of SwiftASB's Swift-native Codex app-server API.", - "developerName": "gaelic-ghost", - "category": "Developer Tools", - "capabilities": [ - "Read", - "Write" - ], - "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/swiftasb-skills", - "defaultPrompt": [ - "Explain whether SwiftASB is the right fit for this Swift app before we start building.", - "Choose a SwiftASB integration shape for this SwiftUI, AppKit, package, or helper project.", - "Add a SwiftUI-facing SwiftASB client model using observable companions.", - "Add an AppKit-facing SwiftASB owner for a window, document, or controller.", - "Design a Swift package API that uses SwiftASB internally without leaking raw wire models.", - "Diagnose why this SwiftASB-backed app or test is failing." - ], - "brandColor": "#F05138", - "composerIcon": "./assets/swiftasb-skills-icon.svg", - "logo": "./assets/swiftasb-skills-icon.svg" - } -} diff --git a/plugins/swiftasb-skills/AGENTS.md b/plugins/swiftasb-skills/AGENTS.md deleted file mode 100644 index 5b9e03cdf..000000000 --- a/plugins/swiftasb-skills/AGENTS.md +++ /dev/null @@ -1,34 +0,0 @@ -# AGENTS.md - -This file is the SwiftASB Skills child-repo override for work done from `socket`. Follow the root `socket` guidance for general git, docs, release, branch, dependency-provenance, and maintainer workflow rules. - -## Scope - -- `swiftasb-skills` ships Codex skills for explaining and integrating [SwiftASB](https://github.com/gaelic-ghost/SwiftASB). -- Treat root [`skills/`](./skills/) as the authored skill source of truth. -- Treat [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json) as plugin packaging metadata. -- Keep this plugin focused on SwiftASB-specific explanation, decision support, and integration workflows. - -## Local Rules - -- Use the current SwiftASB repository, README, DocC docs, release notes, and public Swift API as the source of truth for package behavior. -- SwiftASB `v1.0.0` is the first supported public API baseline, and `v1.8.0` is the current released baseline for ergonomic one-call startup through `CodexAppServer.start(_:)`, Codex CLI `0.142.x` compatibility plus compatible `0.141.x` prior-minor runtime support, app-wide library and inventory companions, stable worktree groups, repository/worktree filters, selected-worktree Git status, project identity, thread source, filesystem, config, extension inventory and marketplace maintenance, MCP install/status/resource helpers, diagnostics, workspace permission, feature policy, feature-operation events, worktree snapshots, query descriptors, thread management, plan-mode turn starts, observable agenda state for thread goals and plans, code-review starts, gated shell-command execution, recent-activity guidance, `CodexTurnItem.Kind.sleep`, optional ASBPresentation/ASBAppKit/ASBSwiftUI products, and observable current-state companions; verify local or GitHub package state before writing exact API guidance. -- Do not copy SwiftASB source, generated wire models, or schema files into this plugin. -- Do not describe generated `CodexWire...` models as the intended public integration surface; SwiftASB's public surface is the hand-owned Swift API. -- Use `apple-dev-skills` for Apple framework behavior, SwiftUI/AppKit lifecycle rules, Xcode workflow selection, DocC, build, and test execution guidance. - -## Skill Boundary Notes - -- Keep feature policy, inventory, MCP, extension inventory, marketplace maintenance, selected Git status, plan/goal, code-review, shell-command, and feature-operation-event guidance inside the existing explanation, integration-shape, app-building, package-building, and diagnosis skills for now because those surfaces change how every SwiftASB integration should think about authority, diagnostics, and UI state. -- Do not split out a new SwiftASB feature-policy, plan/goal, Git-actions, shell-command, review-start, MCP-install, or extension-maintenance skill until SwiftASB ships a concrete repo-guidance sync, typed Git action, extension mutation, worktree automation, or larger implementation workflow that requires its own checklist. -- When that happens, prefer one narrow workflow skill for the shipped operation instead of a broad policy explainer that duplicates the existing adoption and diagnosis skills. - -## Validation - -For Socket marketplace and packaging changes: - -```bash -uv run scripts/validate_socket_metadata.py -``` - -When these skills change, inspect authored Markdown for stale SwiftASB symbol names and verify links still point at the current source-of-truth docs. diff --git a/plugins/swiftasb-skills/assets/swiftasb-skills-icon.svg b/plugins/swiftasb-skills/assets/swiftasb-skills-icon.svg deleted file mode 100644 index 81a31b221..000000000 --- a/plugins/swiftasb-skills/assets/swiftasb-skills-icon.svg +++ /dev/null @@ -1,24 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="SwiftASB skills icon"> - <defs> - <radialGradient id="bg" cx="50%" cy="50%" r="66%"> - <stop offset="0" stop-color="#2a1522"/> - <stop offset="1" stop-color="#020617"/> - </radialGradient> - <pattern id="scan" width="512" height="18" patternUnits="userSpaceOnUse"> - <path d="M0 9h512" stroke="#fb7185" stroke-opacity=".08" stroke-width="2"/> - </pattern> - <filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> - <feGaussianBlur stdDeviation="7" result="blur"/> - <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> - </filter> - </defs> - <rect width="512" height="512" rx="84" fill="url(#bg)"/> - <rect width="512" height="512" rx="84" fill="url(#scan)"/> - <circle cx="256" cy="256" r="178" fill="none" stroke="#f05138" stroke-width="12"/> - <g filter="url(#glow)" fill="none" stroke-linecap="round" stroke-linejoin="round"> - <path d="M150 166c69 83 143 130 228 142-49 42-126 53-188 20" stroke="#f97316" stroke-width="18"/> - <path d="M174 248c42 32 91 52 148 62-40 23-92 24-137 1" stroke="#f472b6" stroke-width="16"/> - <path d="M334 173c44 46 47 95 9 146" stroke="#22d3ee" stroke-width="16"/> - <path d="M292 142l70 72" stroke="#a78bfa" stroke-width="14"/> - </g> -</svg> diff --git a/plugins/swiftasb-skills/examples/explain-before-implementation.md b/plugins/swiftasb-skills/examples/explain-before-implementation.md deleted file mode 100644 index cb9f1efa1..000000000 --- a/plugins/swiftasb-skills/examples/explain-before-implementation.md +++ /dev/null @@ -1,59 +0,0 @@ -# SwiftASB Explanation Examples - -Use these examples when an agent needs to explain SwiftASB before implementation starts. They are intentionally short and decision-oriented. The agent should still verify the current SwiftASB README, DocC docs, release notes, and public API before making exact claims. - -## Strong Fit: SwiftUI Workspace Inspector - -Recommendation: SwiftASB is a good fit for this app. - -What SwiftASB would do here: SwiftASB would let the SwiftUI app start the local Codex runtime, open a thread for the workspace, start text or plan-mode turns, read app-server-owned workspace, worktree, and selected Git status facts, and read live progress, plan, and goal state through Swift-native handles instead of replaying raw app-server JSON. - -What the app would own: The app still owns its windows, navigation, inspector layout, persistence choices, user preferences, and approval UI. SwiftUI and Observation should own view updates. - -What SwiftASB would own: `CodexAppServer` owns the subprocess, one-call startup, app-wide library and inventory reads, feature-operation events, and diagnostics, `CodexAppServer.fs`, `CodexAppServer.config`, `CodexAppServer.extensions`, and `CodexAppServer.mcp` own app-server-routed facts, extension detail, marketplace maintenance, MCP installs, and MCP resource reads, `CodexThread` owns one conversation, stored-thread actions, text turns, plan-mode turns, and thread goals, `CodexTurnHandle` owns one active turn, and companions such as `Inventory`, `Library`, `Dashboard`, `Agenda`, `Minimap`, `RecentTurns`, `RecentFiles`, and `RecentCommands` provide UI-friendly state. - -Tradeoffs: The app depends on a local Codex CLI runtime, same-thread overlapping turns are rejected, and startup validates against SwiftASB's reviewed Codex support window unless the host app explicitly chooses a looser startup compatibility policy. - -Next integration step: Use `swiftasb:choose-integration-shape`, then `swiftasb:build-swiftui-app`. - -## Strong Fit: AppKit Document Window - -Recommendation: SwiftASB is a good fit if each document or workspace window needs its own Codex thread. - -What SwiftASB would do here: SwiftASB would provide the typed runtime, thread, turn, plan/goal, filesystem/config/extension/MCP, workspace-permission, worktree, selected Git status, app-wide inventory, feature-operation, diagnostic, and history surfaces. The AppKit app can connect those surfaces to window-controller actions, menu validation, toolbar controls, sheets, panels, and inspector views. - -What the app would own: AppKit still owns application lifecycle, document/window ownership, main-actor UI updates, menu state, toolbar actions, and user-facing presentation. - -What SwiftASB would own: SwiftASB owns Codex one-call startup, initialization, thread creation or resume, active turn events, plan updates, goal reads and mutations, request responses, interruption, app-server-owned fact reads, selected Git status refresh, app-wide inventory refresh, diagnostics, feature-operation events, and local history reads. - -Tradeoffs: The app needs explicit process lifetime decisions. A window controller should not secretly own app-wide Codex runtime work if multiple windows share one runtime. - -Next integration step: Use `swiftasb:choose-integration-shape`, then `swiftasb:build-appkit-app`. - -## Conditional Fit: Swift Package Library - -Recommendation: SwiftASB can fit, but only if the package intentionally depends on a local Codex runtime. - -What SwiftASB would do here: SwiftASB would sit inside the package implementation and provide typed Codex runtime control. The package should expose its own narrow request, result, progress, and error types unless consumers genuinely need direct SwiftASB handles. - -What the package would own: The package owns its public API, versioning promises, test strategy, runtime documentation, and consumer-facing error model. - -What SwiftASB would own: SwiftASB owns app-server one-call startup, typed startup errors, app-server-owned fact reads, worktree snapshots, selected Git status, app-wide inventory, MCP helpers, feature policy, feature-operation events, thread, agenda, and turn handles, diagnostics, interactive request routing, query descriptors, and local history helpers. - -Tradeoffs: Normal `swift test` should stay deterministic. Live Codex probes need explicit environment flags, temporary workspaces, serial execution, and hard timeouts. - -Next integration step: Use `swiftasb:choose-integration-shape`, then `swiftasb:build-swift-package`. - -## Poor Fit: Hosted Multi-User Service - -Recommendation: SwiftASB is probably not the right foundation. - -What SwiftASB would do here: SwiftASB drives a local Codex app-server from Swift. It is not a hosted multi-user agent platform and does not remove the need for product-level auth, tenancy, job isolation, deployment, or server operations. - -What the app would own: A hosted service would need to own user accounts, authorization, rate limits, workspace isolation, queueing, secrets, audit logs, and deployment health. - -What SwiftASB would own: SwiftASB could still help a local Swift tool or internal helper drive Codex, but it should not be described as a complete hosted platform. - -Tradeoffs: Treating SwiftASB as the server platform would hide major product and security decisions. - -Next integration step: Use `swiftasb:explain-swiftasb` to document why the fit is weak, then choose a hosted architecture separately. diff --git a/plugins/swiftasb-skills/skills/build-appkit-app/SKILL.md b/plugins/swiftasb-skills/skills/build-appkit-app/SKILL.md deleted file mode 100644 index e280ae0f5..000000000 --- a/plugins/swiftasb-skills/skills/build-appkit-app/SKILL.md +++ /dev/null @@ -1,287 +0,0 @@ ---- -name: build-appkit-app -description: Build or refactor an AppKit app feature on top of SwiftASB using explicit application, window, document, thread, and turn ownership with main-actor UI updates and clear runtime diagnostics. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with SwiftASB v1.8.0 or newer, Swift 6, SwiftPM, AppKit, Xcode, and local Codex app-server integrations. -metadata: - owner: gaelic-ghost - repo: socket - package: SwiftASB - category: swiftasb-appkit -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(swift:*) Bash(xcodebuild:*) ---- - -# Build AppKit App With SwiftASB - -## SwiftData And SwiftUI Rule - -When a task combines SwiftData with SwiftUI, keep SwiftData directly coupled to SwiftUI through Apple's data-driven path: `modelContainer`, environment `modelContext`, `@Query`, SwiftData model objects, and bindings. Do not add repositories, stores, service layers, DTO mirrors, view-model caches, wrapper objects, or other abstraction layers between SwiftData and SwiftUI. If this skill is not the right owner for SwiftData-backed SwiftUI work, hand off to `apple-dev-skills:swiftui-app-architecture-workflow` instead of inventing an intermediate data layer. - -## Purpose - -Help an AppKit app use [SwiftASB](https://github.com/gaelic-ghost/SwiftASB) to start local Codex work, show thread, agenda, and turn progress, answer approvals or elicitation requests, list stored threads, archive or unarchive stored threads, inspect app-server-owned worktree, selected Git status, project identity, thread source, filesystem/config/extension/MCP/workspace facts, observe SwiftASB-owned feature operations, expose app-wide inventory, and expose recent history from app-owned controllers or models. - -The real job is to keep AppKit's app, window, document, and view-controller lifetimes in charge of UI behavior while SwiftASB owns the local Codex subprocess, app-wide library and inventory companions, thread agenda companions, stable worktree groups, repository/worktree filters, selected-worktree Git status, project identity and thread-source facts, app-server-owned worktree snapshots, app-server-routed filesystem/config/extension/MCP reads, workspace permission facts, feature policy, feature-operation events, plan-mode turn starts, goal helpers, review and shell-command entry points, typed thread, agenda, and turn handles, events, request responses, diagnostics, and local history. - -## Required Documentation Gate - -Before implementing or proposing AppKit structure, read the relevant Apple documentation through Apple Dev Skills or official Apple docs. - -Minimum rules to rely on: - -- `NSApplication` manages the app's main event loop, windows, menus, events, and app-wide resources. -- `NSApplicationDelegate` handles app lifecycle callbacks such as launch, termination, activation, reopen, and window-update behavior. -- `NSWindowController` manages a window and often participates in document-based ownership. -- `NSViewController` manages a view and has lifecycle methods suitable for window content. -- AppKit UI types such as `NSWindow` and many delegate callbacks are main-actor UI surfaces; update AppKit views and controllers from the main actor. - -Authoritative docs: - -- [AppKit](https://developer.apple.com/documentation/AppKit) -- [NSApplication](https://developer.apple.com/documentation/appkit/nsapplication) -- [NSApplicationDelegate](https://developer.apple.com/documentation/AppKit/NSApplicationDelegate) -- [NSWindowController](https://developer.apple.com/documentation/appkit/nswindowcontroller) -- [NSViewController](https://developer.apple.com/documentation/AppKit/NSViewController) -- [NSWindow](https://developer.apple.com/documentation/appkit/nswindow) - -## When To Use - -- Use this skill when an AppKit app needs a SwiftASB-backed feature. -- Use this skill after `swiftasb:choose-integration-shape` selects an AppKit, document, window-controller, or menu-bar app shape. -- Use this skill when AppKit menus, toolbar actions, sidebars, inspectors, document windows, or panels need to start, steer, interrupt, or inspect Codex turns. -- Use this skill for refactors that move AppKit code away from raw JSON-RPC, ad hoc process ownership, or generated wire models and toward SwiftASB's public handles. - -## Source Check - -Verify current SwiftASB docs and public API before editing: - -- [SwiftASB GitHub repository](https://github.com/gaelic-ghost/SwiftASB) -- `README.md` -- `Sources/SwiftASB/SwiftASB.docc/GettingStartedWithSwiftASB.md` -- `Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md` -- `Sources/SwiftASB/SwiftASB.docc/ReadingDiagnosticsAndHistory.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md` -- `Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexInventory.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexMCP.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexFS.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexConfig.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md` -- `Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadManagement.md` -- `Sources/SwiftASB/Public/CodexAppServer+Library.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Inventory.swift` -- `Sources/SwiftASB/Public/CodexAppServer+LoadedThreads.swift` -- `Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift` -- `Sources/SwiftASB/Public/CodexAppServer+MCP.swift` -- `Sources/SwiftASB/Public/CodexMCP.swift` -- `Sources/SwiftASB/Public/CodexFS.swift` -- `Sources/SwiftASB/Public/CodexConfig.swift` -- `Sources/SwiftASB/Public/CodexWorkspace.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift` -- `Sources/SwiftASB/Public/CodexAppServer.swift` -- `Sources/SwiftASB/Public/CodexDiagnostics.swift` -- `Sources/SwiftASB/Public/CodexErrors.swift` -- `Sources/SwiftASB/Public/CodexReviewHandle.swift` -- `Sources/SwiftASB/Public/CodexThread.swift` -- `Sources/SwiftASB/Public/CodexThread+Dashboard.swift` -- `Sources/SwiftASB/Public/CodexThread+Agenda.swift` -- `Sources/SwiftASB/Public/CodexTurnHandle.swift` - -As of SwiftASB `v1.8.0`, AppKit-facing integrations should prefer: - -- `CodexAppServer.start(_:)` with `CodexAppServer.StartupRequest` for normal one-call subprocess startup, compatibility validation, initialization, and typed `CodexAppServerStartupError` failures -- lower-level `CodexAppServer.start()`, `cliExecutableDiagnostics()`, and `initialize(_:)` only when the app intentionally owns custom diagnostics, compatibility policy, or test setup before initialization -- `CodexAppServer` for subprocess ownership, diagnostics, stored-thread operations, model capability reads, feature-operation-event streams, and hook diagnostics -- `CodexAppServer.makeLibrary(configuration:)` for app-wide stored-thread lists, cwd or repository grouping, stable worktree groups, repository/worktree filters, selected worktree or repository context, selected-worktree Git status, library-local selection, `CodexWorkspace.ProjectInfo` project identity, `CodexAppServer.ThreadSource` source badges, and model/MCP/hook snapshots that refresh when app-server app/skill/MCP state changes -- `CodexAppServer.makeInventory(configuration:)` for routine app-wide model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes -- `CodexAppServer.fs`, `CodexAppServer.config`, and `CodexAppServer.extensions` for app-server-owned file metadata, directory/file reads, file discovery with match metadata, effective config, advanced extension pagination, plugin detail reads, and already-configured marketplace upgrades -- `CodexAppServer.mcp` for MCP server installs, full status snapshots, and app-wide or thread-scoped MCP resource contents -- `SwiftASBFeaturePolicy` for feature-category defaults and host app authority, with `gitObservability`, `extensionInventory`, and `extensionMaintenance` enabled by default and mutation-oriented categories disabled until the app opts in -- `CodexAppServer.featureOperationEvents()` for human-readable SwiftASB-owned mutation records, such as marketplace maintenance attempts -- `CodexWorkspace` for session cwd, app-server-owned worktree snapshots, project identity, Git repository facts, selected Git status snapshots, active permission profile, and runtime filesystem/network permission facts -- `CodexThread` for conversation-scoped text turns, plan-mode turns, thread events, thread actions, archive/unarchive, thread goals, request responses, and local history -- `CodexThread.startReview(against:placement:)` for code-review UI, and `CodexThread.sendShellCommand(_:)` only for explicit user-level shell actions when `shellCommandExecution` is enabled -- `CodexTurnHandle` for one active turn, including events, steering, interruption, request responses, minimap state, and completion handoff -- `CodexTurnItem.Kind.sleep` when custom transcript, history, or activity UI switches over public turn item kinds -- `CodexThread.makeDashboard()`, `CodexThread.makeAgenda()`, and `CodexTurnHandle.minimap` as UI-friendly current-state mirrors -- local history helpers and recent companions for inspector panels, transcript sidebars, and completed work views -- query descriptors such as `CodexAppServer.ThreadListQD`, `CodexFS.FileDiscoveryQD`, `CodexThread.HistoryWindowQD`, `CodexThread.RecentFilesQD`, and `CodexThread.RecentCommandsQD` for repeatable sidebar, file-picker, inspector, and history intent -- optional `ASBPresentation` and `ASBAppKit` products when the app wants SwiftASB presentation snapshots or the packaged AppKit thread sidebar view - -## Implementation Workflow - -1. Confirm the AppKit ownership shape: app delegate, document, window controller, view controller, menu-bar controller, or helper object. -2. Read the Apple docs for the framework behavior the change relies on. -3. Add SwiftASB as a package dependency only if it is not already present: - - package URL: `https://github.com/gaelic-ghost/SwiftASB` - - minimum version: `1.8.0` when using current one-call startup, Codex CLI `0.142.x` compatibility, app-wide library or inventory, stable worktree groups, repository/worktree filters, selected-worktree Git status, feature policy, feature-operation events, extension marketplace maintenance, project identity, thread source, filesystem match metadata, MCP installs/status/resource reads, config warnings, extension inventory, workspace, query-descriptor, thread archive/unarchive, code-review starts, shell-command execution, plan/goal UI, sleep turn-item classification, presentation products, or recent-activity guidance; otherwise verify the support window in SwiftASB's README - - product: `SwiftASB` - - optional products: `ASBPresentation` and `ASBAppKit` when the app uses SwiftASB's reusable presentation snapshots or packaged AppKit views -4. Choose the SwiftASB owner: - - application-level model owns `CodexAppServer` when one runtime serves many windows - - application, window, or document model owns `CodexAppServer.Library` when the UI needs stored-thread lists before a thread is selected - - document or window model owns `CodexThread` when work belongs to one workspace or document - - active command method or controller owns `CodexTurnHandle` while one turn is running -5. Start the app-server from an explicit async lifecycle point, using `appServer.start(_:)` for normal clients and the lower-level `start()` plus `initialize(_:)` sequence only for custom diagnostics or tests. -6. Create, resume, or fork a thread for the window, document, or workspace. -7. Route menu and toolbar actions into local controller methods that start, steer, interrupt, or inspect turns. -8. Use `appServer.fs`, `appServer.config`, `appServer.makeInventory(configuration:)`, `appServer.extensions`, `appServer.mcp`, `CodexWorkspace`, and `SwiftASBFeaturePolicy` when inspectors, preferences, file pickers, MCP panes, or diagnostics need Codex-owned filesystem, config, plugin/skill/app, collaboration-mode, marketplace-maintenance, resource, worktree, selected Git status, project identity, thread source, permission facts, or feature-category choices. -9. Update AppKit views on the main actor from SwiftASB events, dashboard, agenda, minimap, diagnostics, and local history. -10. Route approval and elicitation responses through the matching `CodexTurnHandle` or `CodexThread`. -11. Make startup, compatibility, turn, approval, cancellation, and shutdown errors human-readable. -12. Validate with the repository's documented Xcode path. - -## Ownership Pattern - -Prefer one AppKit-facing object that makes lifetime visible: - -```swift -import AppKit -import SwiftASB - -@MainActor -final class CodexWorkspaceWindowController: NSWindowController { - private let appServer: CodexAppServer - private var inventory: CodexAppServer.Inventory? - private var library: CodexAppServer.Library? - private var thread: CodexThread? - private var currentTurn: CodexTurnHandle? - - @IBOutlet private var statusField: NSTextField! - - init(appServer: CodexAppServer) { - self.appServer = appServer - super.init(window: nil) - } - - required init?(coder: NSCoder) { - nil - } - - func connect(workspacePath: String) { - Task { @MainActor in - do { - let session = try await appServer.start( - .init( - clientInfo: .init( - name: "ExampleApp", - title: "Example App", - version: "1.0.0" - ) - ) - ) - _ = session.cliExecutableDiagnostics - - inventory = try await appServer.makeInventory( - configuration: .init( - hookListCurrentDirectoryPaths: [workspacePath], - extensionCurrentDirectoryPaths: [workspacePath] - ) - ) - thread = try await appServer.startThread( - .init(currentDirectoryPath: workspacePath) - ) - library = try await appServer.makeLibrary( - configuration: .init( - sortedBy: .turnFinishedNewestFirst, - groupedBy: .repository, - query: .unarchived(limit: 30), - mcpServerStatusRequest: .init(detail: .toolsAndAuthOnly) - ) - ) - statusField.stringValue = "Codex is ready." - } catch { - statusField.stringValue = "SwiftASB could not start Codex: \(error)" - } - } - } - - @IBAction func runSelectedTask(_ sender: Any?) { - guard let thread else { - statusField.stringValue = "SwiftASB cannot start a turn before a thread exists." - return - } - - Task { @MainActor in - do { - let turn = try await thread.startTextTurn("Summarize the current workspace.") - currentTurn = turn - - for try await event in turn.events { - if case .completed = event { - _ = try await turn.complete() - currentTurn = nil - statusField.stringValue = "Codex turn finished." - return - } - } - } catch { - currentTurn = nil - statusField.stringValue = "SwiftASB turn failed: \(error)" - } - } - } - - @IBAction func interruptTurn(_ sender: Any?) { - Task { @MainActor in - do { - try await currentTurn?.interrupt() - statusField.stringValue = "Interrupt sent to Codex." - } catch { - statusField.stringValue = "SwiftASB could not interrupt the turn: \(error)" - } - } - } -} -``` - -Use this as a shape, not as a file to paste blindly. Match the app's actual nib/storyboard/programmatic-window setup, document model, and error UI. - -## UI Guidance - -- Show `CodexAppServerStartupError` startup and compatibility failures before enabling menu or toolbar actions. -- Keep menu validation tied to real state: no thread, active turn, waiting approval, or idle. -- Disable same-thread start actions while a turn is active, or create a separate thread when concurrent work is truly intended. -- Use `CodexAppServer.Inventory` for routine app-wide model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes. -- Use a `CodexAppServer.Library` for source lists, launchers, project browsers, stored-thread selection, selected-worktree Git status, project identity display, thread-source badges, app-wide model capabilities, MCP status, and hook diagnostics. -- Use `CodexAppServer.fs` and `CodexFS.FileDiscoveryQD` for sandbox-safe file pickers, metadata inspectors, directory browsers, file-byte previews, watches, highlighted matches, and ranking explanations. -- Use `CodexAppServer.mcp` when the app needs to install MCP servers, show full MCP details, or show text/blob resource contents advertised by a configured MCP server. -- Use `CodexAppServer.config`, `CodexAppServer.extensions`, and `CodexWorkspace` for preferences or diagnostics panes that show effective config, requirements, advanced extension detail, marketplace maintenance, worktree snapshots, selected Git status, project identity, active profile, and filesystem/network permissions. -- Use `SwiftASBFeaturePolicy` to present feature-category toggles only when the app actually lets users change SwiftASB-owned authority. Read-only Git observability and extension inventory are enabled by default; stronger mutation categories should stay deliberate app choices. -- Subscribe to `CodexAppServer.featureOperationEvents()` when AppKit needs to show marketplace-upgrade results or future SwiftASB-owned mutation records in a status pane, log view, or inspector. -- Use `CodexThread.archive()` and `CodexThread.unarchive()` for archive UI actions when the app already owns the selected thread handle. -- Use `CodexThread.readGoal()`, `setGoal(_:)`, `clearGoal()`, `setName(_:)`, `updateMetadata(gitInfo:)`, `compactContext()`, and `rollbackLastTurns(_:)` from explicit menu, toolbar, inspector, or document actions that already own the selected thread. -- Use `CodexThread.startReview(against:placement:)` only from review controls that clearly say what will be reviewed and whether the result appears inline or detached. -- Use `CodexThread.sendShellCommand(_:)` only behind explicit user opt-in for high-impact shell execution; preserve shell syntax and explain that it does not inherit the thread sandbox policy. -- Show approvals as concrete AppKit UI: sheet, popover, panel, or inspector row that names the command, file change, permission, or MCP action. -- Use `dashboard` and `minimap` state for activity views instead of replaying every raw event into controller-owned arrays. -- Handle `CodexTurnItem.Kind.sleep` explicitly in custom turn item switches so AppKit transcript and activity UI remains current with Codex CLI `0.142.x` events. -- Use `ASBThreadSidebarView` from `ASBAppKit` when the app wants the packaged dense source-list renderer over `ASBPresentation` snapshots. -- Keep document and window closure explicit: interrupt active work or make it clear that background work continues elsewhere. -- Surface diagnostics, including config warnings, deprecation notices, MCP status changes, and remote-control status changes, in places a Mac maintainer can actually inspect, such as a status item, inspector, log pane, or preferences diagnostics view. - -## Validation - -Use the repository's documented Xcode build and test path. For Xcode projects, do not assume SwiftPM validation is enough because scheme, target membership, entitlements, sandboxing, signing, and resources may be Xcode-owned. - -Live Codex integration tests should be opt-in, isolated in temporary workspaces, and bounded by hard timeouts. - -## Handoffs - -- Use `swiftasb:explain-swiftasb` when the user needs adoption tradeoffs before implementation. -- Use `swiftasb:choose-integration-shape` when ownership or app shape is unclear. -- Use `swiftasb:diagnose-integration` when startup, turn, approval, MCP, diagnostics, or history behavior fails. -- Use `apple-dev-skills:explore-apple-swift-docs` for AppKit, SwiftUI, SwiftPM, or Observation documentation. -- Use Apple build, test, or Xcode workflow skills for project execution and diagnostics. - -## Guardrails - -- Do not make a window or view controller secretly own app-wide Codex runtime work if the app has multiple windows that should share one `CodexAppServer`. -- Do not mutate AppKit UI from detached background work without returning to the main actor. -- Do not put raw generated `CodexWire...` models into AppKit controller or view state. -- Do not introduce a command bus or broad coordinator just to forward SwiftASB events; use local AppKit actions and SwiftASB handles unless the app already has a real architecture surface for that job. -- Do not start overlapping turns on the same thread; SwiftASB rejects that because the live app-server does not expose a reliable independent lifecycle for them. -- Do not hide local Codex CLI discovery, compatibility, or startup failures behind generic messages; preserve `CodexAppServerStartupError` cases when mapping errors into AppKit status text. -- Do not run multiple SwiftPM or Xcode build/test commands concurrently. diff --git a/plugins/swiftasb-skills/skills/build-swift-package/SKILL.md b/plugins/swiftasb-skills/skills/build-swift-package/SKILL.md deleted file mode 100644 index 63aaad1f3..000000000 --- a/plugins/swiftasb-skills/skills/build-swift-package/SKILL.md +++ /dev/null @@ -1,247 +0,0 @@ ---- -name: build-swift-package -description: Build or refactor a Swift package API on top of SwiftASB without leaking raw app-server wire models, while keeping live Codex probes opt-in, isolated, timeout-bounded, and documented. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with SwiftASB v1.8.0 or newer, Swift 6, SwiftPM, package libraries, command-line tools, and local Codex app-server integrations. -metadata: - owner: gaelic-ghost - repo: socket - package: SwiftASB - category: swiftasb-package -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(swift:*) ---- - -# Build Swift Package With SwiftASB - -## SwiftData And SwiftUI Rule - -When a task combines SwiftData with SwiftUI, keep SwiftData directly coupled to SwiftUI through Apple's data-driven path: `modelContainer`, environment `modelContext`, `@Query`, SwiftData model objects, and bindings. Do not add repositories, stores, service layers, DTO mirrors, view-model caches, wrapper objects, or other abstraction layers between SwiftData and SwiftUI. If this skill is not the right owner for SwiftData-backed SwiftUI work, hand off to `apple-dev-skills:swiftui-app-architecture-workflow` instead of inventing an intermediate data layer. - -## Purpose - -Help a Swift package use [SwiftASB](https://github.com/gaelic-ghost/SwiftASB) internally while exposing the package's own small, Swift-native API to its callers. - -The real job is to keep the package's public surface understandable. SwiftASB can own Codex runtime startup, app-wide library and inventory state, thread agenda state, stable worktree groups, repository/worktree filters, selected-worktree Git status, project identity and thread-source facts, app-server-owned worktree snapshots, app-server-routed filesystem/config/extension/MCP reads, workspace permission facts, feature policy, feature-operation events, plan-mode turn starts, goal helpers, code-review and shell-command entry points, typed threads, turns, events, diagnostics, query descriptors, and local history inside the implementation, but the package author should decide deliberately whether consumers see SwiftASB handles directly or a narrower domain-specific API. - -## Required Documentation Gate - -Before implementing or proposing package structure, read the relevant SwiftPM and Swift documentation through Apple Dev Skills, Swift.org, or official Apple docs. - -Minimum rules to rely on: - -- A Swift package is configured by a `Package.swift` manifest at the package root. -- `Package` defines package name, products, targets, dependencies, platforms, resources, and Swift language mode. -- A library product is the externally visible artifact clients import. -- A package dependency should resolve from a real remote source that other contributors can fetch. -- Version-based dependency requirements are the recommended default for published package dependencies. - -Authoritative docs: - -- [Swift Package Manager](https://www.swift.org/documentation/package-manager/) -- [PackageDescription](https://developer.apple.com/documentation/packagedescription) -- [Package](https://developer.apple.com/documentation/PackageDescription/Package) -- [Swift Package Manager PackageDescription API](https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html) - -## When To Use - -- Use this skill when a Swift package wants to build features on top of SwiftASB. -- Use this skill after `swiftasb:choose-integration-shape` selects a package library, command-line package, helper package, or test harness shape. -- Use this skill when a package should expose SwiftASB-backed capabilities without making raw app-server wire types its public API. -- Use this skill when live Codex behavior needs to be tested or documented without becoming a normal unit-test dependency. - -## Source Check - -Verify current SwiftASB docs and public API before editing: - -- [SwiftASB GitHub repository](https://github.com/gaelic-ghost/SwiftASB) -- `README.md` -- `Sources/SwiftASB/SwiftASB.docc/GettingStartedWithSwiftASB.md` -- `Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md` -- `Sources/SwiftASB/SwiftASB.docc/ReadingDiagnosticsAndHistory.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md` -- `Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexInventory.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexMCP.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexFS.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexConfig.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md` -- `Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadManagement.md` -- `Sources/SwiftASB/Public/CodexAppServer+Library.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Inventory.swift` -- `Sources/SwiftASB/Public/CodexAppServer+LoadedThreads.swift` -- `Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift` -- `Sources/SwiftASB/Public/CodexAppServer+MCP.swift` -- `Sources/SwiftASB/Public/CodexMCP.swift` -- `Sources/SwiftASB/Public/CodexFS.swift` -- `Sources/SwiftASB/Public/CodexConfig.swift` -- `Sources/SwiftASB/Public/CodexWorkspace.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift` -- `Sources/SwiftASB/Public/CodexAppServer.swift` -- `Sources/SwiftASB/Public/CodexDiagnostics.swift` -- `Sources/SwiftASB/Public/CodexErrors.swift` -- `Sources/SwiftASB/Public/CodexReviewHandle.swift` -- `Sources/SwiftASB/Public/CodexThread.swift` -- `Sources/SwiftASB/Public/CodexThread+Agenda.swift` -- `Sources/SwiftASB/Public/CodexTurnHandle.swift` - -As of SwiftASB `v1.8.0`, package integrations should prefer: - -- `CodexAppServer.start(_:)` with `CodexAppServer.StartupRequest` for normal one-call subprocess startup, compatibility validation, initialization, and typed `CodexAppServerStartupError` failures -- lower-level `CodexAppServer.start()`, `cliExecutableDiagnostics()`, and `initialize(_:)` only when the package intentionally owns custom diagnostics, compatibility policy, or test setup before initialization -- `CodexAppServer` for subprocess ownership, diagnostics, stored-thread operations, model capability reads, feature-operation-event streams, and hook diagnostics -- `CodexAppServer.makeLibrary(configuration:)` when the package intentionally exposes app-wide stored-thread lists, cwd or repository grouping, stable worktree groups, repository/worktree filters, selected worktree or repository context, selected-worktree Git status, library-local selection, `CodexWorkspace.ProjectInfo` project identity, `CodexAppServer.ThreadSource` source facts, or app-wide model/MCP/hook snapshots that refresh when app-server app/skill/MCP state changes -- `CodexAppServer.makeInventory(configuration:)` when the package intentionally exposes routine app-wide model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, or collaboration modes -- `CodexAppServer.fs`, `CodexAppServer.config`, and `CodexAppServer.extensions` when the package intentionally exposes app-server-owned filesystem, config, advanced extension pagination, plugin detail reads, or already-configured marketplace upgrades -- `CodexAppServer.mcp` when the package intentionally exposes MCP installs, full status snapshots, or app-wide or thread-scoped MCP resource contents -- `SwiftASBFeaturePolicy` and `CodexAppServer.featureOperationEvents()` when the package intentionally exposes SwiftASB-owned authority choices or mutation-operation records -- `CodexWorkspace` when consumers need session cwd, app-server-owned worktree snapshots, project identity, Git repository facts, selected Git status snapshots, active permission profile, or runtime filesystem/network permission facts -- `CodexThread` for conversation-scoped text turns, plan-mode turns, archive/unarchive, thread actions, thread goals, request responses, and local history -- `CodexThread.startReview(against:placement:)` and `CodexReviewHandle` when the package intentionally exposes app-server review starts -- `CodexThread.sendShellCommand(_:)` only when the package intentionally exposes high-impact user-level shell execution and requires the host to enable `shellCommandExecution` -- `CodexThread.makeAgenda()` and goal helpers when the package intentionally exposes plan and goal state -- `CodexTurnHandle` for one active turn, including events, steering, interruption, request responses, and completion handoff -- `CodexTurnItem.Kind.sleep` when the package switches over public turn item kinds or exposes turn-history classifications -- query descriptors when the package API needs repeatable thread-list, file-discovery, history-window, recent-file, or recent-command intent -- optional `ASBPresentation`, `ASBAppKit`, and `ASBSwiftUI` products only when the package intentionally exposes reusable presentation or UI components -- package-owned public types for the user's domain when consumers do not need direct SwiftASB handles - -## Implementation Workflow - -1. Inspect the package manifest and target layout. -2. Read the SwiftPM docs for the package behavior the change relies on. -3. Decide whether SwiftASB is implementation detail or public API: - - implementation detail: expose package-owned request, result, progress, and error types - - public dependency: expose selected SwiftASB handles only when consumers genuinely need them -4. Add SwiftASB as a dependency only if it is not already present: - - package URL: `https://github.com/gaelic-ghost/SwiftASB` - - minimum version: `1.8.0` when using current one-call startup, Codex CLI `0.142.x` compatibility, app-wide library or inventory, stable worktree groups, repository/worktree filters, selected-worktree Git status, feature policy, feature-operation events, extension marketplace maintenance, project identity, thread source, filesystem match metadata, MCP installs/status/resource reads, config warnings, extension inventory, workspace, query-descriptor, thread archive/unarchive, code-review starts, shell-command execution, plan/goal UI, sleep turn-item classification, presentation products, or recent-activity guidance; otherwise verify the support window in SwiftASB's README - - product: `SwiftASB` -5. Add the dependency to the target that owns Codex behavior, not every target by default. -6. Decide whether plan/goal state, filesystem/config/extension/MCP/workspace/worktree/selected-Git-status/project-identity/thread-source facts, app-wide inventory, feature-policy choices, review starts, shell commands, or feature-operation events are part of the package API or only implementation detail. -7. Keep startup, turn, approval, cancellation, diagnostics, history, and app-server-owned fact errors descriptive and package-specific. -8. Keep normal tests deterministic with package-owned fakes, adapters, fixtures, or small pure transformations. -9. Add live Codex probes only behind explicit opt-in flags, temporary workspaces, serial execution, and hard timeouts. -10. Document runtime requirements, compatibility expectations, and live-test flags in the package README or contributor docs. -11. Validate with `swift build` and `swift test`, plus any repo-documented checks. - -## Public API Pattern - -Prefer a narrow package-owned facade when the package is not primarily a SwiftASB wrapper: - -```swift -import SwiftASB - -public struct WorkspaceSummaryRequest: Sendable { - public var workspacePath: String - public var prompt: String - - public init(workspacePath: String, prompt: String) { - self.workspacePath = workspacePath - self.prompt = prompt - } -} - -public struct WorkspaceSummary: Sendable { - public var text: String -} - -public actor WorkspaceSummarizer { - private let appServer: CodexAppServer - - public init() { - self.appServer = CodexAppServer() - } - - public func shutdown() async { - await appServer.stop() - } - - public func summarize(_ request: WorkspaceSummaryRequest) async throws -> WorkspaceSummary { - _ = try await appServer.start( - .init( - clientInfo: .init( - name: "WorkspaceSummarizer", - title: "Workspace Summarizer", - version: "1.0.0" - ) - ) - ) - - let thread = try await appServer.startThread( - .init(currentDirectoryPath: request.workspacePath) - ) - let turn = try await thread.startTextTurn(request.prompt) - - for try await event in turn.events { - if case .completed = event { - _ = try await turn.complete() - return WorkspaceSummary(text: "Summary completed.") - } - } - - throw WorkspaceSummaryError.turnEndedWithoutCompletion - } -} - -public enum WorkspaceSummaryError: Error, Sendable { - case turnEndedWithoutCompletion -} -``` - -Use this as a shape, not as a file to paste blindly. Most packages should return their own real result data, stream their own progress values, and map SwiftASB failures into errors their consumers can understand. - -## API Design Guidance - -- Keep the public API focused on the package's job, not on exposing every SwiftASB capability. -- Prefer typed request, result, progress, and options values over strings, booleans, or parallel parameters. -- Expose `CodexAppServer`, `CodexThread`, or `CodexTurnHandle` only when the package is intentionally a thin SwiftASB extension surface. -- Keep generated `CodexWire...` models out of public API unless the user explicitly asks for protocol-level work. -- Prefer `CodexAppServer.fs`, `CodexAppServer.config`, `CodexAppServer.makeInventory(configuration:)`, `CodexAppServer.extensions`, `CodexAppServer.mcp`, `CodexWorkspace`, and `SwiftASBFeaturePolicy` over direct local reads when the package needs facts owned by the Codex app-server, including worktree snapshots, selected Git status, project identity, repository facts, inventory, extension detail, MCP status/resources, marketplace maintenance, and feature-category choices. -- Expose feature-operation events only when consumers need audit or status records for SwiftASB-owned mutations. Do not create duplicate package events for routine read-only refreshes. -- Preserve SwiftASB's file-discovery match metadata if the package exposes fuzzy search results; do not recompute highlight ranges or ranking reasons in a parallel scoring system unless the package has its own product-specific ranking. -- Use query descriptor types when the package needs to preserve list, file-discovery, history-window, recent-file, or recent-command intent as data. -- Handle `CodexTurnItem.Kind.sleep` explicitly when switching over `CodexTurnItem.Kind`; do not collapse it into an unknown or failure state. -- Expose thread-management actions such as goals, naming, metadata updates, compaction, or rollback only when those actions are truly part of the package's public job; otherwise keep them as implementation detail around `CodexThread`. -- Expose review starts or shell-command execution only when those actions are truly part of the package's public job; shell commands must remain explicit high-impact user-level execution rather than a hidden helper path. -- Keep cancellation explicit; do not drop a `CodexTurnHandle` silently when the package promises cancellation behavior. -- Document that a local Codex CLI/app-server runtime is required. -- Document SwiftASB compatibility as a reviewed support window, not a generic promise that every future Codex app-server schema is public API. - -## Testing Guidance - -- Keep default `swift test` deterministic and free of live Codex subprocess requirements. -- Use fakes or protocol-shaped seams for package-owned behavior when the test is about your package, not Codex runtime compatibility. -- Put live Codex probes behind an explicit environment flag such as `SWIFTASB_LIVE_TESTS=1`. -- Run live probes in temporary workspaces with hard timeouts. -- Do not run live probes concurrently with other SwiftPM or Xcode build/test commands. -- Make live failure text name the exact boundary: executable discovery, typed startup validation, initialization, thread start, turn start, request response, MCP install, MCP resource read, review start, shell command, diagnostics, history, or shutdown. - -## Validation - -Run the repository's documented validation path. For plain Swift packages, the baseline is: - -```bash -swift build -swift test -``` - -If the package is also an Xcode app workspace, use the repository's documented Xcode workflow instead of assuming SwiftPM is sufficient. - -## Handoffs - -- Use `swiftasb:explain-swiftasb` when the user needs adoption tradeoffs before implementation. -- Use `swiftasb:choose-integration-shape` when ownership or public API shape is unclear. -- Use `swiftasb:diagnose-integration` when startup, turn, approval, MCP, diagnostics, or history behavior fails. -- Use `apple-dev-skills:bootstrap-xcode-workspace --operation align` when product workspace guidance or maintainer workflow needs alignment. -- Use Apple Swift package workflow skills for package build, test, manifest, resource, DocC, or release execution. - -## Guardrails - -- Do not commit machine-local SwiftASB dependency paths such as `/Users/...`, `~/...`, or `../SwiftASB` into public package manifests. -- Do not make live Codex subprocess work part of normal unit tests. -- Do not expose raw generated app-server wire models as the default public API. -- Do not treat same-thread overlapping turns as supported; SwiftASB rejects them because the live app-server does not expose a reliable independent lifecycle for that case. -- Do not hide local Codex CLI discovery, compatibility, startup, or protocol failures behind generic errors; preserve `CodexAppServerStartupError` cases when mapping errors into package-owned error types. -- Do not run multiple SwiftPM or Xcode build/test commands concurrently. diff --git a/plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md b/plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md deleted file mode 100644 index 02955fc5c..000000000 --- a/plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -name: build-swiftui-app -description: Build or refactor a SwiftUI app feature on top of SwiftASB using framework-owned SwiftUI state, SwiftASB thread and turn handles, observable companions, clear runtime diagnostics, and safe validation. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with SwiftASB v1.8.0 or newer, Swift 6, SwiftPM, SwiftUI, Observation, Xcode, and local Codex app-server integrations. -metadata: - owner: gaelic-ghost - repo: socket - package: SwiftASB - category: swiftasb-swiftui -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(swift:*) Bash(xcodebuild:*) ---- - -# Build SwiftUI App With SwiftASB - -## SwiftData And SwiftUI Rule - -When a task combines SwiftData with SwiftUI, keep SwiftData directly coupled to SwiftUI through Apple's data-driven path: `modelContainer`, environment `modelContext`, `@Query`, SwiftData model objects, and bindings. Do not add repositories, stores, service layers, DTO mirrors, view-model caches, wrapper objects, or other abstraction layers between SwiftData and SwiftUI. If this skill is not the right owner for SwiftData-backed SwiftUI work, hand off to `apple-dev-skills:swiftui-app-architecture-workflow` instead of inventing an intermediate data layer. - -## Purpose - -Help a SwiftUI app use [SwiftASB](https://github.com/gaelic-ghost/SwiftASB) to start Codex work, show live progress, expose plan and goal state, handle approvals or user input, list stored threads, archive or unarchive stored threads, inspect app-server-owned worktree, selected Git status, project identity, thread source, filesystem/config/extension/MCP/workspace facts, observe SwiftASB-owned feature operations, expose app-wide inventory, and expose recent thread history without replaying raw app-server protocol payloads into app state. - -The real job is to connect SwiftUI views to SwiftASB's Swift-native handles and observable companions. SwiftUI owns view lifetime and rendering. SwiftASB owns the local Codex app-server process, app-wide library and inventory companions, thread agenda companions, stable worktree groups, repository/worktree filters, selected-worktree Git status, project identity and thread-source facts, app-server-owned worktree snapshots, app-server-routed filesystem/config/extension/MCP reads, workspace permission facts, feature policy, feature-operation events, plan-mode turn starts, goal helpers, review and shell-command entry points, thread, agenda, and turn handles, typed events, request responses, diagnostics, and recent-history companions. - -Keep the shown workspace model at the app or scene ownership boundary; it is not a reusable-view dependency. Reusable SwiftUI panels receive the specific values, bindings, and action closures they need. Prefer existing or deliberately custom environment values/actions for genuine hierarchy-wide capabilities, and do not reintroduce a ViewModel, store, coordinator, or service injection layer below that boundary. - -## Required Documentation Gate - -Before implementing or proposing SwiftUI structure, read the relevant Apple documentation through Apple Dev Skills or official Apple docs. - -Minimum rules to rely on: - -- SwiftUI `@State` is view-managed storage, and SwiftUI updates dependent views when the value changes. -- SwiftUI can store `@Observable` objects in `@State`; subviews update when they read changed observable properties. -- SwiftUI app structure is built from an `App` whose body provides one or more `Scene` values. -- Observation support should come from the `@Observable` macro rather than bare `Observable` protocol conformance. - -Authoritative docs: - -- [SwiftUI State](https://developer.apple.com/documentation/swiftui/state) -- [SwiftUI App](https://developer.apple.com/documentation/swiftui/app) -- [SwiftUI Scene](https://developer.apple.com/documentation/SwiftUI/Scene) -- [Observation Observable macro](https://developer.apple.com/documentation/Observation/Observable%28%29) - -## When To Use - -- Use this skill when a SwiftUI app needs a SwiftASB-backed feature. -- Use this skill after `swiftasb:choose-integration-shape` selects a SwiftUI app shape. -- Use this skill when a SwiftUI app needs live Codex progress, approvals, diagnostics, recent turns, recent files, or recent commands. -- Use this skill for refactors that move a SwiftUI app away from raw JSON-RPC or ad hoc event replay and toward SwiftASB companions. - -## Source Check - -Verify current SwiftASB docs and public API before editing: - -- [SwiftASB GitHub repository](https://github.com/gaelic-ghost/SwiftASB) -- `Sources/SwiftASB/SwiftASB.docc/GettingStartedWithSwiftASB.md` -- `Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md` -- `Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md` -- `Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexInventory.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexMCP.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexFS.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexConfig.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md` -- `Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadManagement.md` -- `Sources/SwiftASB/Public/CodexAppServer+Library.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Inventory.swift` -- `Sources/SwiftASB/Public/CodexAppServer+LoadedThreads.swift` -- `Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift` -- `Sources/SwiftASB/Public/CodexAppServer+MCP.swift` -- `Sources/SwiftASB/Public/CodexMCP.swift` -- `Sources/SwiftASB/Public/CodexFS.swift` -- `Sources/SwiftASB/Public/CodexConfig.swift` -- `Sources/SwiftASB/Public/CodexWorkspace.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift` -- `Sources/SwiftASB/Public/CodexAppServer.swift` -- `Sources/SwiftASB/Public/CodexDiagnostics.swift` -- `Sources/SwiftASB/Public/CodexErrors.swift` -- `Sources/SwiftASB/Public/CodexReviewHandle.swift` -- `Sources/SwiftASB/Public/CodexThread.swift` -- `Sources/SwiftASB/Public/CodexThread+Dashboard.swift` -- `Sources/SwiftASB/Public/CodexThread+Agenda.swift` -- `Sources/SwiftASB/Public/CodexTurnHandle.swift` - -As of SwiftASB `v1.8.0`, SwiftUI-facing integrations should prefer: - -- `CodexAppServer.start(_:)` with `CodexAppServer.StartupRequest` for normal one-call startup, compatibility validation, initialization, and typed `CodexAppServerStartupError` failures -- lower-level `CodexAppServer.start()`, `cliExecutableDiagnostics()`, and `initialize(_:)` only when the app intentionally owns custom diagnostics, compatibility policy, or test setup before initialization -- `CodexAppServer` for process ownership, diagnostics, thread creation, stored-thread operations, feature-operation-event streams, and app-wide capability reads -- `CodexAppServer.makeLibrary(configuration:)` for app-wide stored-thread lists, cwd or repository grouping, stable worktree groups, repository/worktree filters, selected worktree or repository context, selected-worktree Git status, library-local selection, `CodexWorkspace.ProjectInfo` project identity, `CodexAppServer.ThreadSource` source badges, and model/MCP/hook snapshots that refresh when app-server app/skill/MCP state changes -- `CodexAppServer.makeInventory(configuration:)` for routine app-wide model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes -- `CodexAppServer.fs`, `CodexAppServer.config`, and `CodexAppServer.extensions` for app-server-owned file metadata, directory/file reads, file discovery with match metadata, effective config, advanced extension pagination, plugin detail reads, and already-configured marketplace upgrades -- `CodexAppServer.mcp` for MCP server installs, full status snapshots, and app-wide or thread-scoped MCP resource contents -- `SwiftASBFeaturePolicy` for feature-category defaults and host app authority, with `gitObservability`, `extensionInventory`, and `extensionMaintenance` enabled by default and mutation-oriented categories disabled until the app opts in -- `CodexAppServer.featureOperationEvents()` for human-readable SwiftASB-owned mutation records, such as marketplace maintenance attempts -- `CodexWorkspace` for session cwd, app-server-owned worktree snapshots, project identity, Git repository facts, selected Git status snapshots, active permission profile, and runtime filesystem/network permission facts -- `CodexThread` for conversation-scoped text turns, plan-mode turns, request routing, archive/unarchive, thread actions, thread goals, and local history -- `CodexThread.startReview(against:placement:)` for code-review UI, and `CodexThread.sendShellCommand(_:)` only for explicit user-level shell actions when `shellCommandExecution` is enabled -- `CodexTurnHandle` for one active turn, including events, steering, interruption, request responses, and completion handoff -- `CodexThread.makeDashboard()` for thread-level current state -- `CodexThread.makeAgenda()` for current goal state, accepted plan snapshots, proposed plan text, and summary titles -- `CodexThread.startPlanningTurn(...)` and `CodexAppServer.TurnCollaborationMode.plan(...)` for explicit plan-mode UI controls -- `CodexTurnHandle.minimap` for active-turn current state -- `CodexTurnItem.Kind.sleep` when custom turn-history or minimap-adjacent UI switches over public turn item kinds -- `CodexThread.makeRecentTurns(...)`, `makeRecentFiles(...)`, and `makeRecentCommands(...)` for local history views -- `CodexAppServer.ThreadListQD`, `CodexFS.FileDiscoveryQD`, `CodexThread.HistoryWindowQD`, `CodexThread.RecentFilesQD`, and `CodexThread.RecentCommandsQD` when SwiftUI state needs repeatable query intent -- optional `ASBPresentation`, `ASBAppKit`, and `ASBSwiftUI` products when the app wants ready-made sidebar, agenda, or dashboard panels over SwiftASB presentation snapshots - -## Implementation Workflow - -1. Confirm the app's existing SwiftUI state pattern. -2. Read Apple docs for the framework behavior the change relies on. -3. Add SwiftASB as a package dependency only if it is not already present: - - package URL: `https://github.com/gaelic-ghost/SwiftASB` - - minimum version: `1.8.0` when using current one-call startup, Codex CLI `0.142.x` compatibility, app-wide library or inventory, stable worktree groups, repository/worktree filters, selected-worktree Git status, feature policy, feature-operation events, extension marketplace maintenance, project identity, thread source, filesystem match metadata, MCP installs/status/resource reads, config warnings, extension inventory, workspace, query-descriptor, thread archive/unarchive, code-review starts, shell-command execution, plan/goal UI, sleep turn-item classification, presentation products, or recent-activity guidance; otherwise verify the support window in SwiftASB's README - - product: `SwiftASB` - - optional products: `ASBPresentation`, `ASBAppKit`, and `ASBSwiftUI` when the app uses SwiftASB's reusable presentation snapshots or native UI panels -4. Choose the owner object: - - app-wide model owns `CodexAppServer` - - app-wide, scene, or workspace model owns `CodexAppServer.Library` when the UI needs stored-thread lists before a thread is selected - - workspace, document, or conversation model owns `CodexThread` - - active-turn method or model owns `CodexTurnHandle` -5. Start the app-server from an explicit async entrypoint, using `appServer.start(_:)` for normal clients and the lower-level `start()` plus `initialize(_:)` sequence only for custom diagnostics or tests. -6. Create or resume a thread through `CodexAppServer`. -7. Create `CodexAppServer.Library` from the app server when the UI has a launcher, sidebar, project browser, or app-wide diagnostics surface. -8. Use `appServer.fs`, `appServer.config`, `appServer.makeInventory(configuration:)`, `appServer.extensions`, `appServer.mcp`, `CodexWorkspace`, and `SwiftASBFeaturePolicy` when SwiftUI needs filesystem, config, plugin/skill/app, collaboration-mode, marketplace-maintenance, MCP resource or install state, worktree, selected Git status, project identity, thread source, permission facts, or feature-category choices from Codex. -9. Create observable companions from the thread and current turn, including `makeAgenda()` when the UI shows goals or plans. -10. Render state from companions directly where possible. -11. Route approval and elicitation responses through the owning `CodexTurnHandle` or `CodexThread`. -12. Make startup, compatibility, turn, approval, cancellation, and shutdown errors human-readable. -13. Validate with the repository's documented SwiftPM or Xcode path. - -## State Ownership Pattern - -Prefer one app-facing model that makes ownership visible: - -```swift -import Observation -import SwiftASB - -@MainActor -@Observable -final class CodexWorkspaceModel { - private let appServer = CodexAppServer() - - var inventory: CodexAppServer.Inventory? - var library: CodexAppServer.Library? - var thread: CodexThread? - var dashboard: CodexThread.Dashboard? - var agenda: CodexThread.Agenda? - var currentMinimap: CodexTurnHandle.Minimap? - var errorMessage: String? - - func start(workspacePath: String) async { - do { - let session = try await appServer.start( - .init( - clientInfo: .init( - name: "ExampleApp", - title: "Example App", - version: "1.0.0" - ) - ) - ) - _ = session.cliExecutableDiagnostics - - inventory = try await appServer.makeInventory( - configuration: .init( - hookListCurrentDirectoryPaths: [workspacePath], - extensionCurrentDirectoryPaths: [workspacePath] - ) - ) - let thread = try await appServer.startThread( - .init(currentDirectoryPath: workspacePath) - ) - self.thread = thread - library = try await appServer.makeLibrary( - configuration: .init( - sortedBy: .turnFinishedNewestFirst, - groupedBy: .repository, - query: .unarchived(limit: 30), - mcpServerStatusRequest: .init(detail: .toolsAndAuthOnly) - ) - ) - dashboard = await thread.makeDashboard() - agenda = try await thread.makeAgenda() - } catch { - errorMessage = "SwiftASB could not start the local Codex runtime: \(error)" - } - } - - func refreshAppSnapshots() async { - await inventory?.refresh() - await library?.refreshAppSnapshots() - } - - func selectThread(_ threadID: String?) { - library?.selectThread(threadID) - } - - func plan(_ prompt: String) async { - guard let thread else { - errorMessage = "SwiftASB cannot start a planning turn before a thread exists." - return - } - - do { - let turn = try await thread.startPlanningTurn(prompt) - currentMinimap = turn.minimap - - for try await event in turn.events { - if case .completed = event { - _ = try await turn.complete() - currentMinimap = nil - return - } - } - } catch { - errorMessage = "SwiftASB planning turn failed before completion: \(error)" - } - } - - func run(_ prompt: String) async { - guard let thread else { - errorMessage = "SwiftASB cannot start a turn before a thread exists." - return - } - - do { - let turn = try await thread.startTextTurn(prompt) - currentMinimap = turn.minimap - - for try await event in turn.events { - if case .completed = event { - _ = try await turn.complete() - currentMinimap = nil - return - } - } - } catch { - errorMessage = "SwiftASB turn failed before completion: \(error)" - } - } - - func stop() async { - await appServer.stop() - } -} -``` - -Use this as a shape, not as a file to paste blindly. Match the app's real lifetime, error model, and UI needs. - -## UI Guidance - -- Show `CodexAppServerStartupError` startup and compatibility failures before offering turn controls. -- Disable same-thread turn controls while one turn is active, or create a separate thread when concurrent work is truly intended. -- Use `inventory` for routine app-wide model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes. -- Use `library` for stored-thread sidebars, cwd or repository grouping, stable worktree groups, repository/worktree filters, selected worktree or repository context, selected Git status, project identity display, thread-source badges, library-local selection, app-wide model capabilities, MCP server status, and hook diagnostics. -- Show `library.selectedGitStatus`, `lastGitStatusReadAt`, and `latestGitStatusErrorDescription` when a selected-worktree status panel needs branch, SHA, remotes, dirty/untracked counts, or refresh failures. -- Use `CodexAppServer.ThreadListQD` when the same sidebar query should drive both direct `listThreads` reads and library loading. -- Use `CodexAppServer.fs` and `CodexFS.FileDiscoveryQD` for sandbox-safe file pickers, metadata panes, directory browsers, file-byte previews, watches, highlighted matches, and ranking explanations. -- Use `CodexAppServer.mcp` when the UI needs to install MCP servers, show full MCP details, or show text/blob resource contents advertised by a configured MCP server. -- Use `CodexAppServer.config`, `CodexAppServer.extensions`, and `CodexWorkspace` for diagnostics views that show effective config, requirements, advanced extension detail, marketplace maintenance, worktree snapshots, selected Git status, project identity, active profile, and filesystem/network permissions. -- Use `SwiftASBFeaturePolicy` to present feature-category toggles only when the app actually lets users change SwiftASB-owned authority. Read-only Git observability and extension inventory are enabled by default; stronger mutation categories should stay deliberate app choices. -- Subscribe to `CodexAppServer.featureOperationEvents()` when SwiftUI needs to show marketplace-upgrade results or future SwiftASB-owned mutation records. Do not emit parallel UI events for routine read-only refreshes. -- Use `agenda` for plan and goal UI when the view needs current goal state, accepted plan steps, proposed plan text, or goal mutations. Use `CodexThread.readGoal()`, `setGoal(_:)`, `clearGoal()`, `setName(_:)`, `archive()`, `unarchive()`, `updateMetadata(gitInfo:)`, `compactContext()`, and `rollbackLastTurns(_:)` from app-facing actions that already own the selected thread. -- Use `CodexThread.startReview(against:placement:)` only from review controls that clearly say what will be reviewed and where the result appears. -- Use `CodexThread.sendShellCommand(_:)` only behind explicit user opt-in for high-impact shell execution; preserve shell syntax and explain that it does not inherit the thread sandbox policy. -- Use `dashboard` for thread-wide current activity. Use `thread.startPlanningTurn(...)` for Plan buttons instead of sending slash commands through a text prompt. -- Use `currentMinimap` for the active turn's command, file-edit, dynamic-tool, collab-tool, MCP, and compaction activity. -- Handle `CodexTurnItem.Kind.sleep` explicitly in custom turn item switches so UI remains forward-safe for Codex CLI `0.142.x` history and live events. -- Use `ASBThreadSidebar`, `ASBAgendaPanel`, and `ASBDashboardPanel` from `ASBSwiftUI` when the app wants SwiftASB-owned presentation snapshots rendered with packaged UI instead of custom panels. -- Use recent companions for inspector rails and completed history instead of building a second cache from raw events. -- Keep approval and elicitation prompts user-facing and concrete: tell the user what command, file change, permission, or MCP action is being requested. -- Keep cancellation visible and reversible where the app's product model allows it. - -## Validation - -Run the repository's documented validation path. - -For SwiftPM packages: - -```bash -swift build -swift test -``` - -For Xcode apps, use the repository's documented `xcodebuild` or Xcode MCP workflow. Do not assume SwiftPM validation is enough for an app project with Xcode-owned project settings. - -Live Codex integration tests should be opt-in, isolated in temporary workspaces, and bounded by hard timeouts. - -## Handoffs - -- Use `swiftasb:explain-swiftasb` when the user needs adoption tradeoffs before implementation. -- Use `swiftasb:choose-integration-shape` when ownership or app shape is unclear. -- Use `apple-dev-skills:explore-apple-swift-docs` for SwiftUI, Observation, SwiftPM, or AppKit documentation. -- Use Apple build, test, or Xcode workflow skills for project execution and diagnostics. - -## Guardrails - -- Do not put raw generated `CodexWire...` models into SwiftUI view state. -- Do not introduce a command bus or broad coordinator just to forward SwiftASB events; use local model methods and SwiftASB handles unless the app already has a real architecture surface for that job. -- Do not start overlapping turns on the same thread; SwiftASB rejects that because the live app-server does not expose a reliable independent lifecycle for them. -- Do not hide local Codex CLI discovery, compatibility, or startup failures behind generic "failed" messages; preserve `CodexAppServerStartupError` cases when mapping errors into UI text. -- Do not run multiple SwiftPM or Xcode build/test commands concurrently. diff --git a/plugins/swiftasb-skills/skills/choose-integration-shape/SKILL.md b/plugins/swiftasb-skills/skills/choose-integration-shape/SKILL.md deleted file mode 100644 index 7d9ea87cb..000000000 --- a/plugins/swiftasb-skills/skills/choose-integration-shape/SKILL.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -name: choose-integration-shape -description: Choose the right SwiftASB integration shape for a SwiftUI app, AppKit app, command-line tool, helper service, package library, test harness, or mixed Swift project before implementation starts. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with SwiftASB v1.8.0 or newer, Swift 6, SwiftPM, SwiftUI, AppKit, and local Codex app-server integrations. -metadata: - owner: gaelic-ghost - repo: socket - package: SwiftASB - category: swiftasb-planning -allowed-tools: Read Bash(rg:*) Bash(git:*) ---- - -# Choose SwiftASB Integration Shape - -## SwiftData And SwiftUI Rule - -When a task combines SwiftData with SwiftUI, keep SwiftData directly coupled to SwiftUI through Apple's data-driven path: `modelContainer`, environment `modelContext`, `@Query`, SwiftData model objects, and bindings. Do not add repositories, stores, service layers, DTO mirrors, view-model caches, wrapper objects, or other abstraction layers between SwiftData and SwiftUI. If this skill is not the right owner for SwiftData-backed SwiftUI work, hand off to `apple-dev-skills:swiftui-app-architecture-workflow` instead of inventing an intermediate data layer. - -## Purpose - -Pick the smallest correct way for a project to use [SwiftASB](https://github.com/gaelic-ghost/SwiftASB) before code changes begin. - -The practical decision is who owns the local Codex runtime, who owns the app-wide stored-thread library and inventory companions, who owns each conversation thread and its agenda, where active turn state is shown, where app-server-owned worktree, selected Git status, project identity, thread source, filesystem/config/extension/MCP facts appear, which SwiftASB feature categories the host app enables, and how much SwiftASB behavior should be exposed through the user's own app or package API. - -## When To Use - -- Use this skill when a user wants to build on SwiftASB but has not chosen the app or package architecture. -- Use this skill before adding SwiftASB to a SwiftUI, AppKit, command-line, helper-service, or package-only project. -- Use this skill when an existing project has mixed UI, package, and helper targets and needs a clear ownership decision. -- Use this skill when the agent needs to explain the integration plan before editing code. - -## Source Check - -Verify current SwiftASB docs and public API before naming exact symbols: - -- [SwiftASB GitHub repository](https://github.com/gaelic-ghost/SwiftASB) -- `README.md` -- `Sources/SwiftASB/SwiftASB.docc/GettingStartedWithSwiftASB.md` -- `Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md` -- `Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexInventory.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexMCP.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexFS.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexConfig.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md` -- `Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md` -- `Sources/SwiftASB/Public/` - -For SwiftUI, AppKit, SwiftPM, or Xcode behavior, use Apple Dev Skills and Apple documentation first. SwiftASB chooses the Codex integration shape; Apple frameworks still own app lifecycle, view updates, window behavior, and project execution. - -## Classification Workflow - -1. Inspect the repository shape: - - SwiftPM package - - Xcode app project or workspace - - SwiftUI app - - AppKit app - - command-line executable - - helper daemon or local service - - tests or integration harness only -2. Identify the user-visible job: - - chat or transcript UI - - workspace inspector - - sandbox-safe file browser or fuzzy file picker - - command/file activity monitor - - approval and elicitation UI - - model, MCP, hook, config, extension, remote-control, feature-operation, or permission diagnostics - - selected-worktree Git status or marketplace-maintenance UI - - app-wide inventory UI, MCP install UI, MCP resource viewer, MCP inspector, or reusable ASBPresentation/ASBAppKit/ASBSwiftUI UI surface - - plan/goal UI, code-review UI, or explicit shell-command UI - - package API for other apps - - automation or one-shot task execution -3. Choose the SwiftASB owner: - - app-wide model owns `CodexAppServer` - - normal clients start with `CodexAppServer.start(_:)` so startup, compatibility validation, initialization, selected-CLI diagnostics, and typed startup errors stay in one SwiftASB-owned call - - lower-level startup remains a custom diagnostics or test path when the app must inspect the selected executable before deciding whether to initialize - - app-wide or window-scoped launcher model owns `CodexAppServer.Library` when the UI lists stored threads before a thread is chosen - - document or workspace model owns `CodexThread` - - active task model owns `CodexTurnHandle` - - routine app-wide catalogs stay on `CodexAppServer.makeInventory(configuration:)`; advanced extension pagination and marketplace upgrades stay on `CodexAppServer.extensions` - - app-server-owned filesystem/config/extension/MCP/workspace/worktree/project-identity/thread-source reads stay on `CodexAppServer.fs`, `CodexAppServer.config`, `CodexAppServer.extensions`, `CodexAppServer.mcp`, and `CodexWorkspace` - - app-wide feature authority stays in `SwiftASBFeaturePolicy`, and mutation visibility comes from `CodexAppServer.featureOperationEvents()` - - plan and goal UI stays on `CodexThread.makeAgenda()`, `CodexThread.startPlanningTurn(...)`, and `CodexThread` goal helpers - - code-review starts stay on `CodexThread.startReview(against:placement:)` - - user-level shell commands stay behind a visible host-app opt-in to `shellCommandExecution` -4. Choose the state surface: - - SwiftUI observable companions - - app-wide library companion - - AppKit controller-owned models - - command-line event loop - - package API values and async streams - - test harness mocks or live opt-in probes -5. Name validation: - - SwiftPM packages: `swift build` and `swift test` - - Xcode apps: repository-documented Xcode build and test path - - live Codex integration: opt-in flags, temporary workspaces, and hard timeouts only - -## Shape Recommendations - -### SwiftUI App - -Use an app or workspace model to own `CodexAppServer`, then create a `CodexAppServer.Library` when the UI needs a launcher, sidebar, or project browser before choosing a thread. Create a `CodexThread` per conversation or workspace. Store SwiftASB observable companions in a view model instead of replaying raw events into unrelated state. - -Prefer: - -- `CodexAppServer.makeLibrary(configuration:)` for stored-thread sidebars, cwd or repository grouping, stable worktree groups, repository/worktree filters, selected worktree or repository context, library-local selection, `CodexWorkspace.ProjectInfo` project identity, `CodexAppServer.ThreadSource` source facts, and app-wide model/MCP/hook snapshots that refresh when app-server app/skill/MCP state changes -- `CodexAppServer.makeInventory(configuration:)` for routine app-wide capability and extension UI such as model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes -- `SwiftASBFeaturePolicy` on `CodexAppServer.Configuration` or `CodexAppServer.Library.Configuration` when the app should enable, disable, or present feature categories such as `gitObservability`, `extensionInventory`, and `extensionMaintenance` -- `CodexAppServer.Library.selectedGitStatus` and `refreshSelectedGitStatus()` for selected-worktree Git facts when `gitObservability` is enabled -- `CodexAppServer.featureOperationEvents()` for human-readable records of SwiftASB-owned mutations such as marketplace upgrades -- `CodexAppServer.ThreadListQD` for repeatable thread-list intent across direct reads and library loading -- `CodexAppServer.fs` and `CodexFS.FileDiscoveryQD` for sandbox-safe metadata, directory, file-byte, watch, fuzzy file-discovery UI, highlight ranges, and ranking explanations -- `CodexAppServer.mcp.install(_:)`, `statusSnapshot()`, and `readResource(...)` for MCP installs, full MCP detail reads, and app-wide or thread-scoped MCP resource contents -- `CodexAppServer.config`, `CodexAppServer.extensions`, and `CodexWorkspace` for diagnostics, worktree snapshots, selected Git status, project identity, repository facts, permissions, advanced extension pagination, plugin-detail inspection, marketplace maintenance, and runtime facts that should come from the app-server -- `CodexThread.startReview(against:placement:)` for app-server code review UI -- `CodexThread.sendShellCommand(_:)` only when the app deliberately exposes high-impact user-level shell execution and enables `shellCommandExecution` -- `CodexThread.makeDashboard()` for thread-wide activity -- `CodexThread.makeAgenda()` and `CodexThread.startPlanningTurn(...)` for current goal, accepted plan, proposed plan text, and explicit plan-mode controls -- `CodexTurnHandle.minimap` for active turn state -- recent companions for inspector rails and completed history -- `ASBPresentation`, `ASBAppKit`, and `ASBSwiftUI` products when the app wants ready-made sidebar, agenda, or dashboard surfaces over SwiftASB presentation snapshots -- explicit user-visible error strings for `CodexAppServerStartupError`, compatibility, and turn failures - -Handoff: `swiftasb:build-swiftui-app`. - -### AppKit App - -Use an application, document, or window-controller-owned model to hold SwiftASB handles. Keep UI mutation on the main actor and make lifetime explicit so windows do not accidentally keep app-server work alive after close. - -Plan: - -- where `CodexAppServer` starts and stops -- whether the app, scene, window, or document owns a `CodexAppServer.Library` -- which window or document owns each `CodexThread` -- where filesystem/config/extension/MCP/workspace/worktree/selected-Git-status/project-identity/thread-source facts are shown without direct app-process filesystem assumptions -- whether the helper exposes feature-category toggles or only uses SwiftASB defaults -- how menu or toolbar actions start, steer, interrupt, or inspect turns -- how streamed events reach AppKit views safely -- whether `ASBAppKit` or `ASBPresentation` can provide the sidebar or presentation snapshot layer instead of custom controller state - -Handoff: `swiftasb:build-appkit-app`. - -### Command-Line Tool - -Use `CodexAppServer` in a short-lived async main flow. Call `start(_:)`, create or resume a thread, start a turn, stream terminal output or summary, and stop the app-server predictably. Use the lower-level startup calls only when the tool needs a diagnostics screen or custom compatibility decision before initialization. - -Avoid building SwiftUI observable companions unless the tool also feeds a UI. - -### Helper Service - -Use a long-lived owner for `CodexAppServer`, but keep library refreshes, thread ownership, and cancellation explicit. Document how the service starts, stops, exposes status, and avoids overlapping same-thread turns. - -Treat service interruption, process cleanup, and logs as part of the product behavior. - -Use `CodexAppServer.fs`, `CodexAppServer.config`, `CodexAppServer.makeInventory(configuration:)`, `CodexAppServer.extensions`, `CodexAppServer.mcp`, `CodexWorkspace`, and `SwiftASBFeaturePolicy` when the service needs Codex-owned workspace, worktree, selected Git status, project identity, thread source, config, plugin, skill, MCP resource, filesystem facts, inventory, or extension-maintenance authority instead of reading local state directly. - -### Package Library - -Expose the package's own narrow API instead of re-exporting all SwiftASB types by default. Use SwiftASB internally unless the consumer genuinely needs direct `CodexAppServer`, `CodexThread`, or `CodexTurnHandle` access. - -Keep live Codex tests opt-in and timeout-bounded. - -Handoff: `swiftasb:build-swift-package`. - -### Test Harness - -Prefer mock or deterministic transport tests for package behavior. Use live Codex probes only when the test's purpose is runtime compatibility, and isolate them with temporary directories and environment flags. - -## Output Shape - -Return: - -1. `Chosen shape`: one of SwiftUI app, AppKit app, command-line tool, helper service, package library, test harness, or mixed. -2. `SwiftASB owners`: who owns `CodexAppServer`, `CodexThread`, and `CodexTurnHandle`. -3. `State surface`: library companion, observable companions, AppKit model, CLI stream, package API, or tests. -4. `User-visible behavior`: progress, approvals, errors, diagnostics, history, worktree, project identity, thread source, filesystem/config/extension/MCP/workspace facts, cancellation, and, when relevant, inventory, feature-policy choices, mutation-operation events, selected-worktree Git status, MCP installs, code reviews, shell commands, and marketplace maintenance. -5. `Validation path`: exact build/test family to run. -6. `Next skill`: the next SwiftASB or Apple workflow skill. - -## Guardrails - -- Do not add a new manager or coordinator without naming the concrete ownership problem it solves. -- Do not leak raw generated wire types into the user's public API unless the user explicitly asks for protocol-level work. -- Do not hide same-thread overlap rejection; design the UI or API around one active turn per thread. -- Do not run live Codex probes as ordinary unit tests. -- Do not choose Apple framework architecture without reading the relevant Apple docs first. -- Do not expose `sendShellCommand(_:)` as a routine helper; treat it as explicit, high-impact user-level shell execution. diff --git a/plugins/swiftasb-skills/skills/diagnose-integration/SKILL.md b/plugins/swiftasb-skills/skills/diagnose-integration/SKILL.md deleted file mode 100644 index 0f50e221b..000000000 --- a/plugins/swiftasb-skills/skills/diagnose-integration/SKILL.md +++ /dev/null @@ -1,328 +0,0 @@ ---- -name: diagnose-integration -description: Diagnose SwiftASB integration failures across Codex CLI discovery, app-server startup, initialization, threads, turns, approvals, inventory, MCP install/status/resources, reviews, shell commands, worktree grouping, selected Git status, project identity, thread source, filesystem/config/extension/workspace reads, feature policy, feature-operation events, diagnostics, history paging, and live-test isolation. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with SwiftASB v1.8.0 or newer, Swift 6, SwiftPM, SwiftUI, AppKit, CLI tools, package libraries, and local Codex app-server integrations. -metadata: - owner: gaelic-ghost - repo: socket - package: SwiftASB - category: swiftasb-diagnostics -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(swift:*) Bash(xcodebuild:*) ---- - -# Diagnose SwiftASB Integration - -## SwiftData And SwiftUI Rule - -When a task combines SwiftData with SwiftUI, keep SwiftData directly coupled to SwiftUI through Apple's data-driven path: `modelContainer`, environment `modelContext`, `@Query`, SwiftData model objects, and bindings. Do not add repositories, stores, service layers, DTO mirrors, view-model caches, wrapper objects, or other abstraction layers between SwiftData and SwiftUI. If this skill is not the right owner for SwiftData-backed SwiftUI work, hand off to `apple-dev-skills:swiftui-app-architecture-workflow` instead of inventing an intermediate data layer. - -## Purpose - -Find the concrete failure point in a SwiftASB integration and explain it in terms the app maintainer can act on. - -The job is not just to say that "Codex failed." A useful diagnosis identifies which boundary failed: package dependency wiring, Codex CLI discovery, app-server process startup, initialization, app-wide library or inventory refresh, worktree grouping/filtering, selected Git status, project identity or thread-source mapping, thread creation, stored-thread archive state, turn lifecycle, code-review start, shell-command execution, interactive request routing, filesystem/config/extension/MCP/workspace reads, feature policy, feature-operation events, model capability reads, MCP install/status/resource reads, hook diagnostics, diagnostic stream handling, local history reads, or live-test isolation. - -## When To Use - -- Use this skill when a SwiftASB-backed app, CLI, helper service, package, or test harness fails. -- Use this skill when logs mention `CodexAppServerError`, app-server transport failures, protocol failures, same-thread turn rejection, missing Codex CLI, inventory refresh problems, MCP install/status/resource issues, review-start failures, shell-command feature gates, approval or elicitation problems, filesystem/config/extension/workspace read failures, selected Git status failures, feature-category disabled errors, feature-operation event confusion, project identity or thread-source mismatches, config warnings, deprecation notices, remote-control status changes, or history paging failures. -- Use this skill before changing SwiftASB integration code when the failure boundary is still unclear. -- Use this skill when deciding whether a failing check should be a normal unit test, an opt-in live Codex probe, or an app-level integration test. - -## Source Check - -Verify current SwiftASB docs and public API before naming exact symbols: - -- [SwiftASB GitHub repository](https://github.com/gaelic-ghost/SwiftASB) -- `README.md` -- `Sources/SwiftASB/SwiftASB.docc/GettingStartedWithSwiftASB.md` -- `Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md` -- `Sources/SwiftASB/SwiftASB.docc/ReadingDiagnosticsAndHistory.md` -- `Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexInventory.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexMCP.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexFS.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexConfig.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md` -- `Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md` -- `Sources/SwiftASB/SwiftASB.docc/ThreadManagement.md` -- `Sources/SwiftASB/Public/CodexAppServer+Library.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Inventory.swift` -- `Sources/SwiftASB/Public/CodexAppServer+LoadedThreads.swift` -- `Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift` -- `Sources/SwiftASB/Public/CodexAppServer+MCP.swift` -- `Sources/SwiftASB/Public/CodexMCP.swift` -- `Sources/SwiftASB/Public/CodexFS.swift` -- `Sources/SwiftASB/Public/CodexConfig.swift` -- `Sources/SwiftASB/Public/CodexWorkspace.swift` -- `Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift` -- `Sources/SwiftASB/Public/CodexAppServer.swift` -- `Sources/SwiftASB/Public/CodexDiagnostics.swift` -- `Sources/SwiftASB/Public/CodexErrors.swift` -- `Sources/SwiftASB/Public/CodexReviewHandle.swift` - -The current Codex app-server API includes lifecycle operations such as `thread/start`, `thread/resume`, `thread/fork`, `thread/archive`, `thread/unarchive`, `review/start`, `thread/shellCommand`, `turn/start`, `turn/steer`, `turn/interrupt`, filesystem reads and watches, config reads, extension inventory, MCP resource reads, `command/exec`, `model/list`, `modelProvider/capabilities/read`, `mcpServerStatus/list`, and `hooks/list`, plus notifications for thread status, command output, MCP startup status, config warnings, deprecation notices, remote-control status, hook activity, filesystem watch activity, and skills/plugin state. Use the official [Codex app-server docs](https://developers.openai.com/codex/app-server#api-overview) when the diagnosis depends on upstream app-server behavior rather than SwiftASB's public wrapper. - -## Diagnostic Workflow - -1. Capture the exact symptom: - - thrown error text - - app logs - - failing test name - - user-visible behavior - - current branch and package version -2. Classify the boundary: - - dependency wiring - - Codex CLI discovery - - app-server process startup - - initialization - - app-wide library, inventory, or snapshots - - filesystem, config, extension, MCP, workspace-permission, worktree, selected-Git-status, project-identity, or thread-source reads - - feature policy or feature-operation event handling - - thread lifecycle - - stored-thread archive or unarchive - - review start or shell-command execution - - turn lifecycle - - approval or elicitation response handling - - diagnostics stream - - app-wide model, MCP, or hook snapshots - - local history or remote turn paging - - test isolation -3. Verify the smallest source-of-truth fact that can confirm or reject the classification. -4. Recommend the narrowest fix and the exact validation command. -5. If the failure is from active SwiftASB development, separate "consumer app bug" from "SwiftASB package issue" before editing either repo. - -## Boundary Checks - -### Dependency Wiring - -Check that the package dependency is real and remote-fetchable: - -```swift -.package(url: "https://github.com/gaelic-ghost/SwiftASB", from: "1.8.0") -``` - -Then check the target that talks to Codex depends on: - -```swift -.product(name: "SwiftASB", package: "SwiftASB") -``` - -Do not commit machine-local package paths such as `/Users/...`, `~/...`, or `../SwiftASB` into public projects. - -### Codex CLI Discovery - -SwiftASB expects a local Codex CLI runtime. A diagnosis should tell the maintainer which executable was attempted and whether SwiftASB reported it as supported. - -For SwiftASB `v1.8.0`, treat Codex CLI `0.142.x` as the preferred reviewed schema family. Compatible Codex CLI `0.141.x` installs remain in the prior-minor reviewed window; `0.140.x` is outside the default support window. - -For normal clients, `CodexAppServer.start(_:)` returns `StartupSession.cliExecutableDiagnostics` after launching, validating the selected Codex CLI against the reviewed support window, and initializing. Use `CodexAppServer.cliExecutableDiagnostics()` after lower-level `start()` when a UI, CLI, or test intentionally needs to show executable facts before deciding whether to initialize. - -- resolved executable path -- version string -- support-window compatibility -- likely runtime setup issue - -If the app requires a fixed binary, inspect whether it passes `CodexAppServer.Configuration.codexExecutableURL`. - -### App-Server Startup And Initialization - -The expected order for most clients is: - -1. create `CodexAppServer` -2. call `start(_:)` with client metadata -3. inspect `StartupSession.cliExecutableDiagnostics` when the UI needs selected-CLI facts -4. create, resume, or fork a thread - -When diagnosing SwiftASB `v1.8.0` or newer, first check whether the thrown error is `CodexAppServerStartupError`. `codexCLINotFound` points at executable discovery, `incompatibleCodexCLI` and `unknownCodexCLIVersion` point at reviewed-support-window validation, `launchFailed` points at process startup, and `initializeFailed` points at protocol initialization or malformed client metadata. - -Use the lower-level sequence only when the app intentionally owns custom diagnostics or compatibility decisions: - -1. create `CodexAppServer` -2. call `start()` -3. inspect `cliExecutableDiagnostics()` -4. call `initialize(_:)` once with client metadata - -If lower-level initialization fails, separate process startup from protocol initialization. Startup failures are usually executable, environment, sandbox, or process problems. Initialization failures are usually protocol, compatibility, or malformed client metadata problems. - -### Thread Lifecycle - -Use `CodexAppServer` for app-wide stored-thread operations and thread creation. Use `CodexThread` for conversation-scoped actions. - -Check whether the app is: - -- starting an ephemeral thread when it expects stored history -- resuming or forking the wrong thread id -- using the wrong current working directory -- expecting remote turn paging before history has materialized -- treating thread status notifications as terminal turn completion -- mixing thread goals, plan/agenda state, naming, archive/unarchive, metadata updates, compaction, or rollback into a UI surface that no longer owns the selected `CodexThread` - -### App-Wide Library - -Use `CodexAppServer.makeLibrary(configuration:)` when a UI or package needs stored-thread lists before choosing a thread. - -If a library surface does not update, check whether the app is: - -- expecting repository-root grouping when `Library.GroupedBy.cwd` matches exact app-server `cwd` metadata -- expecting worktree groups to be the same as visible grouping, instead of using the library's stable worktree groups and repository/worktree filters -- expecting `selectedGitStatus` to exist when `gitObservability` is disabled, no thread is selected, or the selected worktree has no usable cwd -- copying library arrays into a second state store instead of observing the library companion directly -- relying on app-wide snapshots before `refreshAppSnapshots()` has loaded model, MCP, and hook data -- grouping project rows by old ad hoc Git fields instead of `CodexWorkspace.ProjectInfo` -- drawing source badges from guessed client labels instead of `CodexAppServer.ThreadSource` -- ignoring app-server app/skill/MCP status notifications that now trigger snapshot refreshes -- treating `selectedThreadID` as stored Codex metadata instead of library-local UI selection state -- hiding reconciliation or snapshot errors from the user-facing diagnostics view -- hiding `latestGitStatusErrorDescription` or treating selected Git status refresh as direct app-process filesystem access instead of sandboxed app-server `command/exec` - -### Filesystem, Config, Inventory, Extensions, MCP, And Workspace Facts - -Use app-server-owned fact surfaces when a sandboxed app, helper, or package should not inspect machine state directly: - -- `CodexAppServer.fs` for metadata, directory entries, file bytes, watches, and bounded fuzzy file discovery -- `CodexFS.FileDiscoveryQD` when file-picker or search intent should be repeatable state -- `CodexFS.FileDiscoveryHit.matchKind`, `matchedFileNameRanges`, `matchedRelativePathRanges`, and `rankingReasons` when fuzzy search UI ranking or highlighting looks wrong -- `CodexAppServer.config` for effective config and requirements reads -- `CodexAppServer.makeInventory(configuration:)` for routine model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes -- `CodexAppServer.extensions` for advanced extension pagination, plugin-detail inspection, and already-configured marketplace upgrades -- `CodexAppServer.extensions.upgradeMarketplace(_:)` for upgrading an already-configured plugin marketplace when `extensionMaintenance` is enabled -- `CodexAppServer.mcp.install(_:)` for MCP installs that write Codex config and refresh MCP status -- `CodexAppServer.mcp.statusSnapshot()` and `CodexAppServer.mcp.readResource(...)` for full MCP details and app-wide or thread-scoped MCP resource contents -- `SwiftASBFeaturePolicy` for feature-category defaults and host app authority -- `CodexAppServer.featureOperationEvents()` for human-readable SwiftASB-owned mutation records -- `CodexWorkspace` values on requests and thread sessions for active permission profile, cwd, worktree snapshots, project identity, Git repository facts, selected Git status snapshots, and filesystem/network permissions - -If one of these surfaces fails, check whether the app passed the right current directory, expected direct disk semantics from an app-server read, asked for unpromoted mutation behavior, used `extensions` where `Inventory` is the better routine UI surface, disabled the relevant feature category, or hid app-server permission/profile facts behind a local fallback. - -### Feature Policy And Operation Events - -SwiftASB feature policy is separate from Codex approval requests. It decides whether SwiftASB-owned convenience features such as Git observability or extension maintenance are eligible to run; it does not answer a turn's approval prompt. - -If a feature operation fails, check: - -- whether `CodexAppServer.Configuration.featurePolicy` or `CodexAppServer.Library.Configuration.featurePolicy` disables the category -- whether the failure is from SwiftASB's feature gate or from the underlying app-server method -- whether `featureOperationEvents()` emitted a started, succeeded, failed, cancelled, or skipped event -- whether the event names affected paths, commands, app-server method, intent kind, rollback availability, and diagnostic text - -Routine read-only refreshes should usually be quiet. Missing feature-operation events for Git status reads or extension inventory is expected unless a mutation or maintenance action ran. - -### Turn Lifecycle - -Use `CodexThread.startTextTurn(...)` to create a normal `CodexTurnHandle`. Use `CodexThread.startPlanningTurn(...)` when the user chose plan mode; it sets app-server collaboration mode instead of sending slash-command text through the prompt. Use that handle for active-turn events, steering, interruption, interactive responses, and completion handoff. - -If a turn does not behave as expected, check: - -- whether another turn is already active on the same thread -- whether the app is consuming `turn.events` -- whether terminal completion is being detected -- whether `complete()` is called only after terminal state when a sealed local snapshot is needed -- whether cancellation uses `interrupt()` rather than dropping the handle silently -- whether a planning control used `startPlanningTurn(...)` or `TurnCollaborationMode.plan(...)` instead of prompt text -- whether custom switches over `CodexTurnItem.Kind` handle `.sleep` instead of treating Codex CLI `0.142.x` sleep items as unknown failures - -For plan and goal UI, use `CodexThread.makeAgenda()` to read the current goal, accepted plan, proposed plan deltas, and summary titles. If agenda state looks stale, check that the UI observes the `Agenda` object itself, that `makeAgenda()` succeeded in reading the initial goal, and that the app is not copying plan arrays into disconnected state. Goal mutations should go through `Agenda.setGoal(...)`, `pauseGoal()`, `resumeGoal()`, or `clearGoal()` when the agenda owns the view state. - -SwiftASB rejects overlapping turns on the same thread with `CodexAppServerError.invalidState` because the live app-server does not expose a reliable independent lifecycle for same-thread overlap. - -### Review Starts And Shell Commands - -Use `CodexThread.startReview(against:placement:)` for app-server reviews. Check the subject and placement first: inline reviews run on the current thread, while detached reviews return a review-thread id through `CodexReviewHandle`. - -Use `CodexThread.sendShellCommand(_:)` only for explicit user-level shell execution. It wraps app-server `thread/shellCommand`, preserves shell syntax, and does not inherit the thread sandbox policy. If it fails, check whether `SwiftASBFeatureCategory.ID.shellCommandExecution` is enabled before debugging transport behavior. - -### Approvals And Elicitation - -Approval and elicitation requests are not diagnostics. They are server-originated requests that need typed responses. - -Use the same owner that received the request: - -- `CodexTurnHandle.respond(to:with:)` for active-turn requests -- `CodexThread.respond(to:with:)` for thread-scoped requests - -If a request response fails, check that the response is sent through the matching thread or turn owner and that the app is not trying to answer a passive diagnostic event. - -### Diagnostics Stream - -`CodexAppServer.diagnosticEvents()` reports passive runtime diagnostics such as: - -- `warning` -- `guardianWarning` -- `modelRerouted` -- `modelVerification` -- `configWarning` -- `deprecationNotice` -- `mcpServerStatusChanged` -- `remoteControlStatusChanged` - -Diagnostics explain what the runtime is warning about. They are not approval prompts and do not need responses. - -### Inventory, Models, MCP Status, And Hooks - -Use app-wide snapshots for settings, inspectors, and runtime health: - -- `CodexAppServer.makeInventory(configuration:)` -- `CodexAppServer.listModels(...)` -- `CodexAppServer.readModelCapabilities()` -- `CodexAppServer.mcp.statusSnapshot()` -- `CodexAppServer.mcp.readResource(...)` -- `CodexAppServer.listHooks(...)` -- `CodexAppServer.Library.refreshAppSnapshots()` - -If a model, MCP, hook, app, skill, plugin, or collaboration-mode issue appears in the UI, inspect whether the app is showing Inventory state, model feature gates, configured server status, auth state, tools/resources metadata, hook diagnostics, or startup notifications. Do not infer runtime health only from a failed turn unless the app-wide status surface has also been checked. - -### History And Paging - -Distinguish local history helpers from direct app-server paging. - -Use recent companions and local history helpers for UI history: - -- `CodexThread.makeRecentTurns(...)` -- `CodexThread.makeRecentFiles(...)` -- `CodexThread.makeRecentCommands(...)` -- `CodexThread.HistoryWindowQD` -- `CodexThread.RecentFilesQD` -- `CodexThread.RecentCommandsQD` -- `CodexThread.readRecentTurnHistoryWindow(limit:)` -- `CodexThread.windowAroundTurn(...)` -- `CodexThread.windowAroundItem(...)` - -Use `CodexAppServer.listThreadTurns(...)` when the app specifically needs direct app-server paging and is prepared to surface app-server failures. - -Recent companions may start from an empty local view for ephemeral or not-yet-materialized history while still listening to live events. That is different from a lower-level remote paging failure. - -### Test Isolation - -Keep normal package tests deterministic. Use live Codex probes only when the purpose is runtime compatibility or real app-server behavior. - -Live probes should be: - -- opt-in by environment flag -- run in a temporary workspace -- bounded by hard timeouts -- serial rather than parallel with other SwiftPM or Xcode test runs -- written so failure text names the exact runtime boundary - -## Output Shape - -Return: - -1. `Most likely boundary`: the narrow failure boundary. -2. `Evidence`: the log, code path, command, or API behavior that supports it. -3. `What it means`: plain-language impact for the app or package. -4. `Fix`: one narrow repair path. -5. `Validation`: exact command or manual check. -6. `Escalate to SwiftASB?`: yes/no, with the reason. - -## Guardrails - -- Do not call every thrown error a SwiftASB bug. -- Do not change the consumer app and SwiftASB package in the same pass unless the evidence proves both need edits. -- Do not run live Codex probes as default unit tests. -- Do not hide exact operation names inside vague messages like "failed" or "invalid." -- Do not answer approval or elicitation requests as if they were diagnostics. -- Do not use raw generated wire types as the consumer-facing repair path unless the user is intentionally debugging protocol internals. diff --git a/plugins/swiftasb-skills/skills/explain-swiftasb/SKILL.md b/plugins/swiftasb-skills/skills/explain-swiftasb/SKILL.md deleted file mode 100644 index ee2ec1d3c..000000000 --- a/plugins/swiftasb-skills/skills/explain-swiftasb/SKILL.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -name: explain-swiftasb -description: Explain SwiftASB in user-facing terms, including what it does, what it does not do, adoption tradeoffs, licensing, and when it is or is not the right foundation for a Swift app or package. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with SwiftASB v1.8.0 or newer, Swift 6, SwiftPM, SwiftUI, AppKit, and local Codex app-server integrations. -metadata: - owner: gaelic-ghost - repo: socket - package: SwiftASB - category: swiftasb-explanation -allowed-tools: Read Bash(rg:*) Bash(git:*) ---- - -# Explain SwiftASB - -## SwiftData And SwiftUI Rule - -When a task combines SwiftData with SwiftUI, keep SwiftData directly coupled to SwiftUI through Apple's data-driven path: `modelContainer`, environment `modelContext`, `@Query`, SwiftData model objects, and bindings. Do not add repositories, stores, service layers, DTO mirrors, view-model caches, wrapper objects, or other abstraction layers between SwiftData and SwiftUI. If this skill is not the right owner for SwiftData-backed SwiftUI work, hand off to `apple-dev-skills:swiftui-app-architecture-workflow` instead of inventing an intermediate data layer. - -## Purpose - -Help a user understand whether [SwiftASB](https://github.com/gaelic-ghost/SwiftASB) is the right foundation for their Swift app, tool, or package before implementation starts. - -Start with the real job: SwiftASB lets Swift code drive the local Codex app-server through a Swift-native API. It owns the local Codex subprocess, typed request and response conversion, app-wide stored-thread library and inventory state, thread agenda state, stable worktree groups, repository/worktree filters, selected-worktree Git status, project identity and thread-source facts, app-server-owned worktree snapshots, app-server-routed filesystem/config/extension/MCP reads, workspace permission facts, SwiftASB feature policy, feature-operation events, plan-mode turn starts, thread goals, code-review and gated shell-command actions, thread, agenda, and turn handles, interactive request handling, diagnostics, local history reads, and SwiftUI-friendly observable companions. - -## When To Use - -- Use this skill when a user asks what SwiftASB is or whether they should build on it. -- Use this skill before planning a SwiftASB integration when the app shape or adoption tradeoffs are unclear. -- Use this skill when an agent needs to explain SwiftASB to a non-maintainer user in plain language. -- Use this skill when licensing, runtime dependency, or API-boundary concerns affect adoption. - -## Source Check - -Before giving exact API claims, inspect the current SwiftASB source of truth: - -- [SwiftASB GitHub repository](https://github.com/gaelic-ghost/SwiftASB) -- `README.md` -- `Sources/SwiftASB/SwiftASB.docc/` -- `Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md` -- `Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexInventory.md` -- `Sources/SwiftASB/SwiftASB.docc/CodexMCP.md` -- the public files under `Sources/SwiftASB/Public/` -- the latest release notes or tags - -As of SwiftASB `v1.8.0`, the supported public surface centers on: - -- `CodexAppServer`, the owner of the local Codex subprocess, one-call startup, stored-thread operations, app-wide library and inventory creation, diagnostics, feature-operation-event streams, and capability reads -- `CodexAppServer.start(_:)`, `CodexAppServer.StartupRequest`, `CodexAppServer.StartupSession`, `CodexAppServer.StartupCompatibilityPolicy`, and `CodexAppServerStartupError` for normal startup, reviewed support-window validation, initialization, selected-CLI diagnostics, and typed startup failure handling -- `CodexAppServer.Library`, the app-wide observable companion for stored-thread lists, cwd or repository grouping, stable worktree groups, repository/worktree filters, selected worktree or repository context, selected-worktree Git status, `CodexWorkspace.ProjectInfo` project identity, `CodexAppServer.ThreadSource` source badges, and model/MCP/hook snapshots -- `CodexAppServer.Inventory` from `makeInventory(configuration:)` for routine app-wide model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes -- `CodexAppServer.fs`, `CodexAppServer.config`, and `CodexAppServer.extensions` for app-server-owned filesystem reads, effective config reads, advanced extension pagination, plugin detail reads, and already-configured marketplace upgrades -- `CodexAppServer.mcp` and `CodexMCP` for MCP server installs, full status snapshots, and app-wide or thread-scoped MCP resource contents advertised by configured servers -- `SwiftASBFeaturePolicy`, `SwiftASBFeatureCategory`, `SwiftASBHostAccess`, and `SwiftASBFeatureOperationEvent` for app-wide feature-category defaults, host-access declarations, mutation visibility, and quiet read-only observability -- `CodexWorkspace` for app-server-owned cwd, worktree snapshots, project identity, Git repository facts, selected Git status snapshots, filesystem, network, and permission-profile facts on thread sessions and requests -- `CodexThread`, the handle for one Codex conversation thread, including text turns, plan-mode turns, thread goal reads and mutations, and stored-thread actions -- `CodexThread.makeAgenda()` and `CodexThread.Agenda` for current thread goal state, accepted plan snapshots, proposed plan deltas, and summary titles -- `CodexThread.startPlanningTurn(...)` and `CodexAppServer.TurnCollaborationMode.plan(...)` for explicit plan-mode UI controls without sending slash-command text through the prompt -- `CodexThread.startReview(against:placement:)` and `CodexReviewHandle` for app-server code reviews from a thread, with inline or detached placement -- `CodexThread.sendShellCommand(_:)` for explicit user-level shell access, gated by the disabled-by-default `shellCommandExecution` feature category -- `CodexTurnHandle`, the handle for one active turn -- `CodexTurnItem.Kind.sleep` for preserved upstream sleep turn item classification when clients switch over public turn item kinds -- query descriptors such as `CodexAppServer.ThreadListQD`, `CodexFS.FileDiscoveryQD`, `CodexThread.HistoryWindowQD`, `CodexThread.RecentFilesQD`, and `CodexThread.RecentCommandsQD` -- thread source filtering and labels through `CodexAppServer.ThreadListSourceKind` and `CodexAppServer.ThreadSource` -- observable companions such as `CodexThread.Dashboard`, `CodexThread.Agenda`, `CodexTurnHandle.Minimap`, `RecentTurns`, `RecentFiles`, and `RecentCommands` -- diagnostics such as config warnings, deprecation notices, MCP server status changes, and remote-control status changes -- thread management actions such as archive, unarchive, rename, metadata updates, compaction, and rollback - -Generated `CodexWire...` models are internal scaffolding, not the recommended app-facing API. - -## Explanation Workflow - -1. Identify what the user wants to build. -2. State SwiftASB's job in one plain paragraph. -3. Name the runtime dependency: a local Codex CLI/app-server must be available. -4. Explain the main public owners only after the practical job is clear. -5. Describe the adoption benefits: - - Swift-native values instead of raw JSON-RPC payloads - - async streams for live thread and turn events - - typed approval and elicitation responses - - observable companions for app-wide libraries, app-wide inventories, SwiftUI inspectors, agenda panels, rails, and progress views - - app-server-routed filesystem, config, extension, MCP, workspace, worktree, project identity, thread source, and permission facts for sandboxed clients - - UI-ready fuzzy file-discovery match metadata, highlight ranges, and ranking reasons - - repeatable query descriptors for thread lists, file discovery, history windows, recent files, and recent commands - - local history helpers for recent turns, files, and commands - - explicit plan and goal surfaces for planning controls, goal editors, accepted plan display, and proposed plan previews - - explicit code-review and shell-command surfaces when the host app chooses to expose those actions -6. Describe the adoption costs: - - the app depends on a local Codex runtime - - compatibility follows SwiftASB's reviewed Codex CLI support window; for SwiftASB `v1.8.0`, prefer Codex CLI `0.142.x` and treat compatible `0.141.x` installs as the reviewed prior-minor window - - SwiftASB-owned mutation helpers are feature-policy gated and should produce operation events instead of surprising silent writes - - `sendShellCommand(_:)` is high-impact user-level shell execution and must stay an explicit opt-in app feature - - same-thread overlapping turns are rejected client-side - - generated wire features are not all public API - - users must understand SwiftASB's package license before adoption -7. Give a clear fit recommendation. - -## Fit Guidance - -SwiftASB is a good fit when the user needs a Swift app or package to: - -- start or control local Codex work -- show live command, file-edit, MCP, hook, approval, diagnostic, library, agenda, or history state -- show workspace, worktree, selected Git status, project identity, thread source, filesystem, config, inventory, extension, model, MCP, hook, diagnostic, feature-operation, or permission facts from the app-server instead of reading local machine state directly -- upgrade already-configured plugin marketplaces through a typed extension-maintenance intent while surfacing the command result and operation event -- install MCP servers through SwiftASB-owned config writes, or inspect MCP status and resources from app UI -- show plan and goal state, start plan-mode turns, start app-server code reviews, or expose shell commands as deliberate user actions -- build SwiftUI or AppKit surfaces around Codex conversations -- keep raw app-server protocol models out of their own public API -- use typed Swift handles for threads, turns, approvals, elicitation, diagnostics, and recent history - -SwiftASB is probably not the right first choice when the user needs: - -- a hosted AI SDK unrelated to the local Codex app-server -- a server-side multi-user agent platform -- a cross-platform non-Apple UI toolkit as the primary target -- a stable public wrapper for every experimental Codex app-server feature -- an integration that cannot depend on a local Codex CLI runtime - -## Output Shape - -Answer in this order: - -1. `Recommendation`: one direct fit call. -2. `What SwiftASB would do here`: plain-language role. -3. `What the app would own`: UI, product behavior, persistence choices, and user policy. -4. `What SwiftASB would own`: app-server process, app-wide library and inventory state, agenda state, worktree groups and filters, selected Git status, project identity, thread source, filesystem/config/extension/MCP/workspace reads, feature policy, feature-operation events, typed review and shell-command entry points, typed thread and turn API, events, requests, diagnostics, query descriptors, and companions. -5. `Tradeoffs`: runtime, compatibility, same-thread turn policy, and licensing. -6. `Next integration step`: the next skill or repo action. - -## Handoffs - -- Use `swiftasb:choose-integration-shape` when the user wants to proceed but the app shape is not settled. -- Use `swiftasb:build-swiftui-app` when the chosen target is a SwiftUI app. -- Use `swiftasb:build-appkit-app` when the chosen target is an AppKit app. -- Use `swiftasb:build-swift-package` when the chosen target is a package library, command-line package, helper package, or test harness. -- Use `apple-dev-skills:explore-apple-swift-docs` before making Apple framework claims. -- Use Apple build or Xcode workflow skills when the task shifts from explanation to project execution. - -## Guardrails - -- Do not call SwiftASB a general AI SDK. -- Do not present generated wire models as the public consumer API. -- Do not promise support for app-server families that SwiftASB has not promoted into public API. -- Do not hide the local Codex runtime dependency. -- Do not flatten licensing into "open source" without explaining that SwiftASB's package license is documented in its own repository. diff --git a/scripts/repo-maintenance/lib/DocsCoordinator.fsx b/scripts/repo-maintenance/lib/DocsCoordinator.fsx index 901a8a1c9..57e9207aa 100644 --- a/scripts/repo-maintenance/lib/DocsCoordinator.fsx +++ b/scripts/repo-maintenance/lib/DocsCoordinator.fsx @@ -67,6 +67,10 @@ let private renderMarkdown report = lines.Add("") for document in report.Documents do lines.Add($"- `{document.Document}`: {document.Findings.Length} finding(s), {document.Fixes.Length} fix(es), changed `{document.Changed.ToString().ToLowerInvariant()}`") + for finding in document.Findings do + lines.Add($" - `{finding.Severity}` `{finding.Id}`: {finding.Message}") + for fix in document.Fixes do + lines.Add($" - `fix` `{fix.Id}`: {fix.Message}") lines.Add("") lines.Add("## Responsibility issues") lines.Add("") diff --git a/scripts/repo-maintenance/validations/50-socket.fsx b/scripts/repo-maintenance/validations/50-socket.fsx new file mode 100644 index 000000000..160e43788 --- /dev/null +++ b/scripts/repo-maintenance/validations/50-socket.fsx @@ -0,0 +1,69 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let fail message = raise (InvalidOperationException(message)) + +let readJson path = JsonDocument.Parse(File.ReadAllText(path)) +let relative path = Path.GetRelativePath(root, path) + +let marketplacePath = Path.Combine(root, ".agents", "plugins", "marketplace.json") +let marketplace = readJson marketplacePath +let entries = marketplace.RootElement.GetProperty("plugins").EnumerateArray() |> Seq.toList +let names = entries |> List.map (fun item -> item.GetProperty("name").GetString()) +let duplicateNames = names |> List.countBy id |> List.choose (fun (name, count) -> if count > 1 then Some name else None) +if not (List.isEmpty duplicateNames) then + let rendered = String.concat ", " duplicateNames + fail $"Socket marketplace has duplicate plugin names: {rendered}" + +let mutable sharedVersion: string option = None +for entry in entries do + let name = entry.GetProperty("name").GetString() + let source = entry.GetProperty("source") + if source.GetProperty("source").GetString() = "local" then + let sourcePath = source.GetProperty("path").GetString() + let pluginRoot = Path.GetFullPath(Path.Combine(root, sourcePath)) + if not (Directory.Exists(pluginRoot)) then fail $"Marketplace plugin {name} is missing at {sourcePath}." + let manifestPath = Path.Combine(pluginRoot, ".codex-plugin", "plugin.json") + if not (File.Exists(manifestPath)) then fail $"Marketplace plugin {name} has no .codex-plugin/plugin.json." + use manifest = readJson manifestPath + if manifest.RootElement.GetProperty("name").GetString() <> name then fail $"Marketplace and manifest names differ for {name}." + let version = manifest.RootElement.GetProperty("version").GetString() + match sharedVersion with + | None -> sharedVersion <- Some version + | Some expected when version <> expected -> fail $"Plugin {name} is version {version}; expected {expected}." + | _ -> () + +let claudePath = Path.Combine(root, ".claude-plugin", "marketplace.json") +let claude = readJson claudePath +let claudeNames = + claude.RootElement.GetProperty("plugins").EnumerateArray() + |> Seq.map (fun item -> item.GetProperty("name").GetString()) + |> Set.ofSeq +let unknownClaude = Set.difference claudeNames (Set.ofList names) +if not (Set.isEmpty unknownClaude) then + let rendered = String.concat ", " unknownClaude + fail $"Claude marketplace references plugins absent from Socket: {rendered}" + +let nestedTests = + Directory.GetFiles(root, "*", SearchOption.AllDirectories) + |> Array.filter (fun path -> + let rel = relative path + let parts = rel.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + not (parts |> Array.exists (fun part -> part = ".git" || part = ".venv" || part = ".codex")) + && parts.Length > 1 + && parts[0] <> "tests" + && (parts |> Array.exists (fun part -> part = "test" || part = "tests"))) +if not (Array.isEmpty nestedTests) then fail $"Tests must live only at Socket root; found {relative nestedTests[0]}." + +let repositorySkillRoot = Path.Combine(root, "plugins", "repository-skills") +let legacyRepositoryScripts = + Directory.GetFiles(repositorySkillRoot, "*", SearchOption.AllDirectories) + |> Array.filter (fun path -> path.EndsWith(".py") || path.EndsWith(".sh")) +if not (Array.isEmpty legacyRepositoryScripts) then fail $"Repository Skills contains a legacy script: {relative legacyRepositoryScripts[0]}." + +printfn "Socket marketplace integration, compatibility wiring, root-only tests, and repository-skills automation are valid." diff --git a/scripts/repo-maintenance/version-bump.fsx b/scripts/repo-maintenance/version-bump.fsx new file mode 100644 index 000000000..234ceae48 --- /dev/null +++ b/scripts/repo-maintenance/version-bump.fsx @@ -0,0 +1,46 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text +open System.Text.Json +open System.Text.RegularExpressions + +let repoRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..")) +let version = + match fsi.CommandLineArgs |> Array.skip 1 with + | [| value |] when Regex.IsMatch(value, "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$") -> value + | [| value |] -> failwith $"Release version must use SemVer syntax without a v prefix: {value}" + | _ -> failwith "Usage: version-bump.fsx X.Y.Z" + +let manifests = + Directory.GetFiles(Path.Combine(repoRoot, "plugins"), "plugin.json", SearchOption.AllDirectories) + |> Array.filter (fun path -> path.Contains($"{Path.DirectorySeparatorChar}.codex-plugin{Path.DirectorySeparatorChar}")) + |> Array.filter (fun path -> not (path.Contains($"{Path.DirectorySeparatorChar}SpeakSwiftlyServer{Path.DirectorySeparatorChar}"))) + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + +if Array.isEmpty manifests then failwith "No Socket-owned plugin manifests were found." + +let updates = + manifests + |> Array.map (fun path -> + use document = JsonDocument.Parse(File.ReadAllText(path)) + let current = document.RootElement.GetProperty("version").GetString() + let lines = File.ReadAllText(path).Replace("\r\n", "\n").Split('\n') + let updated = + lines + |> Array.map (fun line -> + if Regex.IsMatch(line, "^\\s*\"version\"\\s*:") then + Regex.Replace(line, "\"version\"\\s*:\\s*\"[^\"]+\"", $"\"version\": \"{version}\"") + else line) + |> String.concat "\n" + path, current, updated) + +for path, current, updated in updates do + if current <> version then + let temporary = path + $".{Guid.NewGuid():N}.tmp" + File.WriteAllText(temporary, updated, UTF8Encoding(false)) + File.Move(temporary, path, true) + printfn "Updated %s: %s -> %s" (Path.GetRelativePath(repoRoot, path)) current version + +printfn "Socket plugin versions are aligned at %s across %d manifests." version manifests.Length diff --git a/shared/project-docs/DocsCoordinator.fsx b/shared/project-docs/DocsCoordinator.fsx index 901a8a1c9..57e9207aa 100644 --- a/shared/project-docs/DocsCoordinator.fsx +++ b/shared/project-docs/DocsCoordinator.fsx @@ -67,6 +67,10 @@ let private renderMarkdown report = lines.Add("") for document in report.Documents do lines.Add($"- `{document.Document}`: {document.Findings.Length} finding(s), {document.Fixes.Length} fix(es), changed `{document.Changed.ToString().ToLowerInvariant()}`") + for finding in document.Findings do + lines.Add($" - `{finding.Severity}` `{finding.Id}`: {finding.Message}") + for fix in document.Fixes do + lines.Add($" - `fix` `{fix.Id}`: {fix.Message}") lines.Add("") lines.Add("## Responsibility issues") lines.Add("") diff --git a/tests/repository-maintenance-e2e.fsx b/tests/repository-maintenance-e2e.fsx index 46cad447d..f3a81c461 100644 --- a/tests/repository-maintenance-e2e.fsx +++ b/tests/repository-maintenance-e2e.fsx @@ -26,6 +26,29 @@ let run cwd executable arguments = let requireSuccess description result = if result.ExitCode <> 0 then failwith $"{description} failed: {result.Stderr}\n{result.Stdout}" +let rootRecipes = run socketRoot "just" [ "--summary" ] +requireSuccess "Socket Just recipe discovery" rootRecipes +let documentationRecipes = + rootRecipes.Stdout.Split([| ' '; '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries) + |> Array.filter (fun name -> name.StartsWith("docs-", StringComparison.Ordinal)) + |> Array.sort +if documentationRecipes <> [| "docs-apply"; "docs-check" |] then + let rendered = String.concat ", " documentationRecipes + failwith $"Expected exactly docs-apply and docs-check, found: {rendered}" + +let nestedTests = + Directory.GetFiles(socketRoot, "*", SearchOption.AllDirectories) + |> Array.map (fun path -> Path.GetRelativePath(socketRoot, path)) + |> Array.filter (fun path -> + let parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + not (parts |> Array.exists (fun part -> part = ".git" || part = ".venv" || part = ".codex")) + && parts.Length > 1 + && parts[0] <> "tests" + && (parts |> Array.exists (fun part -> part = "test" || part = "tests"))) +if not (Array.isEmpty nestedTests) then + let rendered = String.concat ", " nestedTests + failwith $"Tests must live only at the Socket root: {rendered}" + let snapshot () = Directory.GetFiles(testRoot, "*", SearchOption.AllDirectories) |> Array.filter (fun path -> not (path.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}"))) @@ -46,6 +69,15 @@ if first.Length <> second.Length || Array.exists2 (fun (leftPath, leftBytes) (ri failwith "Second full documentation apply was not byte-idempotent." run testRoot "just" [ "docs-check" ] |> requireSuccess "full documentation check" +let fixtureRecipes = run testRoot "just" [ "--summary" ] +requireSuccess "fixture Just recipe discovery" fixtureRecipes +let fixtureDocs = + fixtureRecipes.Stdout.Split([| ' '; '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries) + |> Array.filter (fun name -> name.StartsWith("docs-", StringComparison.Ordinal)) + |> Array.sort +if fixtureDocs <> [| "docs-apply"; "docs-check" |] then + let rendered = String.concat ", " fixtureDocs + failwith $"Installed repository exposed unexpected docs recipes: {rendered}" run testRoot "git" [ "add"; "-A" ] |> requireSuccess "stage generated repository" run testRoot "git" [ "-c"; "user.name=Socket Tests"; "-c"; "user.email=tests@example.invalid"; "commit"; "-qm"; "test fixture" ] |> requireSuccess "commit generated repository" run testRoot "just" [ "repo-validate" ] |> requireSuccess "managed repository validation" diff --git a/tests/test_audit_skill_surfaces.py b/tests/test_audit_skill_surfaces.py deleted file mode 100644 index de6ac8b80..000000000 --- a/tests/test_audit_skill_surfaces.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - - -MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "audit_skill_surfaces.py" -SPEC = importlib.util.spec_from_file_location("audit_skill_surfaces", MODULE_PATH) -assert SPEC and SPEC.loader -audit_skill_surfaces = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = audit_skill_surfaces -SPEC.loader.exec_module(audit_skill_surfaces) - - -def write(path: Path, contents: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - - -def make_repo(tmp_path: Path) -> Path: - write( - tmp_path / "plugins" / "swiftasb-skills" / "skills" / "build-swiftui-app" / "SKILL.md", - "\n".join( - [ - "# Build SwiftUI App", - "", - "As of SwiftASB `v1.6.0`, use current app-facing handles.", - "", - "Use `apple-dev-skills:explore-apple-swift-docs` for Apple framework docs.", - "", - ] - ), - ) - write( - tmp_path / "plugins" / "swiftasb-skills" / "skills" / "diagnose-integration" / "SKILL.md", - "\n".join( - [ - "# Diagnose Integration", - "", - "Run the repository's documented validation path.", - "", - ] - ), - ) - write( - tmp_path / "plugins" / "repository-skills" / "skills" / "maintain-project-readme" / "SKILL.md", - "\n".join( - [ - "# Maintain README", - "", - "When the user explicitly requests subagents, use bounded discovery.", - "", - ] - ), - ) - shared_reference = "# Shared\n\nSame text.\n" - write( - tmp_path - / "plugins" - / "apple-dev-skills" - / "skills" - / "xcode-build-run-workflow" - / "references" - / "snippets" - / "apple-core.md", - shared_reference, - ) - write( - tmp_path - / "plugins" - / "apple-dev-skills" - / "skills" - / "xcode-testing-workflow" - / "references" - / "snippets" - / "apple-core.md", - shared_reference, - ) - return tmp_path - - -def test_build_report_counts_skill_surfaces_and_hotspots(tmp_path: Path) -> None: - repo_root = make_repo(tmp_path) - - report = audit_skill_surfaces.build_report(repo_root, top=2) - - assert report.skill_count == 3 - assert report.reference_count == 2 - assert report.skill_lines_by_plugin["swiftasb-skills"] == 8 - assert [surface.relative_path for surface in report.largest_skills] == [ - "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md", - "plugins/repository-skills/skills/maintain-project-readme/SKILL.md", - ] - assert len(report.duplicate_references) == 1 - assert len(report.duplicate_references[0].paths) == 2 - assert any(hit.phrase == "When the user explicitly requests subagents" for hit in report.phrase_hits) - assert report.version_hits[0].path == "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md" - assert any( - missing.path == "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md" - and missing.expected == "swiftasb:explain-swiftasb" - for missing in report.missing_handoffs - ) - - -def test_json_report_is_serializable(tmp_path: Path) -> None: - report = audit_skill_surfaces.build_report(make_repo(tmp_path), top=1) - - payload = audit_skill_surfaces.report_to_json(report) - - assert payload["skill_count"] == 3 - assert json.loads(json.dumps(payload))["reference_count"] == 2 - assert payload["largest_skills"] == [ - { - "plugin": "swiftasb-skills", - "path": "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md", - "line_count": 5, - } - ] - - -def test_markdown_report_includes_primary_sections(tmp_path: Path) -> None: - report = audit_skill_surfaces.build_report(make_repo(tmp_path), top=1) - - markdown = audit_skill_surfaces.render_markdown(report) - - assert "# Socket Skill Surface Audit" in markdown - assert "## Exact Duplicate References" in markdown - assert "## Version-Sensitive Lines" in markdown - assert "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md:3" in markdown - - -def test_main_writes_markdown_report_to_output_path(tmp_path: Path) -> None: - repo_root = make_repo(tmp_path / "repo") - output_path = Path("docs/agents/skill-surface-audit.md") - - exit_code = audit_skill_surfaces.main( - [ - "--repo-root", - str(repo_root), - "--top", - "1", - "--output", - str(output_path), - ] - ) - - rendered = (repo_root / output_path).read_text(encoding="utf-8") - assert exit_code == 0 - assert rendered.startswith("# Socket Skill Surface Audit") - assert "## Missing Expected Handoffs" in rendered - - -def test_main_writes_json_report_to_output_path(tmp_path: Path) -> None: - repo_root = make_repo(tmp_path / "repo") - output_path = tmp_path / "audit.json" - - exit_code = audit_skill_surfaces.main( - [ - "--repo-root", - str(repo_root), - "--format", - "json", - "--output", - str(output_path), - ] - ) - - payload = json.loads(output_path.read_text(encoding="utf-8")) - assert exit_code == 0 - assert payload["skill_count"] == 3 - assert payload["reference_count"] == 2 - - -def test_swiftui_or_swiftdata_skills_include_direct_swiftdata_rule() -> None: - repo_root = Path(__file__).resolve().parents[1] - swiftui_or_swiftdata_paths = [ - repo_root / "plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/SKILL.md", - repo_root / "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md", - ] - - assert all(path.is_file() for path in swiftui_or_swiftdata_paths) - missing_rule = [ - path.relative_to(repo_root).as_posix() - for path in swiftui_or_swiftdata_paths - if "## SwiftData And SwiftUI Rule" not in path.read_text(encoding="utf-8") - ] - - assert missing_rule == [] - - -def test_apple_swiftdata_snippets_reject_weak_query_only_guidance() -> None: - repo_root = Path(__file__).resolve().parents[1] - stale_phrase = "Prefer `@Query` for view-driven SwiftData fetching" - apple_skill_files = sorted((repo_root / "plugins" / "apple-dev-skills").glob("**/*.md")) - stale_paths = [ - path.relative_to(repo_root).as_posix() - for path in apple_skill_files - if stale_phrase in path.read_text(encoding="utf-8") - ] - - assert stale_paths == [] diff --git a/tests/test_audit_xcode_plugin_compatibility.py b/tests/test_audit_xcode_plugin_compatibility.py deleted file mode 100644 index 097d5d6fd..000000000 --- a/tests/test_audit_xcode_plugin_compatibility.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - - -MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "audit_xcode_plugin_compatibility.py" -SPEC = importlib.util.spec_from_file_location("audit_xcode_plugin_compatibility", MODULE_PATH) -assert SPEC and SPEC.loader -audit = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = audit -SPEC.loader.exec_module(audit) - - -def write(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def make_repo(tmp_path: Path) -> Path: - marketplace = { - "plugins": [ - { - "name": "skills-only", - "source": {"source": "local", "path": "./plugins/skills-only"}, - "policy": {"installation": "AVAILABLE"}, - }, - { - "name": "mixed", - "source": {"source": "local", "path": "./plugins/mixed"}, - "policy": {"installation": "AVAILABLE"}, - }, - { - "name": "remote", - "source": {"source": "url", "url": "https://example.com/remote.git", "ref": "v1.0.0"}, - "policy": {"installation": "AVAILABLE"}, - }, - ] - } - write(tmp_path / ".agents/plugins/marketplace.json", json.dumps(marketplace)) - for name in ("skills-only", "mixed"): - write( - tmp_path / f"plugins/{name}/.codex-plugin/plugin.json", - json.dumps({"name": name, "skills": "./skills/", "mcpServers": "./.mcp.json"} if name == "mixed" else {"name": name, "skills": "./skills/"}), - ) - write(tmp_path / f"plugins/{name}/skills/example/SKILL.md", "---\nname: example\ndescription: Example.\n---\n") - write( - tmp_path / "plugins/mixed/.mcp.json", - json.dumps({"mcpServers": {"local": {"command": "uv", "cwd": "../../mcp"}}}), - ) - write(tmp_path / "plugins/mixed/hooks/hooks.json", "{}") - return tmp_path - - -def test_source_inventory_classifies_skill_only_mixed_and_remote(tmp_path: Path) -> None: - report = audit.build_report(make_repo(tmp_path)) - - by_name = {item.name: item for item in report} - assert by_name["skills-only"].xcode_internal_plugin.status == "likely" - assert by_name["mixed"].xcode_internal_plugin.status == "partial" - assert by_name["mixed"].mcp_servers == ("local",) - assert len(by_name["mixed"].mcp_risks) == 2 - assert by_name["remote"].xcode_internal_plugin.status == "unknown" - - -def test_markdown_report_names_three_xcode_targets(tmp_path: Path) -> None: - markdown = audit.render_markdown(audit.build_report(make_repo(tmp_path))) - - assert "# Socket Xcode Plug-in Compatibility Audit" in markdown - assert "Xcode internal" in markdown - assert "Xcode Codex" in markdown - assert "External agent" in markdown - assert "Runtime-proof queue" in markdown - - -def test_real_socket_marketplace_is_fully_accounted_for() -> None: - repo_root = Path(__file__).resolve().parents[1] - report = audit.build_report(repo_root) - marketplace = json.loads((repo_root / ".agents/plugins/marketplace.json").read_text(encoding="utf-8")) - - assert len(report) == len(marketplace["plugins"]) - assert len({item.name for item in report}) == len(report) - assert all(item.xcode_internal_plugin.status in {"likely", "partial", "blocked", "unknown"} for item in report) diff --git a/tests/test_check_acp_registry.py b/tests/test_check_acp_registry.py deleted file mode 100644 index 860130f8a..000000000 --- a/tests/test_check_acp_registry.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - - -MODULE_PATH = ( - Path(__file__).resolve().parents[1] - / "plugins" - / "agent-portability-skills" - / "skills" - / "operate-acp-agent-integration" - / "scripts" - / "check_acp_registry.py" -) -SPEC = importlib.util.spec_from_file_location("check_acp_registry", MODULE_PATH) -assert SPEC and SPEC.loader -registry_check = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(registry_check) - - -def test_find_agents_requires_exact_id_or_name() -> None: - payload = { - "agents": [ - {"id": "hermes-agent", "name": "Hermes Agent", "version": "0.18.2"} - ] - } - - assert registry_check.find_agents(payload, "hermes-agent") == payload["agents"] - assert registry_check.find_agents(payload, "HERMES AGENT") == payload["agents"] - assert registry_check.find_agents(payload, "hermes") == [] - - -def test_load_registry_accepts_a_valid_registry_document(tmp_path: Path) -> None: - registry_path = tmp_path / "registry.json" - expected = {"version": "1.0.0", "agents": []} - registry_path.write_text(json.dumps(expected), encoding="utf-8") - - assert registry_check.load_registry(registry_path.as_uri()) == expected - - -def test_main_reports_a_missing_agent_without_treating_it_as_an_error( - monkeypatch, - capsys, -) -> None: - monkeypatch.setattr( - registry_check, - "load_registry", - lambda _url: {"version": "1.0.0", "agents": []}, - ) - monkeypatch.setattr(sys, "argv", [str(MODULE_PATH), "hermes-agent"]) - - assert registry_check.main() == 1 - assert "does not currently contain" in capsys.readouterr().out diff --git a/tests/test_cleanup_legacy_socket_installs.py b/tests/test_cleanup_legacy_socket_installs.py deleted file mode 100644 index 8cd059f28..000000000 --- a/tests/test_cleanup_legacy_socket_installs.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - - -MODULE_PATH = ( - Path(__file__).resolve().parent.parent / "scripts" / "cleanup_legacy_socket_installs.py" -) -SPEC = importlib.util.spec_from_file_location("cleanup_legacy_socket_installs", MODULE_PATH) -assert SPEC and SPEC.loader -cleanup_legacy_socket_installs = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = cleanup_legacy_socket_installs -SPEC.loader.exec_module(cleanup_legacy_socket_installs) - - -def write_json(path: Path, value: object) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") - - -def write_plugin_manifest(plugin_root: Path, name: str) -> None: - write_json(plugin_root / ".codex-plugin" / "plugin.json", {"name": name}) - - -def test_plan_marketplace_cleanup_removes_only_known_socket_plugins(tmp_path: Path) -> None: - marketplace_path = tmp_path / ".agents" / "plugins" / "marketplace.json" - write_json( - marketplace_path, - { - "name": "personal", - "plugins": [ - { - "name": "apple-dev-skills", - "source": { - "source": "local", - "path": "/Users/example/socket/plugins/apple-dev-skills", - }, - }, - { - "name": "unrelated", - "source": { - "source": "local", - "path": "/Users/example/.codex/plugins/unrelated", - }, - }, - ], - }, - ) - - rewrite = cleanup_legacy_socket_installs.plan_marketplace_cleanup(marketplace_path) - - assert rewrite is not None - assert rewrite.removed_names == ("apple-dev-skills",) - assert rewrite.data is not None - assert rewrite.data["plugins"] == [ - { - "name": "unrelated", - "source": { - "source": "local", - "path": "/Users/example/.codex/plugins/unrelated", - }, - } - ] - - -def test_plan_marketplace_cleanup_deletes_socket_only_marketplace(tmp_path: Path) -> None: - marketplace_path = tmp_path / ".agents" / "plugins" / "marketplace.json" - write_json( - marketplace_path, - { - "name": "socket", - "plugins": [ - { - "name": "agent-engineering-skills", - "source": { - "source": "local", - "path": "/Users/example/socket/plugins/agent-engineering-skills", - }, - } - ], - }, - ) - - rewrite = cleanup_legacy_socket_installs.plan_marketplace_cleanup(marketplace_path) - - assert rewrite is not None - assert rewrite.data is None - assert rewrite.removed_names == ("agent-engineering-skills",) - - -def test_plan_plugin_dir_cleanup_skips_cache_and_unknown_payloads(tmp_path: Path) -> None: - codex_plugins_root = tmp_path / ".codex" / "plugins" - write_plugin_manifest(codex_plugins_root / "apple-dev-skills", "apple-dev-skills") - write_plugin_manifest(codex_plugins_root / "unrelated", "unrelated") - write_plugin_manifest( - codex_plugins_root / "cache" / "socket" / "python-skills" / "6.3.1", - "python-skills", - ) - - actions = cleanup_legacy_socket_installs.plan_plugin_dir_cleanup(codex_plugins_root) - - assert [action.target for action in actions] == [codex_plugins_root / "apple-dev-skills"] - - -def test_apply_backs_up_and_removes_legacy_directory(tmp_path: Path) -> None: - home = tmp_path - backup_root = home / ".codex" / "backups" / "test" - plugin_dir = home / ".codex" / "plugins" / "python-skills" - write_plugin_manifest(plugin_dir, "python-skills") - action = cleanup_legacy_socket_installs.PlannedAction( - kind="remove-directory", - target=plugin_dir, - description="remove test plugin", - ) - - cleanup_legacy_socket_installs.apply_action(action, home=home, backup_root=backup_root) - - assert not plugin_dir.exists() - assert ( - backup_root - / ".codex" - / "plugins" - / "python-skills" - / ".codex-plugin" - / "plugin.json" - ).is_file() - - -def test_stale_config_plugin_tables_reports_non_socket_marketplaces(tmp_path: Path) -> None: - config_path = tmp_path / ".codex" / "config.toml" - config_path.parent.mkdir(parents=True) - config_path.write_text( - "\n".join( - [ - '[plugins."apple-dev-skills@socket"]', - "enabled = true", - '[plugins."apple-dev-skills@apple-dev-skills"]', - "enabled = true", - '[plugins."apple-dev-skills@local-repo"]', - "enabled = true", - '[plugins."unrelated@local-repo"]', - "enabled = true", - ] - ) - + "\n", - encoding="utf-8", - ) - - stale_tables = cleanup_legacy_socket_installs.stale_config_plugin_tables(config_path) - - assert stale_tables == ["apple-dev-skills@local-repo"] diff --git a/tests/test_cybersecurity_skill_contracts.py b/tests/test_cybersecurity_skill_contracts.py deleted file mode 100644 index 461aba33e..000000000 --- a/tests/test_cybersecurity_skill_contracts.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent -SKILLS_ROOT = ROOT / "plugins" / "cybersecurity-skills" / "skills" - - -def skill_text(name: str) -> str: - return (SKILLS_ROOT / name / "SKILL.md").read_text(encoding="utf-8").lower() - - -def assert_contract(name: str, *required_phrases: str) -> None: - contents = skill_text(name) - missing = [phrase for phrase in required_phrases if phrase.lower() not in contents] - assert not missing, f"{name} is missing required contract phrases: {missing}" - - -def test_benign_lookalikes_do_not_become_binary_verdicts() -> None: - assert_contract( - "assess-and-explain-threat", - "binary safe/malicious verdict", - "contradicting evidence", - "state confidence separately", - ) - - -def test_remote_reputation_requires_egress_approval() -> None: - assert_contract( - "check-artifact-reputation", - "explicit approval", - "upload", - "privacy", - ) - - -def test_isolation_rejects_linux_container_for_macos_payload() -> None: - assert_contract( - "select-analysis-isolation", - "use a macos vm or spare mac for macos payload behavior", - "do not substitute a linux container", - "no host share or forwarded port remains", - ) - - -def test_prepared_lab_removes_ambient_authority_and_verifies_teardown() -> None: - assert_contract( - "prepare-isolated-analysis-lab", - "default host folders/home sharing, clipboard, drag/drop, sockets, ssh agent", - "narrow evidence path", - "run a preflight without executing the target", - "confirm no workload or integration remains active", - ) - - -def test_dynamic_analysis_requires_prepared_lab_and_virtualization_limits() -> None: - assert_contract( - "perform-dynamic-malware-analysis", - "preflighted by `prepare-isolated-analysis-lab`", - "require the prepared-lab record", - "virtualization artifacts or anti-vm behavior", - ) - - -def test_authorized_testing_has_scope_and_stop_conditions() -> None: - assert_contract( - "scope-authorized-security-test", - "access to a target or a public address is not permission", - "establish stop conditions", - "update the scope record before expanding work", - ) - - -def test_vulnerability_validation_keeps_negative_results() -> None: - assert_contract( - "validate-vulnerability", - "smallest safe proof", - "not reachable", - "false positive", - "stop before destructive impact", - ) - - -def test_macos_assessment_separates_platform_controls() -> None: - assert_contract( - "assess-macos-threat", - "gatekeeper", - "notarization", - "xprotect", - "tcc", - "sip", - ) - - -def test_macos_guest_evidence_retains_virtualization_limits() -> None: - assert_contract( - "assess-macos-threat", - "physical host, a macos guest, or a reproduction guest", - "secure enclave", - "anti-vm", - ) - assert_contract( - "inspect-macos-runtime-activity", - "physical-host, affected-host, or macos-guest evidence", - "virtualization artifacts", - "physical-mac proof", - ) - - -def test_macos_recovery_preserves_evidence_and_verifies_outcome() -> None: - assert_contract( - "contain-and-recover-macos", - "preserve decisive evidence", - "prefer reversible", - "residual uncertainty", - "return-to-service decision", - ) - - -def test_incident_containment_records_operational_impact() -> None: - assert_contract( - "contain-security-incident", - "business impact", - "volatile evidence", - "rollback", - ) - - -def test_detection_content_requires_positive_and_negative_fixtures() -> None: - assert_contract( - "author-detection-content", - "test fixtures", - "benign negatives", - "false positives", - "telemetry contract", - ) - - -def test_non_specialist_advice_is_immediate_and_calm() -> None: - assert_contract( - "assess-and-explain-threat", - "plain-language", - "what to do now", - "avoid fear", - ) - - -def test_repository_scanning_routes_to_codex_security() -> None: - assert_contract( - "route-security-work", - "codex security", - "repository-wide", - "reverse-engineering-skills", - ) diff --git a/tests/test_deployment_build_safety_contracts.py b/tests/test_deployment_build_safety_contracts.py deleted file mode 100644 index 577024835..000000000 --- a/tests/test_deployment_build_safety_contracts.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent - - -def text(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8").lower() - - -def test_release_workflow_requires_target_and_session_preflight() -> None: - contents = text( - "plugins/cloud-deployment-skills/skills/" - "dockerized-service-release-deployment-workflow/SKILL.md" - ) - for phrase in ( - "deployment target's architecture", - "build every cloud linux archive and oci image in a clean github actions checkout", - "one build owns its docker client session until it exits", - "do not run docker status", - "inspect the real process rather than trusting the wrapper result", - ): - assert phrase in contents - - -def test_server_docker_workflow_enforces_github_only_cloud_builds() -> None: - contents = text("plugins/server-side-swift/skills/docker-workflow/SKILL.md") - for phrase in ( - "github actions exclusively builds linux images", - "image smoke test", - "release manifest", - "never rebuild during deployment", - ): - assert phrase in contents - - -def test_workspace_service_adapter_uses_native_local_and_github_cloud_boundaries() -> None: - contents = text("plugins/server-side-swift/skills/workspace-service-component/SKILL.md") - assert "homebrew services" in contents - assert "github actions" in contents - assert "do not add docker compose" in contents diff --git a/tests/test_macos_platform_security_forward_scenarios.py b/tests/test_macos_platform_security_forward_scenarios.py deleted file mode 100644 index 7b0953cc0..000000000 --- a/tests/test_macos_platform_security_forward_scenarios.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - - -ROOT = Path(__file__).resolve().parent.parent - - -def surface(plugin: str, skill: str, *references: str) -> str: - root = ROOT / "plugins" / plugin / "skills" / skill - paths = [root / "SKILL.md", *(root / "references" / name for name in references)] - return "\n".join(path.read_text(encoding="utf-8").lower() for path in paths) - - -@pytest.mark.parametrize( - ("plugin", "skill", "references", "phrases"), - [ - ( - "apple-dev-skills", - "macos-privacy-permissions-workflow", - ("responsible-code-and-attribution.md", "permission-class-matrix.md"), - ("helper or xpc", "axisprocesstrustedwithoptions", "responsible executable"), - ), - ( - "apple-dev-skills", - "macos-privacy-permissions-workflow", - ("permission-class-matrix.md",), - ("epdevelopertool.authorizationstatus", "requestaccess()", "do not promise ui"), - ), - ( - "apple-dev-skills", - "macos-privacy-permissions-workflow", - ("permission-class-matrix.md", "responsible-code-and-attribution.md"), - ("controller-target pair", "nsappleeventsusagedescription", "terminal"), - ), - ( - "apple-dev-skills", - "macos-sandbox-file-access-workflow", - ("security-scoped-bookmark-lifecycle.md",), - ("startaccessingsecurityscopedresource()", "stopaccessingsecurityscopedresource()", "if stale"), - ), - ( - "apple-dev-skills", - "diagnose-apple-entitlements", - ("five-state-entitlement-comparison.md", "artifact-and-nested-code-inspection.md"), - ("tracked source", "account authorization", "signed result", "runtime result", "helper"), - ), - ( - "apple-dev-skills", - "macos-sandbox-file-access-workflow", - ("sandbox-and-filesystem-control-map.md",), - ("posix and acl", "app sandbox", "tcc", "data vault/sip"), - ), - ( - "cybersecurity-skills", - "assess-macos-threat", - ("macos-security-layers.md",), - ("xprotect", "not automatically proof of prior execution", "research-macos-security-control"), - ), - ( - "reverse-engineering-skills", - "research-macos-security-control", - ("technical-note-contract.md", "source-and-evidence-hierarchy.md"), - ("private implementation evidence", "not public api", "exact macos version/build"), - ), - ], -) -def test_planned_forward_scenario_has_an_explicit_decision_path( - plugin: str, - skill: str, - references: tuple[str, ...], - phrases: tuple[str, ...], -) -> None: - contents = surface(plugin, skill, *references) - missing = [phrase for phrase in phrases if phrase not in contents] - assert not missing, f"{plugin}:{skill} is missing forward-test decisions: {missing}" diff --git a/tests/test_macos_virtualization_forward_scenarios.py b/tests/test_macos_virtualization_forward_scenarios.py deleted file mode 100644 index 94a6cd6a9..000000000 --- a/tests/test_macos_virtualization_forward_scenarios.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - - -ROOT = Path(__file__).resolve().parent.parent - - -def skill(plugin: str, name: str) -> str: - return (ROOT / "plugins" / plugin / "skills" / name / "SKILL.md").read_text(encoding="utf-8").lower() - - -@pytest.mark.parametrize( - ("plugin", "name", "phrases"), - [ - ( - "apple-dev-skills", - "choose-macos-virtualization-shape", - ("one portable linux application", "persistent oci-backed linux environment", "native macos security"), - ), - ( - "apple-dev-skills", - "virtualization-framework-workflow", - ("configuration construction", "headless", "add only required devices"), - ), - ( - "apple-dev-skills", - "macos-development-vm-workflow", - ("sip and relevant controls", "clean baseline", "restore-image support"), - ), - ( - "apple-dev-skills", - "virtualization-framework-workflow", - ("save/restore only in documented states", "configuration compatible", "not call saved machine state a disk snapshot"), - ), - ( - "cybersecurity-skills", - "prepare-isolated-analysis-lab", - ("offline static tooling", "default host folders/home sharing", "verify teardown"), - ), - ( - "cybersecurity-skills", - "prepare-isolated-analysis-lab", - ("monitored macos dynamic analysis", "baseline state or hashes", "virtualization artifacts"), - ), - ( - "apple-dev-skills", - "choose-macos-virtualization-shape", - ("do not call a linux container or linux vm evidence for native macos behavior", "gatekeeper", "tcc"), - ), - ( - "apple-dev-skills", - "choose-macos-virtualization-shape", - ("secure enclave", "recoveryos", "physical mac"), - ), - ], -) -def test_planned_forward_scenario_has_an_explicit_decision_path( - plugin: str, name: str, phrases: tuple[str, ...] -) -> None: - contents = skill(plugin, name) - missing = [phrase for phrase in phrases if phrase not in contents] - assert not missing, f"{plugin}:{name} is missing forward-test decisions: {missing}" diff --git a/tests/test_macos_virtualization_skill_contracts.py b/tests/test_macos_virtualization_skill_contracts.py deleted file mode 100644 index 626e8d0cf..000000000 --- a/tests/test_macos_virtualization_skill_contracts.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent - - -def text(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8").lower() - - -def test_portability_export_names_every_virtualization_owner() -> None: - export = text("scripts/export_hermes_skills.py") - grouping = text("skills.sh.json") - for skill in ( - "choose-macos-virtualization-shape", - "virtualization-framework-workflow", - "linux-development-vm-workflow", - "macos-development-vm-workflow", - "prepare-isolated-analysis-lab", - ): - assert skill in export - assert skill in grouping diff --git a/tests/test_model_lab_skill_contracts.py b/tests/test_model_lab_skill_contracts.py deleted file mode 100644 index dc5c2371a..000000000 --- a/tests/test_model_lab_skill_contracts.py +++ /dev/null @@ -1,270 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import os -import sys -import tomllib -from pathlib import Path -from types import ModuleType - -import pytest -import yaml - - -ROOT = Path(__file__).resolve().parent.parent -SKILLS_ROOT = ROOT / "plugins" / "model-lab-skills" / "skills" -EXPECTED_SKILLS = { - "choose-model-lab-workflow", - "design-model-experiment", - "prepare-language-model-dataset", - "fine-tune-language-model", - "evaluate-language-model", - "compare-model-checkpoints", - "choose-apple-model-runtime", - "research-model-representations", - "steer-language-model-behavior", - "ablate-refusal-representations", - "evaluate-jailbreak-resilience", - "evaluate-tool-calling-model", - "benchmark-model-runtime", -} - - -def load_module(name: str, path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location(name, path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -def skill_text(name: str) -> str: - return (SKILLS_ROOT / name / "SKILL.md").read_text(encoding="utf-8").lower() - - -def test_inventory_is_complete_and_has_no_scaffold_placeholders() -> None: - assert { - path.name for path in SKILLS_ROOT.iterdir() if path.is_dir() - } == EXPECTED_SKILLS - for name in EXPECTED_SKILLS: - contents = skill_text(name) - assert "[todo" not in contents - assert "structuring this skill" not in contents - assert (SKILLS_ROOT / name / "agents" / "openai.yaml").is_file() - - -def test_routing_preserves_neighbor_plugin_ownership() -> None: - contents = skill_text("choose-model-lab-workflow") - for owner in ( - "cloud-inference-skills", - "python-skills", - "apple-dev-skills", - "agent-engineering-skills", - "cybersecurity-skills", - ): - assert owner in contents - - -def test_apple_runtime_covers_current_source_lanes() -> None: - contents = skill_text("choose-apple-model-runtime") - reference = ( - SKILLS_ROOT - / "choose-apple-model-runtime" - / "references" - / "apple-model-tooling.md" - ).read_text(encoding="utf-8").lower() - for term in ( - "coreai-models", - "coreai-torch", - "coreai-optimization", - "coremltools", - "mlx swift", - "mlx lm", - "executorch core ml", - "experimental mlx", - "foundation models", - "lm studio", - "local model server", - "openai-compatible", - ): - assert term in contents - for term in ( - "structured output", - "loopback-only", - "macos/ios 27+", - "http://localhost:1234/v1", - "native `/api/v1`", - ): - assert term in reference - - -def test_adversarial_workflows_keep_authorization_and_regression_controls() -> None: - ablation = skill_text("ablate-refusal-representations") - jailbreak = skill_text("evaluate-jailbreak-resilience") - assert "authorized" in ablation - assert "capability" in ablation - assert "random norm-matched" in ablation - assert "explicit authorization" in jailbreak - assert "benign over-refusal" in jailbreak - assert "side-effect" in jailbreak - - -def test_experiment_manifest_template_is_valid() -> None: - module = load_module( - "model_lab_manifest_validator", - SKILLS_ROOT - / "design-model-experiment" - / "scripts" - / "validate_experiment_manifest.py", - ) - template = yaml.safe_load( - ( - SKILLS_ROOT - / "design-model-experiment" - / "assets" - / "experiment-manifest.yaml" - ).read_text(encoding="utf-8") - ) - errors = module.validate(template) - assert errors - assert any("template placeholder" in error for error in errors) - template["experiment"].update( - id="exp-001", - title="Adapter comparison", - hypothesis="The adapter improves the held-out score.", - decision="Choose whether to deploy the adapter.", - owner="model-team", - ) - template["provenance"]["code_revision"] = "abc123" - template["provenance"]["model"].update( - id="model", revision="rev", license="license" - ) - template["provenance"]["tokenizer"].update(id="tokenizer", revision="rev") - template["provenance"]["dataset"].update(id="dataset", revision="rev") - template["provenance"]["environment"].update(lockfile="uv.lock", hardware="M4 Pro") - template["method"].update( - controlled_variable="adapter", baseline="base", treatment="adapter" - ) - template["evaluation"]["primary_metrics"] = ["accuracy"] - template["evaluation"]["guardrail_metrics"] = ["regression"] - template["evaluation"]["failure_thresholds"] = {"accuracy": 0.5} - template["budget"]["smoke_run"] = "10 cases" - template["budget"]["full_run"] = "100 cases" - template["budget"]["stop_conditions"] = ["cost exceeds budget"] - template["artifacts"].update( - raw_results="raw", derived_results="derived", report="report.md" - ) - assert module.validate(template) == [] - template["budget"]["maximum_cost_usd"] = "free" - assert ( - "`budget.maximum_cost_usd` must be a finite non-negative number." - in module.validate(template) - ) - template["budget"]["maximum_cost_usd"] = 0 - template["evaluation"]["primary_metrics"] = ["replace-with-primary-metric"] - assert any( - "evaluation.primary_metrics" in error and "placeholder" in error - for error in module.validate(template) - ) - template["evaluation"]["primary_metrics"] = ["accuracy"] - template["evaluation"]["failure_thresholds"] = { - "replace-with-metric": "replace-with-threshold" - } - assert any( - "evaluation.failure_thresholds" in error and "placeholder" in error - for error in module.validate(template) - ) - - -def test_eval_comparison_reports_paired_regressions(tmp_path: Path) -> None: - module = load_module( - "model_lab_eval_comparison", - SKILLS_ROOT / "evaluate-language-model" / "scripts" / "compare_eval_runs.py", - ) - baseline_path = tmp_path / "baseline.jsonl" - treatment_path = tmp_path / "treatment.jsonl" - baseline_path.write_text( - '{"id":"a","score":0.5}\n{"id":"b","score":1.0}\n', encoding="utf-8" - ) - treatment_path.write_text( - '{"id":"a","score":1.0}\n{"id":"b","score":0.0}\n', encoding="utf-8" - ) - baseline = module.load_results(baseline_path) - treatment = module.load_results(treatment_path) - assert set(baseline) == {"a", "b"} - assert treatment["b"]["score"] == 0.0 - assert module.paired_ids(baseline, treatment, allow_partial=False) == ["a", "b"] - comparison = module.build_comparison(baseline, treatment) - assert comparison["mean_paired_delta"] == -0.25 - assert comparison["improved"] == 1 - assert comparison["regressed"] == 1 - assert comparison["partial_comparison"] is False - - -def test_eval_comparison_rejects_missing_or_overwritten_inputs(tmp_path: Path) -> None: - module = load_module( - "model_lab_eval_comparison_guards", - SKILLS_ROOT / "evaluate-language-model" / "scripts" / "compare_eval_runs.py", - ) - with pytest.raises(ValueError, match="identical case ids"): - module.paired_ids({"a": {"score": 1}}, {"b": {"score": 1}}, False) - baseline = tmp_path / "baseline.jsonl" - treatment = tmp_path / "treatment.jsonl" - with pytest.raises(ValueError, match="overwrite an input"): - module.validate_output_path(baseline, baseline, treatment) - baseline.write_text("baseline", encoding="utf-8") - hard_link = tmp_path / "hard-link.jsonl" - os.link(baseline, hard_link) - with pytest.raises(ValueError, match="overwrite an input"): - module.validate_output_path(hard_link, baseline, treatment) - with pytest.raises(ValueError, match="could not write output"): - module.write_output(tmp_path / "missing" / "output.json", "{}") - invalid = tmp_path / "invalid.jsonl" - invalid.write_text('{"id":"a","score":NaN}\n', encoding="utf-8") - with pytest.raises(ValueError, match="finite numeric"): - module.load_results(invalid) - - -def test_provenance_snapshot_uses_sorted_relative_paths(tmp_path: Path) -> None: - module = load_module( - "model_lab_provenance_snapshot", - SKILLS_ROOT - / "compare-model-checkpoints" - / "scripts" - / "snapshot_model_provenance.py", - ) - first = tmp_path / "z.bin" - second = tmp_path / "a.bin" - first.write_bytes(b"z") - second.write_bytes(b"a") - snapshot = module.build_snapshot(tmp_path, "model", "revision") - entries = snapshot["files"] - assert [entry["path"] for entry in entries] == ["a.bin", "z.bin"] - assert all(len(entry["sha256"]) == 64 for entry in entries) - assert snapshot["file_count"] == 2 - with pytest.raises(ValueError, match="outside the model artifact directory"): - module.validate_output_path(tmp_path, tmp_path / "snapshot.json") - with pytest.raises(ValueError, match="overwrite the model artifact"): - module.validate_output_path(first, first) - external_link = tmp_path.parent / f"{tmp_path.name}-model-hard-link" - os.link(first, external_link) - try: - with pytest.raises(ValueError, match="hard-link alias"): - module.validate_output_path(tmp_path, external_link) - finally: - external_link.unlink() - with pytest.raises(ValueError, match="could not write output"): - module.write_output(tmp_path / "missing" / "snapshot.json", "{}") - - -def test_plugin_manifest_matches_socket_version() -> None: - plugin = json.loads( - ( - ROOT / "plugins" / "model-lab-skills" / ".codex-plugin" / "plugin.json" - ).read_text(encoding="utf-8") - ) - with (ROOT / "pyproject.toml").open("rb") as stream: - root_project = tomllib.load(stream) - assert plugin["version"] == root_project["project"]["version"] diff --git a/tests/test_release_version.py b/tests/test_release_version.py deleted file mode 100644 index 2b8cf0f48..000000000 --- a/tests/test_release_version.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - -import pytest - - -MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "release_version.py" -SPEC = importlib.util.spec_from_file_location("release_version", MODULE_PATH) -assert SPEC and SPEC.loader -release_version = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = release_version -SPEC.loader.exec_module(release_version) - - -def write(path: Path, contents: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - - -def make_repo(tmp_path: Path) -> Path: - write(tmp_path / "pyproject.toml", '[project]\nname = "socket"\nversion = "1.2.3"\n') - write(tmp_path / "uv.lock", '[[package]]\nname = "socket"\nversion = "1.2.3"\n') - write( - tmp_path / "plugins/example/.codex-plugin/plugin.json", - json.dumps({"name": "example", "version": "1.2.3"}, indent=2) + "\n", - ) - write( - tmp_path / "plugins/example/pyproject.toml", - '[project]\nname = "example"\nversion = "1.2.3"\n', - ) - write( - tmp_path / "plugins/example/uv.lock", - '[[package]]\nname = "example"\nversion = "1.2.3"\n', - ) - write( - tmp_path / "plugins/SpeakSwiftlyServer/.codex-plugin/plugin.json", - json.dumps({"name": "speak-swiftly", "version": "8.0.0"}, indent=2) + "\n", - ) - return tmp_path - - -def test_discover_targets_excludes_external_and_build_artifacts(tmp_path: Path) -> None: - root = make_repo(tmp_path) - write( - root / "plugins/example/.build/checkouts/ignored/pyproject.toml", - '[project]\nname = "ignored"\nversion = "9.9.9"\n', - ) - - paths = [target.display_path for target in release_version.discover_targets(root)] - - assert "plugins/example/.build/checkouts/ignored/pyproject.toml" not in paths - assert "plugins/SpeakSwiftlyServer/.codex-plugin/plugin.json" not in paths - assert "plugins/example/.codex-plugin/plugin.json" in paths - - -def test_major_version_is_calculated_from_one_aligned_version_set(tmp_path: Path) -> None: - targets = release_version.discover_targets(make_repo(tmp_path)) - - assert release_version.determine_target_version(targets, "major", None) == "2.0.0" - - -def test_automatic_bump_rejects_split_versions(tmp_path: Path) -> None: - root = make_repo(tmp_path) - path = root / "plugins/example/.codex-plugin/plugin.json" - payload = json.loads(path.read_text(encoding="utf-8")) - payload["version"] = "2.0.0" - path.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(release_version.VersionToolError, match="already share one version"): - release_version.determine_target_version( - release_version.discover_targets(root), "major", None - ) - - -def test_apply_version_updates_manifests_and_adjacent_lockfiles(tmp_path: Path) -> None: - root = make_repo(tmp_path) - - changed, unchanged = release_version.apply_version( - root, release_version.discover_targets(root), "2.0.0" - ) - - assert unchanged == [] - assert "pyproject.toml" in changed - assert "uv.lock" in changed - assert "plugins/example/.codex-plugin/plugin.json" in changed - assert "plugins/example/uv.lock" in changed - assert 'version = "2.0.0"' in (root / "plugins/example/uv.lock").read_text() diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py deleted file mode 100644 index 143040eed..000000000 --- a/tests/test_release_workflow.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path - -import pytest - - -SCRIPTS = Path(__file__).resolve().parent.parent / "scripts" -sys.path.insert(0, str(SCRIPTS)) -MODULE_PATH = SCRIPTS / "release_workflow.py" -SPEC = importlib.util.spec_from_file_location("release_workflow", MODULE_PATH) -assert SPEC and SPEC.loader -release_workflow = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = release_workflow -SPEC.loader.exec_module(release_workflow) - - -def result(stdout: str = "", returncode: int = 0) -> object: - return type("Result", (), {"returncode": returncode, "stdout": stdout, "stderr": ""})() - - -def test_snapshot_phase_requires_checks_and_rejects_failures() -> None: - base = { - "number": 7, - "url": "https://github.test/pr/7", - "state": "OPEN", - "head_ref": "release/v2", - "head_sha": "abc", - "review_decision": "", - "comments": 0, - } - - assert release_workflow.PullRequestSnapshot(checks=(), **base).phase == "awaiting-github-state" - assert ( - release_workflow.PullRequestSnapshot(checks=(("validate", "fail"),), **base).phase - == "failed-checks" - ) - assert ( - release_workflow.PullRequestSnapshot(checks=(("validate", "pass"),), **base).phase - == "ready-to-advance" - ) - - -def test_snapshot_phase_requires_the_validate_job_and_an_open_pr() -> None: - base = { - "number": 7, - "url": "https://github.test/pr/7", - "head_ref": "release/v2", - "head_sha": "abc", - "review_decision": "", - "comments": 0, - } - - assert release_workflow.PullRequestSnapshot( - state="OPEN", checks=(("unrelated", "pass"),), **base - ).phase == "awaiting-required-checks" - assert release_workflow.PullRequestSnapshot( - state="CLOSED", checks=(("validate", "pass"),), **base - ).phase == "closed" - - -def test_prepare_and_advance_are_blocked_on_main(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(release_workflow, "current_branch", lambda: "main") - - with pytest.raises(release_workflow.ReleaseWorkflowError, match="feature worktree"): - release_workflow.ensure_feature_branch() - - -def test_find_main_worktree_uses_explicit_worktree_owner(monkeypatch: pytest.MonkeyPatch) -> None: - output = """worktree /workspace/socket -HEAD abc -branch refs/heads/main - -worktree /workspace/feature -HEAD def -branch refs/heads/release/v2 -""" - monkeypatch.setattr(release_workflow, "git", lambda *_args, **_kwargs: result(output)) - - assert release_workflow.find_main_worktree() == Path("/workspace/socket") - - -def test_branch_accounting_requires_one_status_per_unmerged_branch( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - release_workflow, - "git", - lambda *_args, **_kwargs: result("feature/a\nfeature/b\n"), - ) - - with pytest.raises(release_workflow.ReleaseWorkflowError, match="feature/b"): - release_workflow.branch_accounting(tmp_path, {"feature/a": "preserved"}) - - assert release_workflow.branch_accounting( - tmp_path, {"feature/a": "preserved", "feature/b": "in-progress"} - ) == {"feature/a": "preserved", "feature/b": "in-progress"} - - -def test_branch_accounting_rejects_blanket_or_unknown_status() -> None: - with pytest.raises(release_workflow.ReleaseWorkflowError, match="Invalid branch accounting"): - release_workflow.parse_accounting(["feature/a=allowed"]) - - -def test_prepare_version_must_be_the_next_patch_minor_or_major( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - release_workflow.release_version, - "determine_target_version", - lambda _targets, mode, _custom: { - "patch": "1.2.4", - "minor": "1.3.0", - "major": "2.0.0", - }[mode], - ) - - release_workflow.ensure_next_stable_version([], "2.0.0") - with pytest.raises(release_workflow.ReleaseWorkflowError, match="Choose one of"): - release_workflow.ensure_next_stable_version([], "3.0.0") - - -def test_prepare_rejects_an_already_published_version( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - release_workflow, - "git", - lambda args, **_kwargs: result("v9.35.0\n" if args[:2] == ["tag", "-l"] else ""), - ) - - with pytest.raises(release_workflow.ReleaseWorkflowError, match="already exists locally"): - release_workflow.ensure_unpublished("9.35.0") - - -def test_release_evidence_contains_only_prepublication_facts() -> None: - evidence = release_workflow.release_version.ReleaseEvidence( - commit="abc", - captured_at="2026-08-20T12:00:00Z", - marketplace_smoke={"status": "passed"}, - dependabot_alerts=(), - ) - - notes = release_workflow.append_release_evidence("# Notes\n", evidence, []) - - assert "temporary `CODEX_HOME`" in notes - assert "GitHub release object" not in notes - assert "marketplace upgrade" not in notes - - -def test_cli_exposes_one_release_lifecycle() -> None: - help_text = Path(MODULE_PATH).read_text(encoding="utf-8") - - assert 'for operation in ("prepare", "inspect")' in help_text - assert 'subparsers.add_parser("advance")' in help_text - assert "patch-refresh" not in help_text - assert "subtrees" not in help_text diff --git a/tests/test_repository_maintenance_workflow.py b/tests/test_repository_maintenance_workflow.py deleted file mode 100644 index 094918a34..000000000 --- a/tests/test_repository_maintenance_workflow.py +++ /dev/null @@ -1,612 +0,0 @@ -from __future__ import annotations - -import json -import importlib.util -import os -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "plugins/repository-skills/skills/maintain-project-repo/scripts/run_workflow.py" -DOCS_SCRIPT = ROOT / "plugins/repository-skills/skills/maintain-project-repo/scripts/maintain_project_docs.py" - -docs_spec = importlib.util.spec_from_file_location("maintain_project_repo_docs", DOCS_SCRIPT) -assert docs_spec is not None and docs_spec.loader is not None -docs_module = importlib.util.module_from_spec(docs_spec) -sys.modules["maintain_project_repo_docs"] = docs_module -docs_spec.loader.exec_module(docs_module) - - -class RepoMaintenanceToolkitWorkflowTests(unittest.TestCase): - def run_script(self, *args: str, env: dict | None = None) -> tuple[int, dict]: - command_env = dict(env or os.environ) - command_env.setdefault("UV_CACHE_DIR", str(Path(tempfile.gettempdir()) / "repository-skills-uv-cache")) - proc = subprocess.run( - [str(SCRIPT), *args], - cwd="/tmp", - env=command_env, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode, json.loads(proc.stdout) - - def test_report_only_lists_managed_files(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "report-only") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertEqual(payload["path_type"], "fallback") - self.assertIn("scripts/repo-maintenance/validate-all.sh", payload["managed_files"]) - self.assertIn(".github/workflows/validate-repo-maintenance.yml", payload["managed_files"]) - self.assertIn("scripts/repo-maintenance/config/profile.env", payload["managed_files"]) - self.assertEqual(payload["profile"], "generic") - self.assertEqual(payload["documentation_result"], "checked (no writes)") - self.assertEqual( - payload["documentation"]["document_order"], - ["readme", "contributing", "agents", "roadmap"], - ) - for filename in ("README.md", "CONTRIBUTING.md", "AGENTS.md", "ROADMAP.md"): - self.assertFalse(Path(tmpdir, filename).exists()) - - def test_xcode_workspace_profile_installs_workspace_validation_and_dispatches_components(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Product.xcworkspace").mkdir() - (root / "Product.xcodeproj").mkdir() - (root / "project.yml").write_text("name: Product\n", encoding="utf-8") - (root / "Apps/ProductApp").mkdir(parents=True) - (root / "Apps/ProductApp/target.yml").write_text("targets: {}\n", encoding="utf-8") - (root / "Services").mkdir() - package_root = root / "Packages/ProductCore" - package_root.mkdir(parents=True) - (package_root / "Package.swift").write_text("// package\n", encoding="utf-8") - component_validation = package_root / "scripts/repo-maintenance/validate-all.sh" - component_validation.parent.mkdir(parents=True) - component_validation.write_text("#!/usr/bin/env sh\nprintf '%s\\n' package-validated\n", encoding="utf-8") - (root / "AGENTS.md").write_text( - "# AGENTS.md\n\n- scripts/repo-maintenance/validate-all.sh\n- scripts/repo-maintenance/sync-shared.sh\n- scripts/repo-maintenance/release.sh\n", - encoding="utf-8", - ) - - code, payload = self.run_script( - "--repo-root", tmpdir, "--operation", "install", "--profile", "xcode-workspace" - ) - - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertIn("scripts/repo-maintenance/workspace/validate-components.sh", payload["managed_files"]) - self.assertNotIn("Scripts", [path.name for path in root.iterdir()]) - self.assertTrue(Path(tmpdir, "scripts/repo-maintenance/validations/40-xcode-workspace-layout.sh").is_file()) - profile_env = Path(tmpdir, "scripts/repo-maintenance/config/profile.env").read_text(encoding="utf-8") - self.assertIn('REPO_MAINTENANCE_PROFILE="xcode-workspace"', profile_env) - dispatcher = Path(tmpdir, "scripts/repo-maintenance/workspace/validate-components.sh").read_text( - encoding="utf-8" - ) - self.assertIn("scripts/repo-maintenance/validate-all.sh", dispatcher) - self.assertNotIn("Scripts/repo-maintenance", dispatcher) - - subprocess.run(["git", "init"], cwd=tmpdir, check=True, capture_output=True, text=True) - proc = subprocess.run( - ["sh", "scripts/repo-maintenance/validate-all.sh"], - cwd=tmpdir, - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout) - self.assertIn("Validated xcode-workspace composition", proc.stdout) - self.assertIn("package-validated", proc.stdout) - - def test_xcode_workspace_profile_normalizes_legacy_uppercase_toolkit_root(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Product.xcworkspace").mkdir() - (root / "Product.xcodeproj").mkdir() - (root / "project.yml").write_text("name: Product\n", encoding="utf-8") - (root / "Apps/ProductApp").mkdir(parents=True) - (root / "Apps/ProductApp/target.yml").write_text("targets: {}\n", encoding="utf-8") - (root / "Packages").mkdir() - (root / "Services").mkdir() - legacy_custom = root / "Scripts/repo-maintenance/custom.sh" - legacy_custom.parent.mkdir(parents=True) - legacy_custom.write_text("#!/usr/bin/env sh\n", encoding="utf-8") - - code, payload = self.run_script( - "--repo-root", tmpdir, "--operation", "install", "--profile", "xcode-workspace" - ) - - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertIn("legacy Scripts/repo-maintenance", "\n".join(payload["actions"])) - self.assertNotIn("Scripts", [path.name for path in root.iterdir()]) - self.assertTrue((root / "scripts/repo-maintenance/custom.sh").is_file()) - self.assertTrue((root / "scripts/repo-maintenance/validate-all.sh").is_file()) - - def test_xcode_workspace_profile_rejects_legacy_uppercase_toolkit_file(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "Product.xcworkspace").mkdir() - (root / "Product.xcodeproj").mkdir() - (root / "project.yml").write_text("name: Product\n", encoding="utf-8") - (root / "Apps/ProductApp").mkdir(parents=True) - (root / "Apps/ProductApp/target.yml").write_text("targets: {}\n", encoding="utf-8") - (root / "Packages").mkdir() - (root / "Services").mkdir() - (root / "Scripts").mkdir() - (root / "Scripts/repo-maintenance").write_text("not a directory\n", encoding="utf-8") - - code, payload = self.run_script( - "--repo-root", tmpdir, "--operation", "install", "--profile", "xcode-workspace" - ) - - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - self.assertIn("legacy path Scripts/repo-maintenance exists and is not a directory", payload["stderr"]) - - def test_xcode_workspace_profile_rejects_invalid_workspace_layout(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "AGENTS.md").write_text( - "# AGENTS.md\n\n- scripts/repo-maintenance/validate-all.sh\n- scripts/repo-maintenance/sync-shared.sh\n- scripts/repo-maintenance/release.sh\n", - encoding="utf-8", - ) - code, payload = self.run_script( - "--repo-root", tmpdir, "--operation", "install", "--profile", "xcode-workspace" - ) - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "blocked") - self.assertIn("requires a canonical Swift product workspace", payload["stderr"]) - self.assertIn("expected exactly one root .xcworkspace", payload["stderr"]) - - def test_generic_profile_uses_generic_macos_latest_workflow(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "install") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - workflow_text = Path(tmpdir, ".github/workflows/validate-repo-maintenance.yml").read_text(encoding="utf-8") - self.assertIn("runs-on: macos-latest", workflow_text) - self.assertIn("actions/checkout@v6.0.2", workflow_text) - self.assertNotIn("actions/checkout@v4", workflow_text) - self.assertNotIn("maxim-lobanov/setup-xcode@v1", workflow_text) - for filename in ("README.md", "CONTRIBUTING.md", "AGENTS.md", "ROADMAP.md"): - self.assertTrue(Path(tmpdir, filename).is_file()) - self.assertEqual( - payload["documentation_result"], - "canonical documents created or refreshed", - ) - - def test_refresh_recreates_missing_canonical_document(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, _payload = self.run_script("--repo-root", tmpdir, "--operation", "install") - self.assertEqual(code, 0) - roadmap = Path(tmpdir, "ROADMAP.md") - roadmap.unlink() - - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "refresh") - - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertTrue(roadmap.is_file()) - self.assertTrue( - any( - fix.get("action") == "create-roadmap-from-template" - for fix in payload["documentation"]["fixes_applied"] - ) - ) - - def test_documentation_error_fails_combined_operation(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - Path(tmpdir, "README.md").mkdir() - - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "install") - - self.assertEqual(code, 1) - self.assertEqual(payload["status"], "failed") - self.assertIn("documentation", payload) - self.assertTrue(payload["documentation"]["errors"]) - self.assertTrue(Path(tmpdir, "scripts/repo-maintenance/validate-all.sh").is_file()) - - def test_xcode_bootstrap_uses_integrated_repo_install(self) -> None: - bootstrap = ( - ROOT - / "plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run_workflow.py" - ).read_text(encoding="utf-8") - self.assertIn("runner = maintain_project_repo_runner()", bootstrap) - self.assertIn( - '[str(runner), "--repo-root", str(root), "--operation", "install", "--profile", "xcode-workspace"]', - bootstrap, - ) - self.assertNotIn("--skip-doc", bootstrap) - - def test_managed_workflows_avoid_node20_action_versions(self) -> None: - workflow_assets = [ - ROOT / "skills/maintain-project-repo/assets/github/repo-maintenance-workflows/validate-repo-maintenance.yml", - ROOT - / "skills/maintain-project-repo/assets/profiles/apple/github/repo-maintenance-workflows/validate-repo-maintenance.yml", - ] - for workflow_asset in workflow_assets: - with self.subTest(workflow=workflow_asset.name): - workflow_text = workflow_asset.read_text(encoding="utf-8") - self.assertIn("actions/checkout@v6.0.2", workflow_text) - self.assertNotIn("actions/checkout@v4", workflow_text) - self.assertNotIn("maxim-lobanov/setup-xcode@v1", workflow_text) - - def test_generic_profile_keeps_generic_pre_commit_hook(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "install") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertFalse(Path(tmpdir, ".swiftformat").exists()) - self.assertFalse(Path(tmpdir, ".swiftlint.yml").exists()) - hook_text = Path(tmpdir, "scripts/repo-maintenance/hooks/pre-commit.sample").read_text(encoding="utf-8") - self.assertNotIn("swiftformat --lint", hook_text) - self.assertIn('exec "$repo_root/scripts/repo-maintenance/validate-all.sh"', hook_text) - - def test_generated_validation_uses_repo_maintenance_self_dir(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "install") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - - Path(tmpdir, "AGENTS.md").write_text( - "# AGENTS.md\n\n" - "- scripts/repo-maintenance/validate-all.sh\n" - "- scripts/repo-maintenance/sync-shared.sh\n" - "- scripts/repo-maintenance/release.sh\n", - encoding="utf-8", - ) - subprocess.run(["git", "init"], cwd=tmpdir, check=True, capture_output=True, text=True) - - proc = subprocess.run( - ["sh", "scripts/repo-maintenance/validate-all.sh"], - cwd=tmpdir, - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout) - self.assertIn("Repo-maintenance validation completed successfully.", proc.stdout) - - def test_release_script_encodes_protected_main_standard_flow(self) -> None: - release_script = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/release.sh").read_text( - encoding="utf-8" - ) - self.assertIn("Standard release mode must run from a release branch or worktree", release_script) - self.assertIn("version-bump.sh", release_script) - self.assertIn("ensure_operation", release_script) - self.assertIn("--operation prepare|inspect|advance", release_script) - self.assertIn("Version bump commit for $RELEASE_TAG is already at HEAD", release_script) - self.assertIn("emit_continuation_packet", release_script) - self.assertIn("minimum_delay_minutes", release_script) - self.assertIn("reuse a live matching host-native continuation", release_script) - self.assertIn("inspect_pr_gate", release_script) - self.assertIn("--json name,bucket", release_script) - self.assertIn("REPO_MAINTENANCE_MIN_REQUIRED_CHECKS", release_script) - self.assertIn("gh pr checks exits 8 while pending", release_script) - self.assertIn('not-started|awaiting-branch-visibility', release_script) - self.assertIn("remote_branch_is_visible", release_script) - self.assertIn("remote_tag_is_visible", release_script) - self.assertIn("github_release_is_visible", release_script) - self.assertIn("push_release_branch", release_script) - self.assertIn("push_release_tag", release_script) - self.assertIn('rev-list -n 1 "$RELEASE_TAG"', release_script) - self.assertNotIn('gh pr checks "$pr_number" --watch', release_script) - self.assertNotIn('sleep "$poll_seconds"', release_script) - self.assertIn('select(.state == "COMMENTED")', release_script) - self.assertIn("valid concerns in code, or add out-of-scope concerns to ROADMAP.md", release_script) - self.assertIn('gh pr merge "$pr_number" --merge --delete-branch', release_script) - self.assertIn('pull --ff-only origin "$base_branch"', release_script) - self.assertNotIn("release tag `$RELEASE_TAG` was created locally before this PR", release_script) - standard_flow = release_script[release_script.index("run_standard_release()") :] - self.assertLess(standard_flow.index("inspect_pr_gate \"$pr_number\""), standard_flow.index("create_release_tag")) - self.assertLess(standard_flow.index("check_pr_comments \"$pr_number\""), standard_flow.index("create_release_tag")) - self.assertLess(standard_flow.index("fast_forward_base_branch"), standard_flow.index("create_release_tag")) - - def test_common_release_helpers_cover_delayed_github_state(self) -> None: - common_script = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh").read_text( - encoding="utf-8" - ) - self.assertIn("remote_branch_is_visible", common_script) - self.assertIn("remote_tag_is_visible", common_script) - self.assertIn("github_release_is_visible", common_script) - self.assertNotIn("github_wait_timeout", common_script) - self.assertNotIn("sleep", common_script) - - push_step = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/release/30-push-release.sh").read_text( - encoding="utf-8" - ) - self.assertIn('remote_branch_is_visible "$branch_name"', push_step) - self.assertIn('remote_tag_is_visible "$RELEASE_TAG"', push_step) - - release_step = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh").read_text( - encoding="utf-8" - ) - self.assertIn('github_release_is_visible "$RELEASE_TAG"', release_step) - - def test_release_helpers_preserve_prerelease_github_metadata(self) -> None: - common_script = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh").read_text( - encoding="utf-8" - ) - release_script = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/release.sh").read_text( - encoding="utf-8" - ) - release_step = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/release/40-github-release.sh").read_text( - encoding="utf-8" - ) - - self.assertIn("is_semver_prerelease_tag", common_script) - self.assertIn("expected_github_prerelease_value", common_script) - self.assertIn("github_release_create_prerelease_flag", common_script) - self.assertIn("verify_github_release_prerelease_metadata", common_script) - self.assertIn("--json isPrerelease --jq .isPrerelease", common_script) - self.assertIn("prerelease metadata mismatch", common_script) - - for release_text in (release_script, release_step): - with self.subTest(surface=release_text[:32]): - self.assertIn('prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")"', release_text) - self.assertIn('create_github_release_from_notes_or_generated "$RELEASE_TAG" "$prerelease_flag"', release_text) - self.assertIn('verify_github_release_prerelease_metadata "$RELEASE_TAG"', release_text) - - def test_release_notes_helper_prefers_checked_in_notes_then_falls_back(self) -> None: - common_script = ROOT / "skills/maintain-project-repo/assets/repo-maintenance/lib/common.sh" - with tempfile.TemporaryDirectory() as tmpdir: - repo_root = Path(tmpdir) - notes_dir = repo_root / "docs/releases" - notes_dir.mkdir(parents=True) - tagged_notes = notes_dir / "v1.2.3.md" - versioned_notes = notes_dir / "1.2.3.md" - tagged_notes.write_text("# Tagged notes\n", encoding="utf-8") - versioned_notes.write_text("# Versioned notes\n", encoding="utf-8") - fake_bin = repo_root / "bin" - fake_bin.mkdir() - fake_gh = fake_bin / "gh" - fake_gh.write_text('#!/usr/bin/env sh\nprintf "%s\\n" "$*" >> "$GH_LOG"\n', encoding="utf-8") - fake_gh.chmod(0o755) - log_path = repo_root / "gh.log" - - script = '\n'.join( - [ - f'. "{common_script}"', - f'REPO_ROOT="{repo_root}"', - 'create_github_release_from_notes_or_generated v1.2.3 ""', - ] - ) - env = dict(os.environ, PATH=f"{fake_bin}:{os.environ['PATH']}", GH_LOG=str(log_path)) - subprocess.run(["sh", "-c", script], check=True, capture_output=True, text=True, env=env) - self.assertIn(f"--notes-file {tagged_notes}", log_path.read_text(encoding="utf-8")) - - tagged_notes.unlink() - log_path.unlink() - subprocess.run(["sh", "-c", script], check=True, capture_output=True, text=True, env=env) - self.assertIn(f"--notes-file {versioned_notes}", log_path.read_text(encoding="utf-8")) - - versioned_notes.unlink() - log_path.unlink() - subprocess.run(["sh", "-c", script], check=True, capture_output=True, text=True, env=env) - self.assertIn("--generate-notes", log_path.read_text(encoding="utf-8")) - - def test_release_env_documents_scheduled_continuation_default(self) -> None: - release_env = (ROOT / "skills/maintain-project-repo/assets/repo-maintenance/config/release.env").read_text( - encoding="utf-8" - ) - self.assertIn("REPO_MAINTENANCE_RELEASE_OPERATION=prepare", release_env) - self.assertIn("REPO_MAINTENANCE_MIN_REQUIRED_CHECKS=1", release_env) - self.assertIn("host-native continuation", release_env) - self.assertIn("five", release_env) - self.assertIn("Never add a shell poll loop", release_env) - self.assertIn("do not delete/recreate it after an unchanged snapshot", release_env) - - def test_release_guidance_reuses_healthy_pending_continuations(self) -> None: - skill_text = (ROOT / "skills/maintain-project-repo/SKILL.md").read_text(encoding="utf-8") - release_modes = (ROOT / "skills/maintain-project-repo/references/release-modes.md").read_text( - encoding="utf-8" - ) - prompts = (ROOT / "skills/maintain-project-repo/references/automation-prompts.md").read_text( - encoding="utf-8" - ) - - for text in (skill_text, release_modes, prompts): - with self.subTest(surface=text[:32]): - self.assertIn("matching", text) - self.assertIn("pending and healthy", text) - self.assertIn("do not delete/recreate", text) - - def test_continuation_policy_matches_emitted_packet_schema(self) -> None: - socket_root = ( - ROOT - if (ROOT / "docs/maintainers/deferred-work-wakeup-policy.md").is_file() - else ROOT.parents[1] - ) - live_policy = (socket_root / "docs/maintainers/deferred-work-wakeup-policy.md").read_text( - encoding="utf-8" - ) - self.assertIn("Reuse that item unchanged", live_policy) - self.assertIn("minimum delay is five minutes", live_policy) - for field in ( - "repository", - "release tag", - "branch", - "head commit", - "PR number", - "phase", - "minimum_delay_minutes", - "resume/advance commands", - ): - with self.subTest(field=field): - self.assertIn(field, live_policy) - self.assertIn("Pre-PR packets resume with `prepare`", live_policy) - self.assertIn("post-PR\npackets resume with `inspect`", live_policy) - - def test_branch_accounting_guidance_is_documented(self) -> None: - skill_text = (ROOT / "skills/maintain-project-repo/SKILL.md").read_text(encoding="utf-8") - release_modes = (ROOT / "skills/maintain-project-repo/references/release-modes.md").read_text( - encoding="utf-8" - ) - automation_prompts = (ROOT / "skills/maintain-project-repo/references/automation-prompts.md").read_text( - encoding="utf-8" - ) - - for text in (skill_text, release_modes): - with self.subTest(surface=text[:32]): - self.assertIn("branch accounting", text) - self.assertIn("git branch --no-merged <base>", text) - self.assertIn("commit reachability", text) - self.assertIn("temporary rescue refs", text) - self.assertIn("explicit archive ref", text) - - self.assertIn("accounts for every local branch not contained by `main`", automation_prompts) - self.assertIn("do not delete local branches, remote branches, worktrees, archive refs", automation_prompts) - - def test_release_and_publish_triggers_are_documented(self) -> None: - skill_text = (ROOT / "skills/maintain-project-repo/SKILL.md").read_text(encoding="utf-8") - trigger_reference = (ROOT / "skills/maintain-project-repo/references/trigger-eval.md").read_text( - encoding="utf-8" - ) - openai_yaml = (ROOT / "skills/maintain-project-repo/agents/openai.yaml").read_text(encoding="utf-8") - - self.assertIn("references/trigger-eval.md", skill_text) - self.assertIn("maintain-github-repository", skill_text) - - for expected in ( - "release or publish a version", - "bump and tag a release", - "create the GitHub release", - "protected-main release", - "release cleanup and branch accounting", - ): - with self.subTest(expected=expected): - self.assertIn(expected, skill_text) - - for expected in ( - "Release version 1.4.0.", - "Publish this package.", - "Tag this commit and create the GitHub release.", - "Prepare this branch for a protected-main release.", - "Apply my normal GitHub repository settings.", - ): - with self.subTest(expected=expected): - self.assertIn(expected, trigger_reference) - - self.assertIn("protected-main release, publish, tag, GitHub release", openai_yaml) - - def test_prerelease_release_metadata_guidance_is_documented(self) -> None: - skill_text = (ROOT / "skills/maintain-project-repo/SKILL.md").read_text(encoding="utf-8") - release_modes = (ROOT / "skills/maintain-project-repo/references/release-modes.md").read_text( - encoding="utf-8" - ) - - for text in (skill_text, release_modes): - with self.subTest(surface=text[:32]): - self.assertIn("SemVer prerelease", text) - self.assertIn("--prerelease", text) - self.assertIn("prerelease metadata", text) - - def test_refresh_preserves_repo_specific_extra_script(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "install") - self.assertEqual(code, 0) - - custom_script = Path(tmpdir, "scripts/repo-maintenance/validations/90-custom.sh") - custom_script.parent.mkdir(parents=True, exist_ok=True) - custom_script.write_text("#!/usr/bin/env sh\nexit 0\n", encoding="utf-8") - - code, payload = self.run_script("--repo-root", tmpdir, "--operation", "refresh") - self.assertEqual(code, 0) - self.assertEqual(payload["status"], "success") - self.assertTrue(custom_script.is_file()) - - def test_only_generic_and_xcode_workspace_profiles_exist(self) -> None: - installer = ( - ROOT / "skills/maintain-project-repo/scripts/install_maintain_project_repo.py" - ).read_text(encoding="utf-8") - runner = SCRIPT.read_text(encoding="utf-8") - self.assertIn('"generic":', installer) - self.assertIn('"xcode-workspace":', installer) - self.assertNotIn('"swift-package":', installer) - self.assertNotIn('"xcode-app":', installer) - self.assertIn('choices=("generic", "xcode-workspace")', runner) - - -class MaintainProjectRepoDocumentationTests(unittest.TestCase): - def test_select_workflows_preserves_canonical_order(self) -> None: - selected, errors = docs_module.select_workflows(None, None) - self.assertEqual(errors, []) - self.assertEqual( - [workflow.key for workflow in selected], - ["readme", "contributing", "agents", "roadmap"], - ) - - def test_select_workflows_reports_unknown_keys(self) -> None: - selected, errors = docs_module.select_workflows("readme,unknown", "roadmap") - self.assertEqual([workflow.key for workflow in selected], ["readme"]) - self.assertEqual(errors, ["Unknown document workflow key: unknown"]) - - def test_build_child_command_passes_ticket_flags_only_to_roadmap(self) -> None: - args = docs_module.parse_args( - [ - "--project-root", - "/tmp/demo", - "--run-mode", - "check-only", - "--collect-source-tickets", - "--collect-github-issues", - "--github-repo", - "owner/repo", - ] - ) - readme_command = docs_module.build_child_command( - args, docs_module.DOCUMENT_WORKFLOWS[0], Path("/tmp/demo") - ) - roadmap_command = docs_module.build_child_command( - args, docs_module.DOCUMENT_WORKFLOWS[-1], Path("/tmp/demo") - ) - self.assertNotIn("--collect-source-tickets", readme_command) - self.assertIn("--collect-source-tickets", roadmap_command) - self.assertIn("--collect-github-issues", roadmap_command) - self.assertIn("owner/repo", roadmap_command) - - def test_responsibility_audit_flags_cross_doc_drift(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - (root / "README.md").write_text( - "# Demo\n\n## Contribution Workflow\n\nDo all the things.\n", - encoding="utf-8", - ) - (root / "ROADMAP.md").write_text( - "# Roadmap\n\n## Safety Boundaries\n\nDo not.\n", - encoding="utf-8", - ) - issues = docs_module.audit_responsibility_boundaries( - root, docs_module.DOCUMENT_WORKFLOWS - ) - issue_ids = {issue["issue_id"] for issue in issues} - self.assertIn("readme-contains-maintainer-workflow", issue_ids) - self.assertIn("roadmap-contains-procedural-guidance", issue_ids) - - def test_script_reports_selection_errors_as_json(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - proc = subprocess.run( - [ - sys.executable, - str(DOCS_SCRIPT), - "--project-root", - tmpdir, - "--run-mode", - "check-only", - "--include", - "missing", - "--print-json", - ], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(proc.returncode, 1) - self.assertIn("Unknown document workflow key: missing", proc.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_spi_add_package.py b/tests/test_spi_add_package.py deleted file mode 100644 index ed4a441c8..000000000 --- a/tests/test_spi_add_package.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path - -import pytest - - -MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "spi_add_package.py" -SPEC = importlib.util.spec_from_file_location("spi_add_package", MODULE_PATH) -assert SPEC and SPEC.loader -spi_add_package = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = spi_add_package -SPEC.loader.exec_module(spi_add_package) - - -VALID_FORM = """\ -name: Add Package(s) -description: Add one or more new packages to the Swift Package Index. -title: 'Add <Package>' -labels: ['Add Package'] -body: - - type: textarea - id: list - attributes: - label: New Packages - validations: - required: true -""" - - -def test_normalize_github_url_accepts_ssh_and_adds_git_suffix() -> None: - identity = spi_add_package.normalize_github_url("git@github.com:gaelic-ghost/SwiftASB.git") - - assert identity.owner == "gaelic-ghost" - assert identity.repository == "SwiftASB" - assert identity.git_url == "https://github.com/gaelic-ghost/SwiftASB.git" - - -def test_build_issue_form_url_uses_only_official_template_fields() -> None: - identity = spi_add_package.PackageIdentity( - owner="gaelic-ghost", - repository="SwiftASB", - git_url="https://github.com/gaelic-ghost/SwiftASB.git", - ) - - url = spi_add_package.build_issue_form_url(identity) - - assert url.startswith("https://github.com/SwiftPackageIndex/PackageList/issues/new?") - assert "template=add_package.yml" in url - assert "title=Add+SwiftASB" in url - assert "list=https%3A%2F%2Fgithub.com%2Fgaelic-ghost%2FSwiftASB.git" in url - assert "labels=" not in url - assert "body=" not in url - - -def test_validate_live_add_package_form_rejects_missing_default_label() -> None: - form = VALID_FORM.replace("labels: ['Add Package']\n", "") - - with pytest.raises(spi_add_package.SPIAddPackageError, match="default Add Package label"): - spi_add_package.validate_live_add_package_form(form) - - -def test_validate_live_add_package_form_rejects_missing_list_field() -> None: - form = VALID_FORM.replace("id: list", "id: urls") - - with pytest.raises(spi_add_package.SPIAddPackageError, match="New Packages field id"): - spi_add_package.validate_live_add_package_form(form) - - -def test_validate_mode_rejects_skip_flags_for_hands_free() -> None: - args = spi_add_package.parse_args(["hands-free", ".", "--skip-tests"]) - - with pytest.raises(spi_add_package.SPIAddPackageError, match="complete readiness"): - spi_add_package.validate_mode_and_skip_flags(args) - - -def test_dump_package_json_requires_products(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - completed = spi_add_package.subprocess.CompletedProcess( - args=["swift", "package", "dump-package"], - returncode=0, - stdout='{"name":"Empty","products":[]}', - stderr="", - ) - monkeypatch.setattr(spi_add_package, "run_command", lambda *_args, **_kwargs: completed) - - with pytest.raises(spi_add_package.SPIAddPackageError, match="at least one"): - spi_add_package.dump_package_json(tmp_path) - - -def test_confirm_swift_tools_version_rejects_legacy_manifest(tmp_path: Path) -> None: - (tmp_path / "Package.swift").write_text("// swift-tools-version: 4.2\n", encoding="utf-8") - - with pytest.raises(spi_add_package.SPIAddPackageError, match="Swift 5.0 or later"): - spi_add_package.confirm_swift_tools_version(tmp_path) - - -def test_confirm_remote_semver_tag_requires_pushed_release_tag( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - identity = spi_add_package.PackageIdentity( - owner="gaelic-ghost", - repository="SwiftASB", - git_url="https://github.com/gaelic-ghost/SwiftASB.git", - ) - completed = spi_add_package.subprocess.CompletedProcess( - args=["git", "ls-remote", "--tags", identity.git_url], - returncode=0, - stdout="abc123\trefs/tags/v0.0.1\n", - stderr="", - ) - monkeypatch.setattr(spi_add_package, "run_command", lambda *_args, **_kwargs: completed) - - with pytest.raises(spi_add_package.SPIAddPackageError, match="none were visible"): - spi_add_package.confirm_remote_semver_tag(identity, tmp_path, ("v1.0.0",)) - - -def test_computer_use_handoff_forbids_failed_external_paths() -> None: - result = spi_add_package.ReadinessResult( - package_root=Path("/tmp/SwiftASB"), - identity=spi_add_package.PackageIdentity( - owner="gaelic-ghost", - repository="SwiftASB", - git_url="https://github.com/gaelic-ghost/SwiftASB.git", - ), - semver_tags=("v0.1.0",), - indexed_state="not-indexed", - checked_steps=("Package.swift",), - skipped_steps=(), - ) - - handoff = spi_add_package.computer_use_handoff( - "https://github.com/SwiftPackageIndex/PackageList/issues/new?template=add_package.yml", - result=result, - browser=spi_add_package.ZEN_BROWSER_BUNDLE_ID, - ) - forbidden_text = "\n".join(handoff["forbidden_actions"]) - - assert handoff["browser_bundle_id"] == "app.zen-browser.zen" - assert "gh issue create" in forbidden_text - assert "packages.json" in forbidden_text - assert "fork SwiftPackageIndex/PackageList" in forbidden_text - assert "pull request" in forbidden_text - - -def test_source_does_not_contain_forbidden_package_list_write_commands() -> None: - source = MODULE_PATH.read_text(encoding="utf-8") - - forbidden_snippets = [ - 'run_command(["gh"', - "subprocess.run([\"gh\"", - "create-pull-request", - "--label Add Package", - "SwiftPackageIndex/PackageList.git", - ] - for snippet in forbidden_snippets: - assert snippet not in source diff --git a/tests/test_swiftasb_skills_install.py b/tests/test_swiftasb_skills_install.py deleted file mode 100644 index 61266a2a0..000000000 --- a/tests/test_swiftasb_skills_install.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import tomllib -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent - - -def test_swiftasb_skills_marketplace_installs_in_temporary_codex_home( - tmp_path: Path, -) -> None: - codex = shutil.which("codex") - if codex is None: - pytest.skip("codex CLI is not available") - - codex_home = tmp_path / "codex-home" - codex_home.mkdir() - - env = os.environ.copy() - env["CODEX_HOME"] = str(codex_home) - - result = subprocess.run( - [codex, "plugin", "marketplace", "add", str(REPO_ROOT)], - check=True, - capture_output=True, - env=env, - text=True, - ) - - assert "Added marketplace `socket`" in result.stdout - - config_path = codex_home / "config.toml" - assert config_path.is_file() - - config = tomllib.loads(config_path.read_text(encoding="utf-8")) - socket_marketplace = config["marketplaces"]["socket"] - assert socket_marketplace["source_type"] == "local" - assert socket_marketplace["source"] == str(REPO_ROOT) - - marketplace_path = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" - assert marketplace_path.is_file() - - marketplace = json.loads(marketplace_path.read_text(encoding="utf-8")) - dotnet_entry = next( - plugin for plugin in marketplace["plugins"] if plugin["name"] == "dotnet-skills" - ) - assert dotnet_entry["policy"]["installation"] == "AVAILABLE" - assert dotnet_entry["source"] == { - "source": "local", - "path": "./plugins/dotnet-skills", - } - - plugin_root = REPO_ROOT / "plugins" / "swiftasb-skills" - assert (plugin_root / ".codex-plugin" / "plugin.json").is_file() - assert (plugin_root / "skills" / "explain-swiftasb" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "choose-integration-shape" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "build-swiftui-app" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "build-appkit-app" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "build-swift-package" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "diagnose-integration" / "SKILL.md").is_file() - - -def test_dotnet_skills_plugin_exposes_expected_skill_inventory() -> None: - plugin_root = REPO_ROOT / "plugins" / "dotnet-skills" - - assert (plugin_root / ".codex-plugin" / "plugin.json").is_file() - assert (plugin_root / "assets" / "sharp-icon.jpg").is_file() - assert (plugin_root / "skills" / "choose-project-shape" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "bootstrap-solution" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "build-fsharp-project" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "build-csharp-project" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "testing-workflow" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "package-workflow" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "diagnose-project" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "aspnet-core-service-workflow" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "fsharp-csharp-interop" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "ci-workflow" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "upgrade-workflow" / "SKILL.md").is_file() - assert (plugin_root / "skills" / "tooling-style-workflow" / "SKILL.md").is_file() diff --git a/tests/test_unified_swift_workspace_contracts.py b/tests/test_unified_swift_workspace_contracts.py deleted file mode 100644 index 60f0ad158..000000000 --- a/tests/test_unified_swift_workspace_contracts.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent.parent - - -def read(relative: str) -> str: - return (ROOT / relative).read_text(encoding="utf-8") - - -def test_package_workflows_expose_package_context() -> None: - for skill in ( - "swift-package-build-run-workflow", - "swift-package-testing-workflow", - "swift-package-extension-workflow", - ): - script = read(f"plugins/apple-dev-skills/skills/{skill}/scripts/run_workflow.py") - assert "package_context" in script - - -def test_workspace_entrypoint_owns_all_component_roots() -> None: - script = read("plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run_workflow.py") - for phrase in ( - 'choices=("create", "adopt", "add-component", "align")', - 'choices=("app", "extension", "library", "service")', - "Services/services-shared.yml", - "workspace-service-component", - "ensure_services_surface", - ): - assert phrase in script - - -def test_active_xcode_guidance_uses_apps_peer_targets() -> None: - shared = read("plugins/apple-dev-skills/shared/agents-snippets/apple-xcode-project-core.md") - extension = read("plugins/apple-dev-skills/skills/app-extension-architecture-workflow/SKILL.md") - for text in (shared, extension): - assert "Apps/<Target>" in text or "Apps/<ExtensionTarget>" in text - assert "never create a root `Extensions/`" in text - assert "Extension targets use one `Extensions/" not in shared - - -def test_service_adapter_is_native_local_and_github_cloud_only() -> None: - script = read("plugins/server-side-swift/skills/workspace-service-component/scripts/run_workflow.py") - for phrase in ( - "brew services list", - "SERVICE_POSTGRES_FORMULA", - "docker/build-push-action@v7", - "steps.build.outputs.digest", - "id-token: write", - "environment:", - "if: github.event_name != 'pull_request'", - ): - assert phrase in script - assert "docker compose" not in script.lower() - assert "colima" not in script.lower() - - -def test_server_deployment_guidance_has_no_local_linux_or_direct_deploy_path() -> None: - docker = read("plugins/server-side-swift/skills/docker-workflow/SKILL.md") - fly = read("plugins/server-side-swift/skills/fly-io-deployment-workflow/SKILL.md") - combined = f"{docker}\n{fly}".lower() - for forbidden in ("`docker compose up", "`colima start", "`container machine start", "`docker build "): - assert forbidden not in combined - for required in ("github actions", "exact image", "protected environment", "rollback"): - assert required in combined - - -def test_cloud_contract_records_immutable_container_and_archive_identities() -> None: - guidance = read( - "plugins/cloud-deployment-skills/skills/dockerized-service-release-deployment-workflow/SKILL.md" - ) - assert "registry digest" in guidance - assert "SHA-256 checksum" in guidance - assert "clean GitHub Actions checkout" in guidance - - -def test_soto_is_default_and_official_sdk_requires_a_recorded_exception() -> None: - skill = read("plugins/server-side-swift/skills/soto-aws-workflow/SKILL.md") - exception = read( - "plugins/server-side-swift/skills/soto-aws-workflow/references/official-sdk-exception.template.md" - ) - for phrase in ("Soto is the default", "one `AWSClient`", "exactly once"): - assert phrase in skill - for field in ("Soto version checked", "Evidence link or reproduction", "Review or removal condition"): - assert field in exception - lifecycle = read( - "plugins/server-side-swift/skills/soto-aws-workflow/references/awsclient-lifecycle.md" - ) - assert "Long-Running Service" in lifecycle - assert "Warm Lambda Environment" in lifecycle - assert "try await client.shutdown()" in lifecycle diff --git a/tests/test_validate_claude_compatibility.py b/tests/test_validate_claude_compatibility.py deleted file mode 100644 index 0c9d41bd4..000000000 --- a/tests/test_validate_claude_compatibility.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - -import pytest - - -MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "validate_claude_compatibility.py" -SPEC = importlib.util.spec_from_file_location("validate_claude_compatibility", MODULE_PATH) -assert SPEC and SPEC.loader -validate_claude_compatibility = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = validate_claude_compatibility -SPEC.loader.exec_module(validate_claude_compatibility) - - -def write(path: Path, contents: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - - -def make_repo(tmp_path: Path) -> Path: - write( - tmp_path / ".agents" / "plugins" / "marketplace.json", - json.dumps( - { - "name": "socket", - "plugins": [ - { - "name": "example-skills", - "source": {"source": "local", "path": "./plugins/example-skills"}, - } - ], - } - ), - ) - write(tmp_path / "plugins" / "example-skills" / "skills" / "example" / "SKILL.md", "---\nname: example\n---\n") - write( - tmp_path / ".claude-plugin" / "marketplace.json", - json.dumps( - { - "name": "socket", - "owner": {"name": "Test Owner"}, - "description": "Test Claude marketplace.", - "plugins": [ - { - "name": "example-skills", - "source": "./plugins/example-skills", - "description": "Example skills.", - "strict": False, - } - ], - } - ), - ) - write( - tmp_path / "docs" / "maintainers" / "claude-compatibility.json", - json.dumps( - { - "schemaVersion": 1, - "catalog": "socket", - "entries": { - "example-skills": { - "claudeCode": "supported", - "cowork": "skills_only", - "note": "Instruction-only workflow.", - } - }, - } - ), - ) - return tmp_path - - -def configure_paths(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(validate_claude_compatibility, "REPO_ROOT", repo_root) - monkeypatch.setattr(validate_claude_compatibility, "CODEX_MARKETPLACE_PATH", repo_root / ".agents" / "plugins" / "marketplace.json") - monkeypatch.setattr(validate_claude_compatibility, "CLAUDE_MARKETPLACE_PATH", repo_root / ".claude-plugin" / "marketplace.json") - monkeypatch.setattr(validate_claude_compatibility, "INVENTORY_PATH", repo_root / "docs" / "maintainers" / "claude-compatibility.json") - monkeypatch.setattr(validate_claude_compatibility, "EXCLUDED_CLAUDE_PLUGINS", set()) - - -def test_main_accepts_complete_classification(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - - assert validate_claude_compatibility.main() == 0 - - -def test_main_rejects_catalog_plugin_without_strict_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - marketplace_path = repo_root / ".claude-plugin" / "marketplace.json" - document = json.loads(marketplace_path.read_text(encoding="utf-8")) - document["plugins"][0]["strict"] = True - marketplace_path.write_text(json.dumps(document), encoding="utf-8") - - with pytest.raises(validate_claude_compatibility.ValidationError, match="strict to false"): - validate_claude_compatibility.main() - - -def test_main_rejects_local_mcp_for_cowork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - inventory_path = repo_root / "docs" / "maintainers" / "claude-compatibility.json" - document = json.loads(inventory_path.read_text(encoding="utf-8")) - document["entries"]["example-skills"]["claudeCode"] = "local_mcp" - document["entries"]["example-skills"]["cowork"] = "remote_mcp" - inventory_path.write_text(json.dumps(document), encoding="utf-8") - - with pytest.raises(validate_claude_compatibility.ValidationError, match="must be Cowork skills_only"): - validate_claude_compatibility.main() diff --git a/tests/test_validate_hermes_compatibility.py b/tests/test_validate_hermes_compatibility.py deleted file mode 100644 index 7d31aa25a..000000000 --- a/tests/test_validate_hermes_compatibility.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import shutil -import sys -from pathlib import Path - -import pytest - - -ROOT = Path(__file__).resolve().parent.parent -EXPORT_MODULE_PATH = ROOT / "scripts" / "export_hermes_skills.py" -EXPORT_SPEC = importlib.util.spec_from_file_location("export_hermes_skills", EXPORT_MODULE_PATH) -assert EXPORT_SPEC and EXPORT_SPEC.loader -export_hermes_skills = importlib.util.module_from_spec(EXPORT_SPEC) -sys.modules[EXPORT_SPEC.name] = export_hermes_skills -EXPORT_SPEC.loader.exec_module(export_hermes_skills) - -MODULE_PATH = ROOT / "scripts" / "validate_hermes_compatibility.py" -SPEC = importlib.util.spec_from_file_location("validate_hermes_compatibility", MODULE_PATH) -assert SPEC and SPEC.loader -validate_hermes_compatibility = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = validate_hermes_compatibility -SPEC.loader.exec_module(validate_hermes_compatibility) - - -def write(path: Path, contents: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - - -def make_repo(tmp_path: Path) -> Path: - source_root = tmp_path / "plugins" / "agent-portability-skills" / "skills" - for skill_name in export_hermes_skills.EXPORTED_SKILLS: - write( - source_root / skill_name / "SKILL.md", - f"---\nname: {skill_name}\ndescription: Test skill {skill_name}.\n---\n", - ) - export_root = tmp_path / "skills" - export_hermes_skills.write_export(source_root, export_root) - for skill_name in export_hermes_skills.AGENT_ENGINEERING_SKILLS: - write( - tmp_path / "plugins" / "agent-engineering-skills" / "skills" / skill_name / "SKILL.md", - f"---\nname: {skill_name}\ndescription: Test skill {skill_name}.\n---\n", - ) - for skill_name in export_hermes_skills.PYTHON_SKILLS: - write( - tmp_path / "plugins" / "python-skills" / "skills" / skill_name / "SKILL.md", - f"---\nname: {skill_name}\ndescription: Test skill {skill_name}.\n---\n", - ) - for skill_name in export_hermes_skills.JVM_SKILLS: - write( - tmp_path / "plugins" / "server-side-jvm" / "skills" / skill_name / "SKILL.md", - f"---\nname: {skill_name}\ndescription: Test skill {skill_name}.\n---\n", - ) - write( - tmp_path / "skills.sh.json", - json.dumps( - { - "groupings": [ - {"title": "Test Skills", "skills": list(export_hermes_skills.EXPORTED_SKILLS)} - ] - } - ), - ) - write( - tmp_path / "docs" / "maintainers" / "hermes-mcp-examples.yaml", - "mcp_servers:\n example:\n command: tool\n", - ) - write( - tmp_path / "plugins" / "example-skills" / ".mcp.json", - '{"mcpServers": {"example": {"command": "tool"}}}', - ) - write( - tmp_path / "docs" / "maintainers" / "hermes-mcp" / "index.yaml", - "translations:\n example-skills:\n source: plugins/example-skills/.mcp.json\n translation: docs/maintainers/hermes-mcp/example-skills.yaml\n status: ready\n required_environment: []\n setup: Ready for use.\n", - ) - write( - tmp_path / "docs" / "maintainers" / "hermes-mcp" / "example-skills.yaml", - "mcp_servers:\n example:\n command: tool\n", - ) - return tmp_path - - -def configure_paths(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(export_hermes_skills, "SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "MESSAGING_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "APPLE_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "CYBERSECURITY_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "SERVER_SIDE_SWIFT_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "REVERSE_ENGINEERING_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "SWIFT_LANG_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "MODEL_LAB_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "DOTNET_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "AGENT_ENGINEERING_SOURCE_ROOT", repo_root / "plugins" / "agent-engineering-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "PYTHON_SOURCE_ROOT", repo_root / "plugins" / "python-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "JVM_SOURCE_ROOT", repo_root / "plugins" / "server-side-jvm" / "skills") - monkeypatch.setattr(export_hermes_skills, "CLOUD_DEPLOYMENT_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "REPOSITORY_SOURCE_ROOT", repo_root / "plugins" / "agent-portability-skills" / "skills") - monkeypatch.setattr(export_hermes_skills, "EXPORT_ROOT", repo_root / "skills") - monkeypatch.setattr(validate_hermes_compatibility, "REPO_ROOT", repo_root) - monkeypatch.setattr(validate_hermes_compatibility, "EXPORT_ROOT", repo_root / "skills") - monkeypatch.setattr(validate_hermes_compatibility, "GROUPINGS_PATH", repo_root / "skills.sh.json") - monkeypatch.setattr(validate_hermes_compatibility, "MCP_EXAMPLES_PATH", repo_root / "docs" / "maintainers" / "hermes-mcp-examples.yaml") - monkeypatch.setattr(validate_hermes_compatibility, "MCP_TRANSLATIONS_INDEX_PATH", repo_root / "docs" / "maintainers" / "hermes-mcp" / "index.yaml") - - -def test_main_accepts_exact_export_and_valid_metadata( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - - assert validate_hermes_compatibility.main() == 0 - - -def test_main_rejects_stale_export(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - (repo_root / "skills" / "hermes-agent-compatibility" / "SKILL.md").write_text( - "---\nname: hermes-agent-compatibility\ndescription: Drifted export.\n---\n", - encoding="utf-8", - ) - - with pytest.raises(validate_hermes_compatibility.ValidationError, match="stale or incomplete"): - validate_hermes_compatibility.validate_exported_skills() - - -def test_main_rejects_unknown_grouped_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - write( - repo_root / "skills.sh.json", - json.dumps({"groupings": [{"title": "Test", "skills": ["unknown-skill"]}]}), - ) - - with pytest.raises(validate_hermes_compatibility.ValidationError, match="absent"): - validate_hermes_compatibility.validate_groupings() - - -def test_main_rejects_machine_local_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - source = repo_root / "plugins" / "agent-portability-skills" / "skills" / "hermes-agent-compatibility" / "SKILL.md" - source.write_text( - "---\nname: hermes-agent-compatibility\ndescription: Test skill.\nmetadata:\n path: /Users/example\n---\n", - encoding="utf-8", - ) - export_hermes_skills.write_export() - - with pytest.raises(validate_hermes_compatibility.ValidationError, match="machine-local"): - validate_hermes_compatibility.validate_exported_skills() - - -def test_exported_skill_description_accepts_240_characters( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - source = ( - repo_root - / "plugins" - / "agent-portability-skills" - / "skills" - / "hermes-agent-compatibility" - / "SKILL.md" - ) - source.write_text( - f"---\nname: hermes-agent-compatibility\ndescription: {'x' * 240}\n---\n", - encoding="utf-8", - ) - export_hermes_skills.write_export() - - validate_hermes_compatibility.validate_exported_skills() - - -def test_exported_skill_description_rejects_241_characters( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - source = ( - repo_root - / "plugins" - / "agent-portability-skills" - / "skills" - / "hermes-agent-compatibility" - / "SKILL.md" - ) - source.write_text( - f"---\nname: hermes-agent-compatibility\ndescription: {'x' * 241}\n---\n", - encoding="utf-8", - ) - export_hermes_skills.write_export() - - with pytest.raises( - validate_hermes_compatibility.ValidationError, - match=r"skills/hermes-agent-compatibility/SKILL\.md description is 241 characters; maximum is 240", - ): - validate_hermes_compatibility.validate_exported_skills() - - -def test_export_check_detects_missing_skill(tmp_path: Path) -> None: - source_root = tmp_path / "source" - for skill_name in export_hermes_skills.EXPORTED_SKILLS: - write(source_root / skill_name / "SKILL.md", "---\nname: test\n---\n") - export_root = tmp_path / "skills" - export_hermes_skills.write_export(source_root, export_root) - shutil.rmtree(export_root / "sync-skills-repo-guidance") - - assert not export_hermes_skills.has_exact_export(source_root, export_root) - - -def test_mcp_translation_rejects_missing_socket_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - (repo_root / "plugins" / "example-skills" / ".mcp.json").unlink() - - with pytest.raises(validate_hermes_compatibility.ValidationError, match="no declared Socket .mcp.json source"): - validate_hermes_compatibility.validate_mcp_translations() - - -def test_mcp_translation_rejects_undocumented_environment_placeholder( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - repo_root = make_repo(tmp_path) - configure_paths(repo_root, monkeypatch) - write( - repo_root / "docs" / "maintainers" / "hermes-mcp" / "example-skills.yaml", - "mcp_servers:\n example:\n command: tool\n env:\n API_KEY: ${API_KEY}\n", - ) - - with pytest.raises(validate_hermes_compatibility.ValidationError, match="undocumented environment placeholders"): - validate_hermes_compatibility.validate_mcp_translations() diff --git a/tests/test_validate_socket.py b/tests/test_validate_socket.py deleted file mode 100644 index 91c38e240..000000000 --- a/tests/test_validate_socket.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path - -import pytest - - -ROOT = Path(__file__).resolve().parent.parent - - -def load_module(name: str, filename: str): - spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / filename) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -validate_socket = load_module("validate_socket", "validate_socket.py") -validate_skill_metadata = load_module( - "validate_socket_skill_metadata", "validate_socket_skill_metadata.py" -) - - -def write(path: Path, contents: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - - -def test_core_profile_uses_root_owned_checks_only() -> None: - checks = validate_socket.checks_for_profile("core") - - assert [check.name for check in checks] == [ - "root marketplace metadata", - "shared skill metadata", - "root tests", - "root type checks", - "root lint", - ] - - -def test_full_profile_adds_compatibility_and_child_checks_once() -> None: - checks = validate_socket.checks_for_profile("full") - names = [check.name for check in checks] - - assert names.count("Agent Portability Skills tests") == 1 - assert names.count("Cybersecurity Skills tests") == 1 - assert names.count("Reverse Engineering Skills tests") == 1 - assert "Hermes compatibility" in names - assert "Claude compatibility" in names - assert "release readiness" not in names - - -def test_validation_profiles_do_not_duplicate_release_choreography() -> None: - source = (ROOT / "scripts" / "validate_socket.py").read_text(encoding="utf-8") - - assert '"release"' not in source - assert "release-ready" not in source - - -def test_full_validation_workflow_uses_macos_runner() -> None: - workflow = (ROOT / ".github" / "workflows" / "validate-socket.yml").read_text( - encoding="utf-8" - ) - - assert "runs-on: macos-latest" in workflow - assert "apt-get" not in workflow - assert "uv run scripts/validate_socket.py --profile full" in workflow - - -def test_dry_run_does_not_execute_a_subprocess(monkeypatch: pytest.MonkeyPatch) -> None: - called = False - - def unexpected_run(*args: object, **kwargs: object) -> None: - nonlocal called - called = True - - monkeypatch.setattr(validate_socket.subprocess, "run", unexpected_run) - validate_socket.run_check(validate_socket.CORE_CHECKS[0], dry_run=True) - - assert not called - - -def test_shared_skill_validator_accepts_valid_plugin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - plugin_root = tmp_path / "plugins" / "example-skills" - write(plugin_root / ".codex-plugin" / "plugin.json", "{}\n") - write(plugin_root / "AGENTS.md", "# Guidance\n") - write( - plugin_root / "skills" / "example-skill" / "SKILL.md", - "---\nname: example-skill\ndescription: A valid skill.\n---\n", - ) - write( - plugin_root / "skills" / "example-skill" / "agents" / "openai.yaml", - "interface:\n default_prompt: Use $example-skill.\n", - ) - monkeypatch.setattr(validate_skill_metadata, "REPO_ROOT", tmp_path) - - assert validate_skill_metadata.main() == 0 - - -def test_shared_skill_validator_rejects_directory_name_drift( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - plugin_root = tmp_path / "plugins" / "example-skills" - write(plugin_root / ".codex-plugin" / "plugin.json", "{}\n") - write(plugin_root / "AGENTS.md", "# Guidance\n") - write( - plugin_root / "skills" / "example-skill" / "SKILL.md", - "---\nname: wrong-name\ndescription: A valid skill.\n---\n", - ) - monkeypatch.setattr(validate_skill_metadata, "REPO_ROOT", tmp_path) - - with pytest.raises(SystemExit): - validate_skill_metadata.main() - - -@pytest.mark.parametrize( - ("interface", "match"), - [ - ("interface: {}\n", "non-empty interface"), - ("interface:\n default_prompt: Use this skill.\n", "invocation token"), - ("interface:\n default_prompt: Use $example-skill.\n display_name: ''\n", "display_name"), - ], -) -def test_shared_skill_validator_rejects_invalid_openai_interface( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - interface: str, - match: str, -) -> None: - plugin_root = tmp_path / "plugins" / "example-skills" - write(plugin_root / ".codex-plugin" / "plugin.json", "{}\n") - write(plugin_root / "AGENTS.md", "# Guidance\n") - write( - plugin_root / "skills" / "example-skill" / "SKILL.md", - "---\nname: example-skill\ndescription: A valid skill.\n---\n", - ) - write(plugin_root / "skills" / "example-skill" / "agents" / "openai.yaml", interface) - monkeypatch.setattr(validate_skill_metadata, "REPO_ROOT", tmp_path) - - with pytest.raises(SystemExit): - validate_skill_metadata.main() - assert match in capsys.readouterr().err diff --git a/tests/test_validate_socket_metadata.py b/tests/test_validate_socket_metadata.py deleted file mode 100644 index b66a98409..000000000 --- a/tests/test_validate_socket_metadata.py +++ /dev/null @@ -1,518 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path - -import pytest - - -MODULE_PATH = Path(__file__).resolve().parent.parent / "scripts" / "validate_socket_metadata.py" -SPEC = importlib.util.spec_from_file_location("validate_socket_metadata", MODULE_PATH) -assert SPEC and SPEC.loader -validate_socket_metadata = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = validate_socket_metadata -SPEC.loader.exec_module(validate_socket_metadata) - - -def write(path: Path, contents: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents, encoding="utf-8") - - -def make_marketplace_repo(tmp_path: Path, manifest: dict[str, object]) -> Path: - repo_root = tmp_path - plugin_root = repo_root / "plugins" / "example-skills" - write( - repo_root / ".agents" / "plugins" / "marketplace.json", - json.dumps( - { - "interface": { - "displayName": "Test Marketplace", - }, - "plugins": [ - { - "name": "example-skills", - "source": { - "source": "local", - "path": "./plugins/example-skills", - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL", - }, - "category": "Developer Tools", - } - ] - }, - indent=2, - ) - + "\n", - ) - write(plugin_root / ".codex-plugin" / "plugin.json", json.dumps(manifest, indent=2) + "\n") - write(plugin_root / "skills" / "example" / "SKILL.md", "---\nname: example\n---\n") - return repo_root - - -def make_remote_marketplace_repo( - tmp_path: Path, - *, - source: dict[str, object], -) -> Path: - repo_root = tmp_path - write( - repo_root / ".agents" / "plugins" / "marketplace.json", - json.dumps( - { - "interface": { - "displayName": "Test Marketplace", - }, - "plugins": [ - { - "name": "speak-swiftly", - "source": source, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL", - }, - "category": "Productivity", - } - ] - }, - indent=2, - ) - + "\n", - ) - return repo_root - - -def run_validator(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(validate_socket_metadata, "REPO_ROOT", repo_root) - monkeypatch.setattr( - validate_socket_metadata, - "MARKETPLACE_PATH", - repo_root / ".agents" / "plugins" / "marketplace.json", - ) - validate_socket_metadata.main() - - -def test_main_accepts_plugin_manifest_with_root_skills_component( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - - run_validator(repo_root, monkeypatch) - - -def test_main_accepts_plugin_manifest_with_streamable_http_mcp_config( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - "mcpServers": "./.mcp.json", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".mcp.json", - json.dumps( - { - "mcpServers": { - "dice": { - "url": "https://mcp.dice.com/mcp", - } - } - }, - indent=2, - ) - + "\n", - ) - - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_mcp_config_without_transport( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - "mcpServers": "./.mcp.json", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".mcp.json", - json.dumps({"mcpServers": {"dice": {"note": "missing transport"}}}, indent=2) + "\n", - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_accepts_plugin_manifest_with_interface_assets( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - "interface": { - "composerIcon": "./assets/icon.jpg", - "logo": "./assets/logo.jpg", - "screenshots": ["./assets/screenshot.jpg"], - }, - }, - ) - plugin_root = repo_root / "plugins" / "example-skills" - write(plugin_root / "assets" / "icon.jpg", "icon") - write(plugin_root / "assets" / "logo.jpg", "logo") - write(plugin_root / "assets" / "screenshot.jpg", "screenshot") - - run_validator(repo_root, monkeypatch) - - -def test_main_accepts_plugin_with_read_only_custom_agent( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".codex" / "agents" / "swift-steward.toml", - '\n'.join( - [ - 'name = "swift-steward"', - 'description = "Read-heavy Swift repo-maintenance steward."', - 'model = "gpt-5.4-mini"', - 'sandbox_mode = "read-only"', - 'nickname_candidates = ["Swift Steward"]', - 'developer_instructions = """Return a draft review packet with a proposed patch set and validation handoff. Do not apply edits."""', - "", - ] - ), - ) - - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_custom_agent_without_read_only_sandbox( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".codex" / "agents" / "swift-steward.toml", - '\n'.join( - [ - 'name = "swift-steward"', - 'description = "Read-heavy Swift repo-maintenance steward."', - 'sandbox_mode = "workspace-write"', - 'developer_instructions = """Return a draft review packet with a proposed patch set and validation handoff. Do not apply edits."""', - "", - ] - ), - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_custom_agent_with_empty_model( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".codex" / "agents" / "swift-steward.toml", - '\n'.join( - [ - 'name = "swift-steward"', - 'description = "Read-heavy Swift repo-maintenance steward."', - 'model = ""', - 'sandbox_mode = "read-only"', - 'developer_instructions = """Return a draft patch plan for review. Do not apply edits."""', - "", - ] - ), - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_review_packet_agent_without_report_contract( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".codex" / "agents" / "repo-docs-auditor.toml", - '\n'.join( - [ - 'name = "repo-docs-auditor"', - 'description = "Read-heavy docs auditor."', - 'sandbox_mode = "read-only"', - 'developer_instructions = """Return a draft patch plan for review. Do not apply edits."""', - "", - ] - ), - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_named_review_packet_agent_without_report_contract( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - write( - repo_root - / "plugins" - / "example-skills" - / ".codex" - / "agents" - / "skills-repo-guidance-sync.toml", - '\n'.join( - [ - 'name = "skills-repo-guidance-sync"', - 'description = "Read-heavy skills guidance sync."', - 'sandbox_mode = "read-only"', - 'developer_instructions = """Return a draft guidance audit for review. Do not apply edits."""', - "", - ] - ), - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_custom_agent_without_review_boundary( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - }, - ) - write( - repo_root / "plugins" / "example-skills" / ".codex" / "agents" / "swift-steward.toml", - '\n'.join( - [ - 'name = "swift-steward"', - 'description = "Read-heavy Swift repo-maintenance steward."', - 'sandbox_mode = "read-only"', - 'developer_instructions = """Return findings. Do not apply edits."""', - "", - ] - ), - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_plugin_manifest_with_missing_interface_asset( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./skills/", - "interface": { - "composerIcon": "./assets/missing.jpg", - }, - }, - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_plugin_manifest_missing_root_skills_component( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - }, - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_accepts_unavailable_empty_placeholder_plugin( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = tmp_path - plugin_root = repo_root / "plugins" / "placeholder-skills" - write( - repo_root / ".agents" / "plugins" / "marketplace.json", - json.dumps( - { - "interface": { - "displayName": "Test Marketplace", - }, - "plugins": [ - { - "name": "placeholder-skills", - "source": { - "source": "local", - "path": "./plugins/placeholder-skills", - }, - "policy": { - "installation": "NOT_AVAILABLE", - "authentication": "ON_INSTALL", - }, - "category": "Developer Tools", - } - ] - }, - indent=2, - ) - + "\n", - ) - write( - plugin_root / ".codex-plugin" / "plugin.json", - json.dumps({"name": "placeholder-skills"}, indent=2) + "\n", - ) - - run_validator(repo_root, monkeypatch) - - -def test_main_accepts_root_git_plugin_source( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_remote_marketplace_repo( - tmp_path, - source={ - "source": "url", - "url": "https://github.com/gaelic-ghost/SpeakSwiftlyServer.git", - "ref": "main", - }, - ) - - run_validator(repo_root, monkeypatch) - - -def test_main_accepts_git_subdir_plugin_source( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_remote_marketplace_repo( - tmp_path, - source={ - "source": "git-subdir", - "url": "https://github.com/example/codex-plugins.git", - "path": "./plugins/speak-swiftly", - "sha": "abc123", - }, - ) - - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_root_git_source_with_path( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_remote_marketplace_repo( - tmp_path, - source={ - "source": "url", - "url": "https://github.com/gaelic-ghost/SpeakSwiftlyServer.git", - "path": "./plugins/speak-swiftly", - }, - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_git_subdir_source_without_relative_path( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_remote_marketplace_repo( - tmp_path, - source={ - "source": "git-subdir", - "url": "https://github.com/example/codex-plugins.git", - }, - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) - - -def test_main_rejects_plugin_manifest_with_nonstandard_root_skills_component( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo_root = make_marketplace_repo( - tmp_path, - { - "name": "example-skills", - "skills": "./other-skills/", - }, - ) - - with pytest.raises(SystemExit): - run_validator(repo_root, monkeypatch) From e98043f47b1c80f18beb0baaf14b530bb4ebdeb4 Mon Sep 17 00:00:00 2001 From: Gale W <mail@galewilliams.com> Date: Fri, 21 Aug 2026 00:02:07 -0400 Subject: [PATCH 3/5] repository: remove legacy automation and obsolete skills Why:\nSocket now uses managed FSX assets and root Just entrypoints without a second Python or shell maintenance stack.\n\nBreaking:\nRemoves SwiftASB, Python bootstrap/project/FastAPI/FastMCP skills, customization files, nested tests and evals, ACCESSIBILITY.md, and legacy automation commands.\n\nVerification:\njust docs-check\njust repo-validate\njust test\ngit diff --check --- .github/workflows/validate-socket.yml | 26 - ACCESSIBILITY.md | 180 --- AGENTS.md | 5 + CONTRIBUTING.md | 5 +- README.md | 2 +- ROADMAP.md | 50 +- Socket.xcworkspace/contents.xcworkspacedata | 3 - docs/agents/README.md | 17 +- .../agents-guidance-consolidation-plan.md | 2 +- docs/maintainers/claude-compatibility.md | 6 +- .../cloud-deployment-skills-plugin-plan.md | 2 +- .../cloud-inference-skills-plugin-plan.md | 2 +- .../cybersecurity-skills-plugin-plan.md | 2 +- .../deferred-work-wakeup-policy.md | 6 +- docs/maintainers/dotnet-skills-plugin-plan.md | 2 +- docs/maintainers/errorhandles-package-plan.md | 2 +- .../expo-inline-native-modules-skill-plan.md | 2 +- .../game-dev-skills-plugin-plan.md | 2 +- docs/maintainers/hermes-compatibility.md | 9 +- .../macos-platform-security-skills-plan.md | 6 +- .../model-lab-skills-plugin-plan.md | 8 +- docs/maintainers/plugin-install-testing.md | 2 +- docs/maintainers/plugin-packaging-strategy.md | 6 +- .../project-audit-skills-plugin-plan.md | 2 +- docs/maintainers/python-skills-plugin-plan.md | 467 ------- docs/maintainers/release-workflow.md | 139 +- .../reverse-engineering-skills-plugin-plan.md | 2 +- docs/maintainers/rust-skills-plugin-plan.md | 2 +- docs/maintainers/socket-xcode-workspace.md | 2 +- docs/maintainers/subtree-workflow.md | 4 +- ...swift-workspace-and-cloud-boundary-plan.md | 2 +- .../xcode-27-agentic-tooling-plan.md | 14 +- .../xcode-plugin-install-support-plan.md | 4 +- docs/releases/v10.0.2.md | 4 +- .../agents/skills-repo-guidance-sync.toml | 36 - plugins/agent-portability-skills/AGENTS.md | 17 +- .../bootstrap-skills-plugin-repo/SKILL.md | 85 -- .../agents/openai.yaml | 2 - .../references/bootstrap-contract.md | 16 - .../codex-subagent-skill-guidance.md | 62 - .../references/posix-symlink-policy.md | 7 - .../scripts/bootstrap_skills_plugin_repo.py | 176 --- .../hermes-agent-compatibility/SKILL.md | 3 +- .../operate-acp-agent-integration/SKILL.md | 2 +- .../scripts/check-acp-registry.fsx | 41 + .../scripts/check_acp_registry.py | 93 -- .../skills/sync-skills-repo-guidance/SKILL.md | 110 -- .../agents/openai.yaml | 4 - .../references/source-order.md | 7 - .../references/sync-checklist.md | 13 - .../scripts/sync_skills_repo_guidance.py | 174 --- .../agentdeck/hooks/capture-session-start.sh | 6 - plugins/agentdeck/hooks/hooks.json | 6 +- .../agentdeck/hooks/run-thread-title-hook.fsx | 13 + .../agentdeck/hooks/run-thread-title-hook.sh | 7 - plugins/apple-creator-studio-skills/AGENTS.md | 2 +- .../.github/scripts/sync_shared_snippets.sh | 97 -- .../.github/scripts/validate_repo_docs.sh | 230 ---- .../validate_skill_creator_contract.py | 160 --- plugins/apple-dev-skills/AGENTS.md | 2 +- plugins/apple-dev-skills/CONTRIBUTING.md | 17 +- plugins/apple-dev-skills/README.md | 2 +- plugins/apple-dev-skills/ROADMAP.md | 2 +- .../customization-consolidation-review.md | 202 --- .../docs/maintainers/reality-audit.md | 13 +- .../shared/workflow-planner.fsx | 60 + .../SKILL.md | 9 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 1 - .../skills/app-intents-workflow/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../appkit-app-architecture-workflow/SKILL.md | 11 +- .../references/customization-flow.md | 22 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 9 +- .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 132 -- .../SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../skills/apple-typography-workflow/SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../apple-ui-accessibility-workflow/SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../arkit-spatial-sensing-workflow/SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../skills/author-swift-docc-docs/SKILL.md | 21 +- .../references/customization-flow.md | 33 - .../references/customization.template.yaml | 4 - .../scripts/customization_config.py | 213 --- .../scripts/run-workflow.fsx | 60 + .../scripts/run_workflow.py | 277 ---- .../skills/avaudio-engine-workflow/SKILL.md | 9 - .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/avfaudio-session-workflow/SKILL.md | 9 - .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 9 - .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/bootstrap-xcode-workspace/SKILL.md | 8 +- .../scripts/run-workflow.fsx | 72 + .../scripts/run_workflow.py | 1200 ----------------- .../camera-capture-depth-workflow/SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../core-animation-layer-workflow/SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../core-image-processing-workflow/SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../SKILL.md | 9 - .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 9 - .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../devicecheck-app-attest-workflow/SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../diagnose-apple-entitlements/SKILL.md | 4 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 104 -- .../skills/explore-apple-swift-docs/SKILL.md | 25 +- .../references/automation-prompts.md | 2 +- .../references/customization-flow.md | 34 - .../references/customization.template.yaml | 4 - .../references/dash_http_api.md | 2 +- .../references/dash_url_and_service.md | 2 +- .../scripts/customization_config.py | 213 --- .../scripts/dash_api_probe.py | 98 -- .../scripts/dash_catalog_match.py | 105 -- .../scripts/dash_catalog_refresh.py | 163 --- .../scripts/dash_url_install.py | 49 - .../scripts/dash_url_search.py | 36 - .../scripts/run-workflow.fsx | 41 + .../scripts/run_workflow.py | 385 ------ .../feedback-assistant-workflow/SKILL.md | 1 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 89 -- .../SKILL.md | 9 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 1 - .../skills/format-swift-sources/SKILL.md | 14 +- .../references/automation-prompts.md | 2 +- .../references/customization-flow.md | 31 - .../references/customization.template.yaml | 4 - .../swiftformat-xcode-config-export.md | 6 +- .../scripts/customization_config.py | 213 --- .../export-swiftformat-xcode-config.fsx | 51 + .../export_swiftformat_xcode_config.py | 178 --- .../icon-composer-app-icon-workflow/SKILL.md | 6 +- .../ios-runtime-forensics-workflow/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../linux-development-vm-workflow/SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../macos-development-vm-workflow/SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../macos-distribution-workflow/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 4 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 138 -- .../SKILL.md | 4 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 104 -- .../skills/mailkit-workflow/SKILL.md | 9 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 1 - .../photos-library-editing-workflow/SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/safari-mcp-workflow/SKILL.md | 7 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 152 --- .../skills/sf-symbols-workflow/SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/structure-swift-sources/SKILL.md | 64 +- .../agents/openai.yaml | 2 +- .../references/automation-prompts.md | 6 +- .../references/customization-flow.md | 39 - .../references/customization.template.yaml | 8 - .../references/file-headers.md | 6 +- .../references/todo-fixme-ledgers.md | 2 +- .../scripts/customization_config.py | 213 --- .../scripts/normalize-swift-structure.fsx | 47 + .../scripts/normalize_swift_file_headers.py | 406 ------ .../scripts/normalize_todo_fixme_ledgers.py | 581 -------- .../scripts/run-workflow.fsx | 60 + .../scripts/run_workflow.py | 325 ----- .../swift-package-build-run-workflow/SKILL.md | 18 +- .../references/customization-flow.md | 29 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../scripts/run-workflow.fsx | 60 + .../scripts/run_workflow.py | 347 ----- .../swift-package-extension-workflow/SKILL.md | 12 +- .../references/customization-flow.md | 22 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../scripts/run-workflow.fsx | 49 + .../scripts/run_workflow.py | 163 --- .../swift-package-testing-workflow/SKILL.md | 18 +- .../references/customization-flow.md | 29 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../scripts/run-workflow.fsx | 60 + .../scripts/run_workflow.py | 287 ---- .../skills/swiftdata-workflow/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../swiftui-animation-workflow/SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../swiftui-component-audit-workflow/SKILL.md | 7 - .../references/customization-flow.md | 7 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/swiftui-liquid-glass/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/swiftui-performance-audit/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/tipkit-workflow/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/tips-helpviewer-workflow/SKILL.md | 5 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../tvos-app-experience-workflow/SKILL.md | 10 - .../references/customization-flow.md | 28 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 106 -- .../tvos-media-playback-workflow/SKILL.md | 10 - .../references/customization-flow.md | 28 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 106 -- .../video-codec-processing-workflow/SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../vision-image-analysis-workflow/SKILL.md | 7 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 136 -- .../skills/xcode-build-run-workflow/SKILL.md | 22 +- .../references/customization-flow.md | 33 - .../references/customization.template.yaml | 5 - .../scripts/customization_config.py | 213 --- .../scripts/detect-xcode-managed-scope.fsx | 19 + .../scripts/detect_xcode_managed_scope.sh | 42 - .../scripts/run-workflow.fsx | 60 + .../scripts/run_workflow.py | 423 ------ .../SKILL.md | 9 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../xcode-localization-workflow/SKILL.md | 5 +- .../references/customization-flow.md | 30 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../skills/xcode-testing-workflow/SKILL.md | 22 +- .../references/customization-flow.md | 33 - .../references/customization.template.yaml | 5 - .../scripts/customization_config.py | 213 --- .../scripts/detect-xcode-managed-scope.fsx | 3 + .../scripts/detect_xcode_managed_scope.sh | 42 - .../scripts/run-workflow.fsx | 60 + .../scripts/run_workflow.py | 439 ------ .../flash/evals/client-external-image.eval.md | 23 - .../evals/connect-existing-endpoint.eval.md | 23 - .../flash/evals/cpu-gpu-pipeline.eval.md | 25 - .../flash/evals/dev-loop-iteration.eval.md | 59 - .../flash/evals/fixtures/dev-loop/main.py | 21 - .../flash/evals/lb-multi-route-api.eval.md | 25 - .../flash/evals/qb-gpu-function.eval.md | 26 - .../runpodctl/evals/cpu-pod-create.eval.md | 19 - .../evals/hub-deploy-serverless.eval.md | 31 - .../image-to-template-to-serverless.eval.md | 29 - .../evals/pod-auto-terminate.eval.md | 22 - .../pod-from-template-with-volume.eval.md | 22 - .../runpodctl/evals/pod-ssh-connect.eval.md | 20 - .../serverless-autoscale-by-requests.eval.md | 21 - plugins/cybersecurity-skills/AGENTS.md | 2 +- .../scripts/validate_repo_metadata.py | 220 --- plugins/game-dev-skills/AGENTS.md | 2 +- .../messaging-collaboration-skills/AGENTS.md | 6 +- .../skills/compare-model-checkpoints/SKILL.md | 4 +- .../scripts/snapshot-model-provenance.fsx | 57 + .../scripts/snapshot_model_provenance.py | 124 -- .../skills/design-model-experiment/SKILL.md | 8 +- .../assets/experiment-manifest.json | 15 + .../assets/experiment-manifest.yaml | 46 - .../scripts/validate-experiment-manifest.fsx | 40 + .../scripts/validate_experiment_manifest.py | 151 --- .../skills/evaluate-language-model/SKILL.md | 2 +- .../scripts/compare-eval-runs.fsx | 65 + .../scripts/compare_eval_runs.py | 157 --- .../evaluate-tool-calling-model/SKILL.md | 3 - plugins/network-protocol-skills/AGENTS.md | 2 +- plugins/python-skills/AGENTS.md | 15 +- plugins/python-skills/scripts/__init__.py | 1 - .../scripts/validate_repo_metadata.py | 381 ------ .../shared/bootstrap-contract.md | 59 - .../bootstrap-python-mcp-service/SKILL.md | 263 ---- .../agents/openai.yaml | 8 - .../assets/README.md.tmpl | 43 - .../profiles/init_fastmcp_service.config.yaml | 15 - .../references/customization.md | 18 - .../references/fastmcp-docs-lookup.md | 37 - .../references/interactive-customization.md | 41 - .../references/mcp-mapping-guidelines.md | 48 - .../scripts/assess_api_for_mcp.py | 282 ---- .../scripts/init_fastmcp_service.sh | 553 -------- .../skills/bootstrap-python-service/SKILL.md | 233 ---- .../agents/openai.yaml | 8 - .../assets/README.md.tmpl | 44 - .../profiles/init_python_service.config.yaml | 15 - .../references/conventions.md | 46 - .../references/customization.md | 18 - .../references/interactive-customization.md | 41 - .../scripts/init_python_service.sh | 309 ----- .../bootstrap-uv-python-workspace/SKILL.md | 204 --- .../agents/openai.yaml | 8 - .../assets/README.md.tmpl | 28 - .../init_uv_python_project.config.yaml | 10 - .../init_uv_python_workspace.config.yaml | 11 - .../references/customization.md | 18 - .../references/interactive-customization.md | 40 - .../references/uv-command-recipes.md | 78 -- .../scripts/init_uv_python_project.sh | 497 ------- .../scripts/init_uv_python_workspace.sh | 570 -------- .../build-python-agent-service/SKILL.md | 152 --- .../agents/openai.yaml | 8 - .../skills/build-python-project/SKILL.md | 124 -- .../build-python-project/agents/openai.yaml | 8 - .../choose-python-project-shape/SKILL.md | 113 -- .../agents/openai.yaml | 8 - .../skills/fastapi-service-workflow/SKILL.md | 89 -- .../agents/openai.yaml | 8 - .../skills/fastmcp-service-workflow/SKILL.md | 82 -- .../agents/openai.yaml | 8 - .../skills/integrate-fastapi-fastmcp/SKILL.md | 158 --- .../agents/openai.yaml | 8 - .../references/integration-patterns.md | 47 - .../references/official-docs.md | 47 - .../skills/python-testing-workflow/SKILL.md | 23 +- .../profiles/bootstrap_pytest_uv.config.yaml | 7 - .../assets/profiles/run_pytest_uv.config.yaml | 6 - .../references/customization.md | 18 - .../references/interactive-customization.md | 44 - .../scripts/bootstrap_pytest_uv.sh | 279 ---- .../scripts/run_pytest_uv.sh | 228 ---- .../assets/CONTRIBUTING.template.md | 8 +- plugins/reverse-engineering-skills/AGENTS.md | 4 + .../scripts/validate_repo_metadata.py | 217 --- .../scripts/run-workflow.fsx | 21 + .../scripts/run_workflow.py | 217 --- plugins/swift-lang/AGENTS.md | 2 +- pyproject.toml | 33 - scripts/audit_skill_surfaces.py | 398 ------ scripts/audit_xcode_plugin_compatibility.py | 338 ----- scripts/cleanup_legacy_socket_installs.py | 302 ----- scripts/export_hermes_skills.py | 343 ----- scripts/release.sh | 6 - scripts/release_version.py | 628 --------- scripts/release_workflow.py | 528 -------- .../contributing/CONTRIBUTING.template.md | 8 +- .../syncing/10-managed-repository-assets.fsx | 23 + .../syncing/30-apple-workflow-runtime.fsx | 14 + .../syncing/40-repository-skills-exports.fsx | 32 +- .../validations/50-socket.fsx | 16 +- scripts/spi_add_package.py | 470 ------- scripts/validate_claude_compatibility.py | 190 --- scripts/validate_hermes_compatibility.py | 270 ---- scripts/validate_socket.py | 212 --- scripts/validate_socket_metadata.py | 579 -------- scripts/validate_socket_skill_metadata.py | 117 -- skills.sh.json | 3 - .../SKILL.md | 9 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- skills/bootstrap-skills-plugin-repo/SKILL.md | 85 -- .../agents/openai.yaml | 2 - .../references/bootstrap-contract.md | 16 - .../codex-subagent-skill-guidance.md | 62 - .../references/posix-symlink-policy.md | 7 - .../scripts/bootstrap_skills_plugin_repo.py | 176 --- skills/bootstrap-xcode-workspace/SKILL.md | 8 +- .../scripts/run-workflow.fsx | 72 + .../scripts/run_workflow.py | 1200 ----------------- skills/build-python-agent-service/SKILL.md | 152 --- .../agents/openai.yaml | 8 - .../SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- skills/compare-model-checkpoints/SKILL.md | 4 +- .../scripts/snapshot-model-provenance.fsx | 57 + .../scripts/snapshot_model_provenance.py | 124 -- skills/design-model-experiment/SKILL.md | 8 +- .../assets/experiment-manifest.json | 15 + .../assets/experiment-manifest.yaml | 46 - .../scripts/validate-experiment-manifest.fsx | 40 + .../scripts/validate_experiment_manifest.py | 151 --- skills/diagnose-apple-entitlements/SKILL.md | 4 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 104 -- skills/evaluate-language-model/SKILL.md | 2 +- .../scripts/compare-eval-runs.fsx | 65 + .../scripts/compare_eval_runs.py | 157 --- skills/evaluate-tool-calling-model/SKILL.md | 3 - skills/fastapi-service-workflow/SKILL.md | 89 -- .../agents/openai.yaml | 8 - skills/fastmcp-service-workflow/SKILL.md | 82 -- .../agents/openai.yaml | 8 - .../SKILL.md | 9 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- skills/hermes-agent-compatibility/SKILL.md | 3 +- skills/linux-development-vm-workflow/SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- skills/macos-development-vm-workflow/SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../SKILL.md | 4 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 138 -- .../SKILL.md | 4 - .../references/customization-flow.md | 5 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 104 -- skills/mailkit-workflow/SKILL.md | 9 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../assets/CONTRIBUTING.template.md | 8 +- skills/operate-acp-agent-integration/SKILL.md | 2 +- .../scripts/check-acp-registry.fsx | 41 + .../scripts/check_acp_registry.py | 93 -- skills/python-testing-workflow/SKILL.md | 23 +- .../profiles/bootstrap_pytest_uv.config.yaml | 7 - .../assets/profiles/run_pytest_uv.config.yaml | 6 - .../references/customization.md | 18 - .../references/interactive-customization.md | 44 - .../scripts/bootstrap_pytest_uv.sh | 279 ---- .../scripts/run_pytest_uv.sh | 228 ---- skills/safari-mcp-workflow/SKILL.md | 7 - .../references/customization-flow.md | 20 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 152 --- .../swift-package-extension-workflow/SKILL.md | 12 +- .../references/customization-flow.md | 22 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- .../scripts/run-workflow.fsx | 49 + .../scripts/run_workflow.py | 163 --- skills/sync-skills-repo-guidance/SKILL.md | 110 -- .../agents/openai.yaml | 4 - .../references/source-order.md | 7 - .../references/sync-checklist.md | 13 - .../scripts/sync_skills_repo_guidance.py | 174 --- skills/tvos-app-experience-workflow/SKILL.md | 10 - .../references/customization-flow.md | 28 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 106 -- skills/tvos-media-playback-workflow/SKILL.md | 10 - .../references/customization-flow.md | 28 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 106 -- .../SKILL.md | 4 - .../references/customization-flow.md | 21 - .../references/customization.template.yaml | 3 - .../scripts/customization_config.py | 213 --- tests/repository-maintenance-e2e.fsx | 12 +- uv.lock | 328 ----- 555 files changed, 1661 insertions(+), 38039 deletions(-) delete mode 100644 .github/workflows/validate-socket.yml delete mode 100644 ACCESSIBILITY.md delete mode 100644 docs/maintainers/python-skills-plugin-plan.md delete mode 100644 plugins/agent-portability-skills/.codex/agents/skills-repo-guidance-sync.toml delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/SKILL.md delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/agents/openai.yaml delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md delete mode 100644 plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py create mode 100644 plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx delete mode 100755 plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check_acp_registry.py delete mode 100644 plugins/agent-portability-skills/skills/sync-skills-repo-guidance/SKILL.md delete mode 100644 plugins/agent-portability-skills/skills/sync-skills-repo-guidance/agents/openai.yaml delete mode 100644 plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/source-order.md delete mode 100644 plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/sync-checklist.md delete mode 100644 plugins/agent-portability-skills/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py delete mode 100644 plugins/agentdeck/hooks/capture-session-start.sh create mode 100644 plugins/agentdeck/hooks/run-thread-title-hook.fsx delete mode 100644 plugins/agentdeck/hooks/run-thread-title-hook.sh delete mode 100755 plugins/apple-dev-skills/.github/scripts/sync_shared_snippets.sh delete mode 100644 plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh delete mode 100644 plugins/apple-dev-skills/.github/scripts/validate_skill_creator_contract.py delete mode 100644 plugins/apple-dev-skills/docs/maintainers/customization-consolidation-review.md create mode 100644 plugins/apple-dev-skills/shared/workflow-planner.fsx delete mode 100644 plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization.template.yaml delete mode 120000 plugins/apple-dev-skills/skills/app-extension-architecture-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/app-intents-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/app-intents-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/app-intents-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/apple-image-representation-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/apple-typography-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization.template.yaml delete mode 100644 plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/avaudio-engine-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/avfaudio-session-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/camera-capture-depth-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/core-animation-layer-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/core-image-processing-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/diagnose-apple-entitlements/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/customization_config.py delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_api_probe.py delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_match.py delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_refresh.py delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_install.py delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_search.py create mode 100644 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization.template.yaml delete mode 100644 plugins/apple-dev-skills/skills/feedback-assistant-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml delete mode 120000 plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/format-swift-sources/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/format-swift-sources/references/customization.template.yaml delete mode 100644 plugins/apple-dev-skills/skills/format-swift-sources/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/format-swift-sources/scripts/export-swiftformat-xcode-config.fsx delete mode 100644 plugins/apple-dev-skills/skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py delete mode 100644 plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/linux-development-vm-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/macos-development-vm-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/macos-distribution-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/mailkit-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/mailkit-workflow/references/customization.template.yaml delete mode 120000 plugins/apple-dev-skills/skills/mailkit-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/photos-library-editing-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/safari-extension-control-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/safari-mcp-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/sf-symbols-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/structure-swift-sources/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/structure-swift-sources/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/structure-swift-sources/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize-swift-structure.fsx delete mode 100755 plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_swift_file_headers.py delete mode 100755 plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_todo_fixme_ledgers.py create mode 100644 plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swiftdata-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swiftui-animation-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swiftui-liquid-glass/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/swiftui-performance-audit/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/tipkit-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/tipkit-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/tipkit-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/tips-helpviewer-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/tvos-app-experience-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/tvos-media-playback-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/video-codec-processing-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/virtualization-framework-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/vision-image-analysis-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect-xcode-managed-scope.fsx delete mode 100755 plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect_xcode_managed_scope.sh create mode 100644 plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run_workflow.py delete mode 100644 plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/xcode-localization-workflow/scripts/customization_config.py delete mode 100644 plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization-flow.md delete mode 100644 plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization.template.yaml delete mode 100755 plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/customization_config.py create mode 100644 plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect-xcode-managed-scope.fsx delete mode 100755 plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect_xcode_managed_scope.sh create mode 100644 plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run-workflow.fsx delete mode 100755 plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run_workflow.py delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/client-external-image.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/connect-existing-endpoint.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/cpu-gpu-pipeline.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/dev-loop-iteration.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/fixtures/dev-loop/main.py delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/lb-multi-route-api.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/flash/evals/qb-gpu-function.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/cpu-pod-create.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/hub-deploy-serverless.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/image-to-template-to-serverless.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/pod-auto-terminate.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/pod-from-template-with-volume.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/pod-ssh-connect.eval.md delete mode 100644 plugins/cloud-inference-skills/skills/runpodctl/evals/serverless-autoscale-by-requests.eval.md delete mode 100755 plugins/cybersecurity-skills/scripts/validate_repo_metadata.py create mode 100644 plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx delete mode 100644 plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py create mode 100644 plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.json delete mode 100644 plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.yaml create mode 100644 plugins/model-lab-skills/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx delete mode 100644 plugins/model-lab-skills/skills/design-model-experiment/scripts/validate_experiment_manifest.py create mode 100644 plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare-eval-runs.fsx delete mode 100644 plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare_eval_runs.py delete mode 100644 plugins/python-skills/scripts/__init__.py delete mode 100755 plugins/python-skills/scripts/validate_repo_metadata.py delete mode 100644 plugins/python-skills/shared/bootstrap-contract.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/SKILL.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/assets/README.md.tmpl delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/assets/profiles/init_fastmcp_service.config.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/references/customization.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/references/fastmcp-docs-lookup.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/references/interactive-customization.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-mcp-service/references/mcp-mapping-guidelines.md delete mode 100755 plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/assess_api_for_mcp.py delete mode 100755 plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/init_fastmcp_service.sh delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/SKILL.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/assets/README.md.tmpl delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/assets/profiles/init_python_service.config.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/references/conventions.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/references/customization.md delete mode 100644 plugins/python-skills/skills/bootstrap-python-service/references/interactive-customization.md delete mode 100755 plugins/python-skills/skills/bootstrap-python-service/scripts/init_python_service.sh delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/SKILL.md delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/README.md.tmpl delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_project.config.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_workspace.config.yaml delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/references/customization.md delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/references/interactive-customization.md delete mode 100644 plugins/python-skills/skills/bootstrap-uv-python-workspace/references/uv-command-recipes.md delete mode 100755 plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_project.sh delete mode 100755 plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh delete mode 100644 plugins/python-skills/skills/build-python-agent-service/SKILL.md delete mode 100644 plugins/python-skills/skills/build-python-agent-service/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/build-python-project/SKILL.md delete mode 100644 plugins/python-skills/skills/build-python-project/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/choose-python-project-shape/SKILL.md delete mode 100644 plugins/python-skills/skills/choose-python-project-shape/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/fastapi-service-workflow/SKILL.md delete mode 100644 plugins/python-skills/skills/fastapi-service-workflow/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/fastmcp-service-workflow/SKILL.md delete mode 100644 plugins/python-skills/skills/fastmcp-service-workflow/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/integrate-fastapi-fastmcp/SKILL.md delete mode 100644 plugins/python-skills/skills/integrate-fastapi-fastmcp/agents/openai.yaml delete mode 100644 plugins/python-skills/skills/integrate-fastapi-fastmcp/references/integration-patterns.md delete mode 100644 plugins/python-skills/skills/integrate-fastapi-fastmcp/references/official-docs.md delete mode 100644 plugins/python-skills/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml delete mode 100644 plugins/python-skills/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml delete mode 100644 plugins/python-skills/skills/python-testing-workflow/references/customization.md delete mode 100644 plugins/python-skills/skills/python-testing-workflow/references/interactive-customization.md delete mode 100755 plugins/python-skills/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh delete mode 100755 plugins/python-skills/skills/python-testing-workflow/scripts/run_pytest_uv.sh delete mode 100755 plugins/reverse-engineering-skills/scripts/validate_repo_metadata.py create mode 100644 plugins/server-side-swift/skills/workspace-service-component/scripts/run-workflow.fsx delete mode 100755 plugins/server-side-swift/skills/workspace-service-component/scripts/run_workflow.py delete mode 100644 pyproject.toml delete mode 100755 scripts/audit_skill_surfaces.py delete mode 100644 scripts/audit_xcode_plugin_compatibility.py delete mode 100644 scripts/cleanup_legacy_socket_installs.py delete mode 100644 scripts/export_hermes_skills.py delete mode 100755 scripts/release.sh delete mode 100755 scripts/release_version.py delete mode 100644 scripts/release_workflow.py create mode 100644 scripts/repo-maintenance/syncing/10-managed-repository-assets.fsx create mode 100644 scripts/repo-maintenance/syncing/30-apple-workflow-runtime.fsx delete mode 100755 scripts/spi_add_package.py delete mode 100644 scripts/validate_claude_compatibility.py delete mode 100644 scripts/validate_hermes_compatibility.py delete mode 100644 scripts/validate_socket.py delete mode 100644 scripts/validate_socket_metadata.py delete mode 100644 scripts/validate_socket_skill_metadata.py delete mode 100644 skills/app-extension-architecture-workflow/references/customization-flow.md delete mode 100644 skills/app-extension-architecture-workflow/references/customization.template.yaml delete mode 100755 skills/app-extension-architecture-workflow/scripts/customization_config.py delete mode 100644 skills/bootstrap-skills-plugin-repo/SKILL.md delete mode 100644 skills/bootstrap-skills-plugin-repo/agents/openai.yaml delete mode 100644 skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md delete mode 100644 skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md delete mode 100644 skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md delete mode 100644 skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py create mode 100644 skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx delete mode 100755 skills/bootstrap-xcode-workspace/scripts/run_workflow.py delete mode 100644 skills/build-python-agent-service/SKILL.md delete mode 100644 skills/build-python-agent-service/agents/openai.yaml delete mode 100644 skills/choose-macos-virtualization-shape/references/customization-flow.md delete mode 100644 skills/choose-macos-virtualization-shape/references/customization.template.yaml delete mode 100755 skills/choose-macos-virtualization-shape/scripts/customization_config.py create mode 100644 skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx delete mode 100644 skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py create mode 100644 skills/design-model-experiment/assets/experiment-manifest.json delete mode 100644 skills/design-model-experiment/assets/experiment-manifest.yaml create mode 100644 skills/design-model-experiment/scripts/validate-experiment-manifest.fsx delete mode 100644 skills/design-model-experiment/scripts/validate_experiment_manifest.py delete mode 100644 skills/diagnose-apple-entitlements/references/customization-flow.md delete mode 100644 skills/diagnose-apple-entitlements/references/customization.template.yaml delete mode 100755 skills/diagnose-apple-entitlements/scripts/customization_config.py create mode 100644 skills/evaluate-language-model/scripts/compare-eval-runs.fsx delete mode 100644 skills/evaluate-language-model/scripts/compare_eval_runs.py delete mode 100644 skills/fastapi-service-workflow/SKILL.md delete mode 100644 skills/fastapi-service-workflow/agents/openai.yaml delete mode 100644 skills/fastmcp-service-workflow/SKILL.md delete mode 100644 skills/fastmcp-service-workflow/agents/openai.yaml delete mode 100644 skills/file-provider-and-finder-sync-workflow/references/customization-flow.md delete mode 100644 skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml delete mode 100755 skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py delete mode 100644 skills/linux-development-vm-workflow/references/customization-flow.md delete mode 100644 skills/linux-development-vm-workflow/references/customization.template.yaml delete mode 100755 skills/linux-development-vm-workflow/scripts/customization_config.py delete mode 100644 skills/macos-development-vm-workflow/references/customization-flow.md delete mode 100644 skills/macos-development-vm-workflow/references/customization.template.yaml delete mode 100755 skills/macos-development-vm-workflow/scripts/customization_config.py delete mode 100644 skills/macos-privacy-permissions-workflow/references/customization-flow.md delete mode 100644 skills/macos-privacy-permissions-workflow/references/customization.template.yaml delete mode 100755 skills/macos-privacy-permissions-workflow/scripts/customization_config.py delete mode 100644 skills/macos-sandbox-file-access-workflow/references/customization-flow.md delete mode 100644 skills/macos-sandbox-file-access-workflow/references/customization.template.yaml delete mode 100755 skills/macos-sandbox-file-access-workflow/scripts/customization_config.py delete mode 100644 skills/mailkit-workflow/references/customization-flow.md delete mode 100644 skills/mailkit-workflow/references/customization.template.yaml delete mode 100755 skills/mailkit-workflow/scripts/customization_config.py create mode 100644 skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx delete mode 100755 skills/operate-acp-agent-integration/scripts/check_acp_registry.py delete mode 100644 skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml delete mode 100644 skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml delete mode 100644 skills/python-testing-workflow/references/customization.md delete mode 100644 skills/python-testing-workflow/references/interactive-customization.md delete mode 100755 skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh delete mode 100755 skills/python-testing-workflow/scripts/run_pytest_uv.sh delete mode 100644 skills/safari-mcp-workflow/references/customization-flow.md delete mode 100644 skills/safari-mcp-workflow/references/customization.template.yaml delete mode 100755 skills/safari-mcp-workflow/scripts/customization_config.py delete mode 100644 skills/swift-package-extension-workflow/references/customization-flow.md delete mode 100644 skills/swift-package-extension-workflow/references/customization.template.yaml delete mode 100755 skills/swift-package-extension-workflow/scripts/customization_config.py create mode 100644 skills/swift-package-extension-workflow/scripts/run-workflow.fsx delete mode 100755 skills/swift-package-extension-workflow/scripts/run_workflow.py delete mode 100644 skills/sync-skills-repo-guidance/SKILL.md delete mode 100644 skills/sync-skills-repo-guidance/agents/openai.yaml delete mode 100644 skills/sync-skills-repo-guidance/references/source-order.md delete mode 100644 skills/sync-skills-repo-guidance/references/sync-checklist.md delete mode 100644 skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py delete mode 100644 skills/tvos-app-experience-workflow/references/customization-flow.md delete mode 100644 skills/tvos-app-experience-workflow/references/customization.template.yaml delete mode 100755 skills/tvos-app-experience-workflow/scripts/customization_config.py delete mode 100644 skills/tvos-media-playback-workflow/references/customization-flow.md delete mode 100644 skills/tvos-media-playback-workflow/references/customization.template.yaml delete mode 100755 skills/tvos-media-playback-workflow/scripts/customization_config.py delete mode 100644 skills/virtualization-framework-workflow/references/customization-flow.md delete mode 100644 skills/virtualization-framework-workflow/references/customization.template.yaml delete mode 100755 skills/virtualization-framework-workflow/scripts/customization_config.py delete mode 100644 uv.lock diff --git a/.github/workflows/validate-socket.yml b/.github/workflows/validate-socket.yml deleted file mode 100644 index aa4a34d0a..000000000 --- a/.github/workflows/validate-socket.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Validate Socket - -on: - push: - branches: - - main - pull_request: - -jobs: - validate: - runs-on: macos-latest - - steps: - - name: Check out repository - # This is a validated floor, not a ceiling; update to newer stable official versions when validated. - uses: actions/checkout@v6.0.2 - - - name: Set up uv - # This is a validated floor, not a ceiling; update to newer stable official versions when validated. - uses: astral-sh/setup-uv@v8.0.0 - - - name: Sync root maintainer environment - run: uv sync --dev - - - name: Run full Socket validation - run: uv run scripts/validate_socket.py --profile full diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md deleted file mode 100644 index 8ba22bd82..000000000 --- a/ACCESSIBILITY.md +++ /dev/null @@ -1,180 +0,0 @@ -# Accessibility - -Accessibility expectations for the `socket` superproject's root documentation, maintainer workflows, and metadata surfaces. - -## Table of Contents - -- [Overview](#overview) -- [Standards Baseline](#standards-baseline) -- [Accessibility Architecture](#accessibility-architecture) -- [Engineering Workflow](#engineering-workflow) -- [Known Gaps](#known-gaps) -- [User Support and Reporting](#user-support-and-reporting) -- [Verification and Evidence](#verification-and-evidence) - -## Overview - -### Status - -`socket` targets accessible documentation and maintainer-facing project surfaces, but it does not claim verified conformance to a formal accessibility standard. - -### Scope - -This document covers the root superproject surfaces in this repository: - -- Markdown documentation such as [README.md](./README.md), [AGENTS.md](./AGENTS.md), [ROADMAP.md](./ROADMAP.md), and root maintainer docs under [`docs/`](./docs/) -- Root repository metadata such as [`.agents/plugins/marketplace.json`](./.agents/plugins/marketplace.json) -- Root maintainer automation such as [`scripts/validate_socket_metadata.py`](./scripts/validate_socket_metadata.py) and [`.github/workflows/validate-socket.yml`](./.github/workflows/validate-socket.yml) - -This document does not redefine accessibility policy for child repositories under [`plugins/`](./plugins/). Those repositories may maintain their own narrower accessibility contracts. - -### Accessibility Goals - -The root `socket` layer should stay understandable and operable for maintainers working through text-first tools, GitHub, local editors, terminals, and assistive technologies. In practice, that means keeping root docs structurally clear, preserving meaningful headings and lists, using descriptive link text and human-readable log output, and avoiding root-level workflow changes that make the superproject harder to navigate non-visually. - -## Standards Baseline - -### Target Standard - -For the root superproject layer, `socket` targets WCAG 2.2 AA principles as a documentation and workflow baseline where they reasonably apply to repository content, static documentation, generated logs, and maintainer-facing automation output. - -### Conformance Language Rules - -This repository may say that its root documentation and maintainer workflow surfaces target WCAG 2.2 AA-inspired practices. It must not claim formal WCAG conformance, legal compliance, or audited accessibility status for the superproject unless that claim is backed by documented review evidence in this file. - -### Supported Platforms and Surfaces - -This accessibility contract currently applies to: - -- GitHub-rendered Markdown for the root repository -- local Markdown viewing in ordinary editors -- terminal-readable output from the root validation script -- CI logs produced by the root GitHub Actions workflow - -Because `socket` is a superproject rather than a shipped application, there is no separate root web UI, desktop UI, or mobile UI covered by this document today. - -## Accessibility Architecture - -### Semantic Structure - -Root documentation should keep real heading hierarchy, ordered and unordered lists, fenced code blocks, and meaningful section titles so GitHub, editors, and assistive technologies can expose the structure correctly. Docs should prefer descriptive links over bare URLs where the link target has a specific role in the repo. - -### Input and Keyboard Model - -The root repository should remain fully usable from keyboard-first environments. Contributor workflows at the root level should not depend on pointer-only interactions, drag-and-drop-only actions, or GUI-only steps when an equivalent documented terminal path exists. - -### Focus Management - -`socket` does not ship an interactive root UI with custom focus management. The practical focus rule here is to preserve clear reading order in Markdown, avoid broken heading jumps, and keep docs organized so keyboard and screen-reader users can move predictably through the content. - -### Naming and Announcements - -Root scripts, workflow steps, validation failures, and documentation headings should use descriptive names that make the surface understandable without extra visual context. Operator-facing messages should identify what broke, which file or surface is involved, and the likely cause instead of using vague labels. - -### Color, Contrast, and Motion - -The root superproject should not rely on color alone to communicate meaning in documentation or generated summaries. Screenshots, diagrams, and other visual artifacts should include text labels or surrounding context that does not depend on color perception. Root docs should avoid animation-dependent explanations. - -### Zoom, Reflow, and Responsive Behavior - -Root documentation should remain readable under normal browser zoom and narrow layout behavior on GitHub. Prefer short paragraphs, flat list structures, and code blocks that are still understandable when horizontally scrolled. - -### Media, Captions, and Alternatives - -Root media assets live under [`docs/media/`](./docs/media/). Contributors should provide meaningful alt text plus adjacent text that explains the point of each screenshot, diagram, or recorded demo. If audio or video is ever added at the root level, include captions or a transcript where practical. - -## Engineering Workflow - -### Design and Implementation Rules - -When editing root docs or maintainer automation: - -- preserve heading structure and intentional document organization -- use descriptive labels for scripts, workflow steps, and validation errors -- keep command examples copyable and text-complete -- avoid adding root-level workflows that require inaccessible or undocumented GUI-only steps -- update accessibility-relevant root docs in the same pass when the superproject workflow meaningfully changes - -### Automated Testing - -The root repository does not currently run automated accessibility auditing tools. Its current automated evidence is structural: - -- `uv run scripts/validate_socket_metadata.py` validates that the root marketplace wiring is present, readable, and correctly aligned with packaged plugin manifests -- [`.github/workflows/validate-socket.yml`](./.github/workflows/validate-socket.yml) runs full Socket validation in CI on pushes to `main` and on pull requests - -### Manual Testing - -For accessibility-relevant root changes, contributors should manually review: - -- heading hierarchy and section ordering in edited Markdown -- link text and table-of-contents accuracy -- code-block readability and command accuracy -- image alt text, adjacent media explanations, and relative media paths -- terminal output from root scripts for clarity and ambiguity -- GitHub-rendered formatting when a change significantly reshapes a root document - -### Assistive Technology Coverage - -The root repository does not currently keep a formally documented assistive-technology test matrix. The expected baseline is that root docs and script output remain usable in text-first environments, including screen readers that rely on semantic Markdown structure and terminals that expose plain text output. - -### Definition of Done - -Root documentation or maintainer-workflow changes are not ready for review until: - -- the changed surface is structurally readable and semantically organized -- any new operator-facing message is descriptive and unambiguous -- root validation commands still pass when the change affects root automation or marketplace metadata -- this file is updated in the same pass if the root accessibility contract, known gaps, or verification story materially changed - -## Known Gaps - -### Current Exceptions - -Current root-level gaps and limits: - -- `socket` does not yet run automated accessibility tooling against its Markdown docs -- the root repo does not maintain a formal assistive-technology compatibility matrix -- accessibility expectations for child repositories are only covered here at the boundary level, not enforced uniformly across all nested repositories - -### Planned Remediation - -Known gaps should be addressed when the root maintainer workflow grows enough to justify stronger checks. That may include adding doc-focused linting or a documented manual review checklist if the superproject starts carrying more user-facing policy surfaces. - -### Ownership - -The root repository maintainers are responsible for keeping this document accurate when the root superproject workflow changes. Changes that materially affect root docs, validation, or maintainer automation should update this file in the same pass when the accessibility contract meaningfully changes. - -## User Support and Reporting - -### Feedback Path - -Use the root repository's normal GitHub collaboration surfaces to report accessibility issues in the superproject layer. In practice, that usually means opening a GitHub issue or pull request against `gaelic-ghost/socket` with enough detail to identify the affected root doc, workflow, script output, or metadata surface. - -### Triage Expectations - -Accessibility reports should be treated as ordinary quality issues for the root superproject and scoped to the affected root surface first. If the report is really about a child repository under [`plugins/`](./plugins/), move the follow-up into the appropriate child repo or child-repo document set instead of leaving the concern ambiguously tracked at the superproject layer. - -## Verification and Evidence - -### CI Signals - -Current root CI evidence: - -- [`.github/workflows/validate-socket.yml`](./.github/workflows/validate-socket.yml) -- `uv run scripts/validate_socket_metadata.py` - -These checks validate structural integrity for root marketplace metadata. They do not, by themselves, prove accessibility conformance. - -### Audit Cadence - -Root accessibility review should happen whenever: - -- the root documentation structure changes substantially -- the root validator output changes materially -- the root GitHub workflow changes in a way that affects maintainer operability -- this document becomes stale relative to the root repo's actual workflow - -### Review History - -- 2026-05-02: Added root screenshot guidance after introducing README media under `docs/media/`. -- 2026-04-14: Added the first root `ACCESSIBILITY.md` for the `socket` superproject and documented the root-only accessibility boundary around docs, metadata, and maintainer automation. diff --git a/AGENTS.md b/AGENTS.md index 166574469..3a098821e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,11 @@ root documentation, and cross-plugin policy. - Authored child `skills/`, `mcps/`, `apps/`, and equivalent surfaces are source. - Plugin manifests and marketplace files are packaging metadata. +- Reusable scripts, templates, and contracts are versioned managed assets that + the owning install or sync recipe copies into their canonical target paths. +- Gale's repository preferences are hard-coded in owning assets and skill + contracts. Do not add per-user preference files, layered customization, + runtime config merging, or alternate policy paths. - Installed plugins, caches, enabled-state configuration, and consumer copies are runtime state, not editable source. - When documentation and automation disagree, correct the owning source rather diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7c3ba0be..b230a50a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,8 +109,9 @@ skills, marketplaces, documentation, and reports. ### Accessibility Expectations -Follow [ACCESSIBILITY.md](./ACCESSIBILITY.md). Keep commands, logs, headings, -links, and errors readable and actionable. +Keep commands, logs, headings, links, errors, and user-facing behavior readable +and actionable. Record product-specific accessibility requirements beside the +surface that owns them; do not create a separate root accessibility contract. ### Verification diff --git a/README.md b/README.md index 6105dcb58..754bb35c0 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Current Socket catalog shape: - `game-dev-skills`: Apple platform game development workflows for native Metal and Metal 4 renderers, GPTK 3/4 routing, MetalFX, GPU asset streaming, experimental neural rendering, SpriteKit, SceneKit, GameplayKit simulation, Game Controller input, Core Haptics feedback, Xcode profiling, game-stack routing, and device-aware validation handoffs - `network-protocol-skills`: modern networking and application-protocol workflows for transport selection, HTTP/3 and QUIC planning, Media over QUIC draft-aware guidance, WebRTC signaling/media/data-channel work, and protocol diagnostics with stack-plugin handoffs - `professional-skills`: career and professional workflow guidance, starting with Dice job search and its bundled read-only remote MCP configuration -- `python-skills`: Python runtime and tooling workflows, including local-first agent services, FastAPI and FastMCP service maintenance, and pytest-based testing; see the [Python skills expansion plan](./docs/maintainers/python-skills-plugin-plan.md) for maintainer details +- `python-skills`: focused diagnostics, packaging, tooling, CI, upgrade, and testing guidance for existing Python code - `repository-skills`: routed Git and GitHub collaboration, README, CONTRIBUTING, AGENTS, ROADMAP, repository settings, protected-main release, and Codex GUI worktree workflow guidance; its portable repository and diff --git a/ROADMAP.md b/ROADMAP.md index 07c70dbad..458da2059 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ - [Product Principles](#product-principles) - [Milestone Progress](#milestone-progress) - [Milestone 6: Dotnet skills plugin](#milestone-6-dotnet-skills-plugin) -- [Milestone 7: Python skills plugin expansion](#milestone-7-python-skills-plugin-expansion) +- [Milestone 7: Python skills plugin consolidation](#milestone-7-python-skills-plugin-consolidation) - [Milestone 8: Server-Side Swift skills plugin](#milestone-8-server-side-swift-skills-plugin) - [Milestone 9: Rust skills plugin](#milestone-9-rust-skills-plugin) - [Milestone 10: Expo inline native modules workflow](#milestone-10-expo-inline-native-modules-workflow) @@ -53,7 +53,7 @@ ## Milestone Progress - Milestone 6: Dotnet skills plugin - Completed -- Milestone 7: Python skills plugin expansion - Completed +- Milestone 7: Python skills plugin consolidation - Completed - Milestone 8: Server-Side Swift skills plugin - Completed - Milestone 9: Rust skills plugin - Completed - Milestone 10: Expo inline native modules workflow - Completed @@ -104,7 +104,7 @@ Completed - [x] Route F# web application Azure work through Microsoft's official Azure Skills plugin rather than duplicating Azure MCP, Azure CLI, Azure Developer CLI, or deployment guidance in Socket. - [x] Switch the root marketplace entry for `dotnet-skills` from placeholder to installable only after real skill content exists. - [x] Update root README and maintainer docs so users understand the new installable child plugin surface. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py` and any child-plugin checks added by the new plugin. +- [x] Run root metadata validation with `just repo-validate` and any child-plugin checks added by the new plugin. ### Exit Criteria @@ -115,7 +115,7 @@ Completed - [x] The F# web guidance distinguishes Giraffe, Falco, and Oxpecker contracts while preserving ordinary ASP.NET Core and F# domain boundaries. - [x] Root Socket docs, marketplace wiring, and validation agree on the plugin's install surface. -## Milestone 7: Python skills plugin expansion +## Milestone 7: Python skills plugin consolidation ### Status @@ -123,25 +123,19 @@ Completed ### Scope -- [x] Repair the `python-skills` child validator so it matches the current monorepo-owned child docs model without reintroducing a child README. -- [x] Record the detailed expansion plan in [`docs/maintainers/python-skills-plugin-plan.md`](./docs/maintainers/python-skills-plugin-plan.md). -- [x] Expand `python-skills` from scaffold-heavy coverage into ongoing project choice, implementation, diagnostics, packaging, tooling/style, CI, and upgrade workflows. -- [x] Keep the existing `uv`, FastAPI, FastMCP, and pytest skill surfaces intact unless a later cleanup deliberately renames or replaces one without leaving duplicate long-term surfaces. +- [x] Keep only diagnostics, packaging, tooling/style, CI, upgrade, and testing guidance for existing Python code. +- [x] Remove Python bootstrap, synchronization, project-creation, FastAPI, FastMCP, and agent-service skills. +- [x] Remove Python and shell automation from the plugin while retaining ordinary Python ecosystem command guidance. ### Tickets -- [x] Update `plugins/python-skills/scripts/validate_repo_metadata.py` and child tests so validation targets `AGENTS.md`, plugin metadata, and skill metadata instead of a removed child `README.md`. -- [x] Add `python-skills:choose-python-project-shape`. -- [x] Add `python-skills:build-python-project`. - [x] Add `python-skills:diagnose-python-project`. - [x] Add `python-skills:python-package-workflow`. - [x] Add `python-skills:python-tooling-style-workflow`. - [x] Add `python-skills:python-ci-workflow`. - [x] Add `python-skills:python-upgrade-workflow`. - [x] Replace `python-skills:uv-pytest-unit-testing` with the broader `python-skills:python-testing-workflow` without a duplicate compatibility surface. -- [x] Update Python plugin metadata after the first new skill slice lands. -- [x] Run child validation with `uv run scripts/validate_repo_metadata.py`, `uv run pytest`, `uv run ruff check .`, and `uv run mypy .`. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Consolidate validation into the root FSX integration and E2E path. ### Exit Criteria @@ -185,7 +179,7 @@ Completed - [x] Update Hummingbird guidance for current `hb` Server and Lambda prompts, generated `swift-configuration`, OpenAPIHummingbird plus `hummingbird-lambda` Lambda shape, and the separate `swift-openapi-lambda` transport distinction. - [x] Add `server-side-swift:sync-hummingbird-service-guidance` for existing Hummingbird repositories that need repo-local `AGENTS.md`, Codex local environment files, `hb` CLI assumptions, Server or Lambda shape, OpenAPI transport notes, and SwiftPM command guidance refreshed. - [x] Update plugin metadata prompts and keywords as new server-side Swift skill surfaces ship. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py` after each metadata or marketplace-facing update. +- [x] Run root metadata validation with `just repo-validate` after each metadata or marketplace-facing update. ### Exit Criteria @@ -216,7 +210,7 @@ Completed - [x] Add package and CI workflow skills for publish-facing and automation guidance. - [x] Switch the root marketplace entry for `rust-skills` from placeholder to installable only after real skill content exists. - [x] Update root README and maintainer docs so users understand the new installable child plugin surface. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -245,7 +239,7 @@ Completed - [x] Update `plugins/web-dev-skills/AGENTS.md` with Expo and React Native native-boundary guidance. - [x] Update `plugins/web-dev-skills/.codex-plugin/plugin.json` metadata so the plugin advertises the Expo inline native module workflow. - [x] Decide whether the root marketplace entry needs to move from placeholder to installable as part of the first implementation slice. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -283,7 +277,7 @@ In Progress - [x] Add a Node stdlib App Server control-socket client for opt-in `thread/name/set` tests. - [x] Wire `agentdeck` into the root Socket marketplace as a normal local child plugin. - [x] Update root README so users can see the new installable plugin surface. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. - [x] Run a hook test from the Codex GUI and inspect `thread-title-decisions.jsonl`. - [x] Install or refresh the plugin locally, trust the hook, start a real new thread, and compare captured `session_id` with the created thread id. - [x] Record the desktop bridge MCP and skill plan in `plugins/agentdeck/docs/desktop-bridge-mcp-skill-plan.md`. @@ -381,7 +375,7 @@ Completed - [x] Add version-sensitive dyld-cache, Apple dynamic-analysis, kernel/boot/firmware, and security-research reporting workflows with exact-build and live-source gates. - [x] Add .NET and Unity artifact workflows without delaying the Apple-focused sequence. - [x] Switch the root marketplace entry to installable only after real skill content exists. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -444,7 +438,7 @@ Completed - [x] Add `android-dev:release-readiness-workflow` for versioning, signing, release builds, R8/ProGuard, app bundles, APKs, Play delivery handoffs, permissions, privacy checks, and release automation routing. - [x] Update plugin metadata after real skills land, including `skills`, keywords, prompts, and accurate installable descriptions. - [x] Switch the root marketplace entry to installable only after real skill content exists. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -483,7 +477,7 @@ In Progress - [ ] Keep `server-side-jvm:build-clojure-service` as a future candidate until the Java and Scala foundations are stable. - [x] Update plugin metadata after real skills land, including `skills`, keywords, prompts, and accurate installable descriptions. - [x] Switch the root marketplace entry to installable only after real skill content exists. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -514,7 +508,7 @@ In Progress - [x] Add `choose-agent-integration-protocol`, `operate-acp-agent-integration`, `build-acp-agent`, and `operate-zed-agent` for cross-host direction selection, ACP operation/development, and Zed native/external/terminal workflows. - [x] Add `operate-a2a-agent-integration` for peer Agent Card discovery, message/task lifecycle, streaming and push delivery, authentication, trust boundaries, and Hermes 0.20 A2A routing without conflating A2A with ACP or MCP. - [x] Refresh Hermes 0.20, current ACP v1 versus draft RFDs, Zed custom external-agent setup, Nous services, Codex and Claude plugin surfaces, and Xcode 27 Beta 5 headless MCP evidence for the 9.31.0 compatibility release. -- [x] Validate exported Hermes metadata, grouping integrity, generated export freshness, and maintained MCP examples with `uv run scripts/validate_hermes_compatibility.py`. +- [x] Validate exported Hermes metadata, grouping integrity, generated export freshness, and maintained MCP examples with `just repo-validate`. - [x] Make an explicit Codex-and-Hermes compatibility classification mandatory for every new or materially changed Socket plugin, skill, and MCP declaration. Require portable-skill export decisions, validated MCP translations, and a real native-plugin design or host-specific boundary for runtime surfaces. ### Tickets @@ -573,7 +567,7 @@ Completed - [x] Wire `swift-lang` into the root Socket marketplace as an installable child plugin. - [x] Update Apple Dev and Server-Side Swift guidance to hand off shared Swift cleanup work to `swift-lang` when it is available. - [x] Keep Apple Dev's existing `format-swift-sources` and `structure-swift-sources` available during the first release so standalone Apple-only installs do not break. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py` and any new child-plugin validation added for `swift-lang`. +- [x] Run root metadata validation with `just repo-validate` and any new child-plugin validation added for `swift-lang`. ### Exit Criteria @@ -605,7 +599,7 @@ In Progress - [ ] Add later skills for architecture mapping, adoption-risk decisions, and remediation planning after the first two workflows prove useful. - [ ] Wire the plugin into the root marketplace as `NOT_AVAILABLE` while it is a placeholder, then switch it to installable only after real skill content exists. - [ ] Update root README and ROADMAP when the plugin becomes installable. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -642,7 +636,7 @@ Completed - [x] Wire `game-dev-skills` into the root Socket marketplace as an installable child plugin. - [x] Update root README and ROADMAP so users understand the new plugin surface. - [x] Run skill-folder validation and plugin-manifest validation for the new child plugin. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -675,7 +669,7 @@ Completed - [x] Wire `cloud-deployment-skills` into the root Socket marketplace as an installable child plugin. - [x] Update root README and ROADMAP so users understand the new plugin surface and the AWS delegation decision. - [x] Update root README and maintainer guidance so users understand the Azure Skills delegation decision and F# web-framework handoff. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -744,7 +738,7 @@ Completed - [x] Install Runpod's upstream `companion-clis`, `flash`, and `runpodctl` skills into the exported plugin `skills/` tree, with `.agents/skills` kept as a symlink discovery mirror. - [x] Wire `cloud-inference-skills` into the root Socket marketplace as an installable child plugin. - [x] Update root README, CONTRIBUTING, and ROADMAP so users and maintainers understand the new plugin surface. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ### Exit Criteria @@ -1286,5 +1280,5 @@ and test/production deployments for GitHub Actions. - Completed Milestone 1, `superproject docs and marketplace alignment`, by bringing the root README, AGENTS guidance, roadmap shape, and marketplace-path explanation back into alignment with the live mixed-monorepo model. - Added the first root `ROADMAP.md` and established the checklist-style planning format for the superproject. - Added a root marketplace-validation script and GitHub Actions workflow so `socket` now checks packaged plugin paths and manifest alignment instead of leaving that audit entirely manual. -- Added root `CONTRIBUTING.md`, `ACCESSIBILITY.md`, `LICENSE`, and `NOTICE` so the superproject's contributor, accessibility, and legal surfaces are explicit at the repository root. +- Added root contributor and legal surfaces; the later managed-docs migration removed the obsolete standalone accessibility contract. - Collapsed the older subtree migration and plugin-alignment planning docs into this roadmap history plus the still-live root maintainer references once those plans had become historical rather than active operating guidance. diff --git a/Socket.xcworkspace/contents.xcworkspacedata b/Socket.xcworkspace/contents.xcworkspacedata index f80a6c491..da4cfe7fc 100644 --- a/Socket.xcworkspace/contents.xcworkspacedata +++ b/Socket.xcworkspace/contents.xcworkspacedata @@ -13,9 +13,6 @@ <FileRef location = "group:AGENTS.md"> </FileRef> - <FileRef - location = "group:ACCESSIBILITY.md"> - </FileRef> <FileRef location = "group:.agents/plugins/marketplace.json"> </FileRef> diff --git a/docs/agents/README.md b/docs/agents/README.md index 31a622dcf..ce5b922a0 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -19,17 +19,16 @@ Guidelines: - Remove or archive stale reports once their durable conclusions move into the owning docs. -## Check-Only Skill Surface Audit +## Repository Evidence -Use the root skill-surface audit when a maintainer or Codex automation needs a -fresh token-efficiency and drift snapshot without editing skills: +Use the root managed validation when a maintainer or Codex automation needs a +fresh integration and drift result without editing skills: ```bash -uv run scripts/audit_skill_surfaces.py \ - --top 10 \ - --output docs/agents/skill-surface-audit.md +just repo-validate +just test ``` -Treat the generated report as review material. Move durable conclusions into the -owning roadmap, maintainer docs, validation scripts, or skill sources before -considering the report resolved. +Treat generated reports as review material. Move durable conclusions into the +owning roadmap, maintainer docs, managed FSX validation, or skill sources before +considering a report resolved. diff --git a/docs/maintainers/agents-guidance-consolidation-plan.md b/docs/maintainers/agents-guidance-consolidation-plan.md index 565043ea3..7023010a8 100644 --- a/docs/maintainers/agents-guidance-consolidation-plan.md +++ b/docs/maintainers/agents-guidance-consolidation-plan.md @@ -205,7 +205,7 @@ Apply the consolidation by current root section, not by ad hoc sentence edits: Run validation serially: ```bash -uv run scripts/validate_socket.py --profile compatibility +just repo-validate ``` Also perform a manual scenario review for: diff --git a/docs/maintainers/claude-compatibility.md b/docs/maintainers/claude-compatibility.md index 6b0a52702..59e375e65 100644 --- a/docs/maintainers/claude-compatibility.md +++ b/docs/maintainers/claude-compatibility.md @@ -112,9 +112,9 @@ similarly named Codex or Xcode surface. and a temporary-home install smoke test. ```bash -uv run scripts/validate_socket_metadata.py -uv run scripts/validate_hermes_compatibility.py -uv run scripts/validate_claude_compatibility.py +just repo-sync +just repo-validate +just test claude plugin validate . ``` diff --git a/docs/maintainers/cloud-deployment-skills-plugin-plan.md b/docs/maintainers/cloud-deployment-skills-plugin-plan.md index 8106a0a6b..4944943ef 100644 --- a/docs/maintainers/cloud-deployment-skills-plugin-plan.md +++ b/docs/maintainers/cloud-deployment-skills-plugin-plan.md @@ -31,6 +31,6 @@ The first practical use case is AWS. AWS now publishes the official [`aws/agent- ## Validation -- Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- Run root metadata validation with `just repo-validate`. - Run `git diff --check`. - For release, follow the standard Socket release mode because this is a monorepo-owned child plugin with no subtree synchronization requirement. diff --git a/docs/maintainers/cloud-inference-skills-plugin-plan.md b/docs/maintainers/cloud-inference-skills-plugin-plan.md index 07bf5b9b7..15fb000b5 100644 --- a/docs/maintainers/cloud-inference-skills-plugin-plan.md +++ b/docs/maintainers/cloud-inference-skills-plugin-plan.md @@ -63,4 +63,4 @@ Do not duplicate Hugging Face or AWS setup while their first-party Codex plugins - [x] Install Runpod's upstream `companion-clis`, `flash`, and `runpodctl` skills with `npx skills add runpod/skills`. - [x] Wire `cloud-inference-skills` into the root Socket marketplace as an installable child plugin. - [x] Update root README, CONTRIBUTING, and ROADMAP so users and maintainers understand the new plugin surface. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. diff --git a/docs/maintainers/cybersecurity-skills-plugin-plan.md b/docs/maintainers/cybersecurity-skills-plugin-plan.md index 47ba8063c..c0aaa6d38 100644 --- a/docs/maintainers/cybersecurity-skills-plugin-plan.md +++ b/docs/maintainers/cybersecurity-skills-plugin-plan.md @@ -227,7 +227,7 @@ Forward-test with redistributable, locally generated, or explicitly approved fix - Use official vendor documentation, current tool help, checked-out source, and observed local behavior before community summaries. - Date version-sensitive platform, threat-intelligence, scanner, and beta claims. Require live confirmation when they affect a conclusion. - Do not embed malware, exploit payloads, private samples, VM images, tool databases, cloud keys, machine-local paths, or copied proprietary intelligence in the plugin. -- Validate every skill with the skill-authoring validator and run `uv run scripts/validate_socket_metadata.py` after marketplace or manifest changes. +- Validate every skill with the skill-authoring validator and run `just repo-validate` after marketplace or manifest changes. - Export portable skills through the Hermes tap in the same pass and update `skills.sh.json`; document any host-specific tool or Computer Use workflow instead of pretending the Codex manifest is portable. - Add a checked-in Hermes `mcp_servers` translation only if a later approved `.mcp.json` exists. A guidance-only first release needs no MCP translation or native Hermes plugin. - Update root README inventory text, `ROADMAP.md`, marketplace metadata, and version surfaces together when the plugin becomes installable. diff --git a/docs/maintainers/deferred-work-wakeup-policy.md b/docs/maintainers/deferred-work-wakeup-policy.md index 9fb8779c3..ce83962d1 100644 --- a/docs/maintainers/deferred-work-wakeup-policy.md +++ b/docs/maintainers/deferred-work-wakeup-policy.md @@ -65,8 +65,8 @@ separate completion states. Run these serially after changing this policy or its exported guidance: ```bash -uv run scripts/validate_socket_metadata.py -uv run scripts/validate_hermes_compatibility.py -uv run scripts/export_hermes_skills.py --check +just repo-sync +just repo-validate +just test uv run pytest plugins/repository-skills/skills/maintain-project-repo/tests/test_maintain_project_repo_workflow.py ``` diff --git a/docs/maintainers/dotnet-skills-plugin-plan.md b/docs/maintainers/dotnet-skills-plugin-plan.md index d4d79368d..16baddeea 100644 --- a/docs/maintainers/dotnet-skills-plugin-plan.md +++ b/docs/maintainers/dotnet-skills-plugin-plan.md @@ -276,7 +276,7 @@ The first slice should be intentionally small but installable: - [x] Decide not to add per-skill `agents/openai.yaml` metadata in the first slice because the first plugin slice keeps metadata at the plugin level. - [x] Switch the root marketplace entry for `dotnet-skills` to installable only after real skill content exists. - [x] Update `README.md` and `ROADMAP.md` so Socket documents the new child plugin surface. -- [x] Run `uv run scripts/validate_socket_metadata.py`. +- [x] Run `just repo-validate`. - [x] Run any child-plugin validation added by the new plugin; no child-local validator was added in the first slice. ## Second Implementation Slice diff --git a/docs/maintainers/errorhandles-package-plan.md b/docs/maintainers/errorhandles-package-plan.md index fd629e1f1..3bb838eba 100644 --- a/docs/maintainers/errorhandles-package-plan.md +++ b/docs/maintainers/errorhandles-package-plan.md @@ -104,7 +104,7 @@ For Socket: 1. Keep `plugins/swift-lang` guidance in sync with the package API after it exists. -2. Run `uv run scripts/validate_socket_metadata.py` when skills or plugin +2. Run `just repo-validate` when skills or plugin metadata change. 3. Do not claim the package exists until the standalone repository and fetchable Swift package are created. diff --git a/docs/maintainers/expo-inline-native-modules-skill-plan.md b/docs/maintainers/expo-inline-native-modules-skill-plan.md index bc479066a..5dc84e566 100644 --- a/docs/maintainers/expo-inline-native-modules-skill-plan.md +++ b/docs/maintainers/expo-inline-native-modules-skill-plan.md @@ -194,7 +194,7 @@ For the Socket skill implementation pass: 1. Run root metadata validation: ```bash - uv run scripts/validate_socket_metadata.py + just repo-validate ``` 2. Run child-local validation if `web-dev-skills` gains tests or a validator. diff --git a/docs/maintainers/game-dev-skills-plugin-plan.md b/docs/maintainers/game-dev-skills-plugin-plan.md index e80513c17..c7b02ea09 100644 --- a/docs/maintainers/game-dev-skills-plugin-plan.md +++ b/docs/maintainers/game-dev-skills-plugin-plan.md @@ -122,7 +122,7 @@ Guide carefully gated experimental renderer integration across MetalFX, Metal 4 - [x] Wire `game-dev-skills` into the root Socket marketplace as installable. - [x] Update README and ROADMAP so users understand the new plugin surface. - [x] Run skill-folder validation and plugin-manifest validation for the new child plugin. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ## Open Questions diff --git a/docs/maintainers/hermes-compatibility.md b/docs/maintainers/hermes-compatibility.md index 69cff7dbc..da4c2287b 100644 --- a/docs/maintainers/hermes-compatibility.md +++ b/docs/maintainers/hermes-compatibility.md @@ -29,7 +29,6 @@ workflow, not either host's plugin manifest. Hermes discovers the root `skills/` directory by default after a user adds the Socket tap. The curated set is: -- `bootstrap-skills-plugin-repo` - `build-acp-agent` - `build-hermes-agent-extensions` - `choose-agent-integration-protocol` @@ -40,7 +39,6 @@ Socket tap. The curated set is: - `operate-hermes-agent` - `operate-hermes-agent-gateway` - `operate-zed-agent` -- `sync-skills-repo-guidance` - `use-nous-research-services` - `app-extension-architecture-workflow` - `diagnose-apple-entitlements` @@ -71,9 +69,6 @@ Socket tap. The curated set is: - `evaluate-jailbreak-resilience` - `evaluate-tool-calling-model` - `benchmark-model-runtime` -- `build-python-agent-service` -- `fastapi-service-workflow` -- `fastmcp-service-workflow` - `python-testing-workflow` - `coordinate-external-agents` - `coordinate-worktrees-and-threads` @@ -192,8 +187,8 @@ The exported macOS platform-security workflows are portable instruction contract 5. Regenerate and validate: ```bash - uv run scripts/export_hermes_skills.py - uv run scripts/validate_hermes_compatibility.py + just repo-sync + just repo-validate ``` 6. Run the root metadata validator and relevant tests before review. diff --git a/docs/maintainers/macos-platform-security-skills-plan.md b/docs/maintainers/macos-platform-security-skills-plan.md index 34062bf8a..61f3797f6 100644 --- a/docs/maintainers/macos-platform-security-skills-plan.md +++ b/docs/maintainers/macos-platform-security-skills-plan.md @@ -714,8 +714,8 @@ Run commands strictly serially and from the owning repository root. Planning slice: ```bash -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh -uv run scripts/validate_socket_metadata.py +just repo-validate +just repo-validate ``` Implementation slices add, as applicable: @@ -724,7 +724,7 @@ Implementation slices add, as applicable: cd plugins/apple-dev-skills && uv run pytest cd plugins/reverse-engineering-skills && uv run scripts/validate_repo_metadata.py cd plugins/cybersecurity-skills && uv run scripts/validate_repo_metadata.py -uv run scripts/validate_socket_metadata.py +just repo-validate ``` Also run the repository's current Hermes, Claude/Cowork, architecture, and diff --git a/docs/maintainers/model-lab-skills-plugin-plan.md b/docs/maintainers/model-lab-skills-plugin-plan.md index f2ba25a72..88498e2c2 100644 --- a/docs/maintainers/model-lab-skills-plugin-plan.md +++ b/docs/maintainers/model-lab-skills-plugin-plan.md @@ -400,7 +400,7 @@ plugins/model-lab-skills/ └── skills/ ├── choose-model-lab-workflow/ ├── design-model-experiment/ - │ └── assets/experiment-manifest.yaml + │ └── assets/experiment-manifest.json ├── prepare-language-model-dataset/ │ └── assets/dataset-card.md ├── fine-tune-language-model/ @@ -413,9 +413,9 @@ plugins/model-lab-skills/ Likely deterministic scripts after the contracts settle: -- `validate_experiment_manifest.py` -- `snapshot_model_provenance.py` -- `compare_eval_runs.py` +- `validate-experiment-manifest.fsx` +- `snapshot-model-provenance.fsx` +- `compare-eval-runs.fsx` Do not add these scripts as placeholders. Add and test them when the first implemented skill needs their deterministic behavior. diff --git a/docs/maintainers/plugin-install-testing.md b/docs/maintainers/plugin-install-testing.md index 23696a259..f880bc8c5 100644 --- a/docs/maintainers/plugin-install-testing.md +++ b/docs/maintainers/plugin-install-testing.md @@ -23,7 +23,7 @@ marketplaces. A local checkout marketplace should be added, inspected, removed, and then discarded; trying to upgrade it should fail because it is not a Git marketplace. -During `scripts/release.sh advance X.Y.Z`, the release workflow automates the +During `just repo-release-advance X.Y.Z`, the release workflow automates the Socket local checkout add/remove path below and records the result with the current Dependabot alert query. It writes `.socket-release-evidence.json` in the clean `main` worktree after verifying that `main` matches the reviewed remote diff --git a/docs/maintainers/plugin-packaging-strategy.md b/docs/maintainers/plugin-packaging-strategy.md index d8442897f..9d4f289ad 100644 --- a/docs/maintainers/plugin-packaging-strategy.md +++ b/docs/maintainers/plugin-packaging-strategy.md @@ -69,7 +69,7 @@ The canonical plugin payload in `SpeakSwiftlyServer` should own the Codex-facing The standalone `SpeakSwiftlyServer` repository should also remain the source of truth for the Swift package, executable, LaunchAgent behavior, embedded API, HTTP/MCP implementation, API docs, release notes, and live-service validation. -The Socket catalog entry named `speak-swiftly` points at the Git-backed `gaelic-ghost/SpeakSwiftlyServer` plugin source instead of a local `./plugins/SpeakSwiftlyServer` mirror. Because the plugin root is the repository root, it uses the Codex marketplace source shape for a Git-backed root plugin rather than a `git-subdir` entry. Run the marketplace audit and `uv run scripts/validate_socket_metadata.py` after changes to this entry. +The Socket catalog entry named `speak-swiftly` points at the Git-backed `gaelic-ghost/SpeakSwiftlyServer` plugin source instead of a local `./plugins/SpeakSwiftlyServer` mirror. Because the plugin root is the repository root, it uses the Codex marketplace source shape for a Git-backed root plugin rather than a `git-subdir` entry. Run `just repo-validate` after changes to this entry. Update README, ROADMAP, subtree workflow guidance, and any SpeakSwiftlyServer-facing install docs in the same pass when this catalog model changes so users see one coherent story: Codex users can install `Speak Swiftly` from either the Git-backed `socket` marketplace or the standalone `SpeakSwiftlyServer` marketplace; app embedders use `SpeakSwiftlyServer` as a Swift package. @@ -102,8 +102,8 @@ codex plugin marketplace add gaelic-ghost/SpeakSwiftlyServer When a user has already migrated from an older copied-plugin or personal-local-marketplace install to the Git-backed marketplace, use the repo-owned cleanup helper instead of hand-editing home-directory files: ```bash -uv run scripts/cleanup_legacy_socket_installs.py -uv run scripts/cleanup_legacy_socket_installs.py --apply +just repo-sync +just repo-validate ``` The helper's job is intentionally narrow. It removes known legacy `socket` entries from `~/.agents/plugins/marketplace.json` and copied personal payload directories such as `~/.codex/plugins/apple-dev-skills` after backing them up. It leaves Codex's installed cache under `~/.codex/plugins/cache/` alone, because that cache is Codex-owned install state for current marketplace entries. diff --git a/docs/maintainers/project-audit-skills-plugin-plan.md b/docs/maintainers/project-audit-skills-plugin-plan.md index 97519b36f..8f84a86cb 100644 --- a/docs/maintainers/project-audit-skills-plugin-plan.md +++ b/docs/maintainers/project-audit-skills-plugin-plan.md @@ -131,7 +131,7 @@ score without explaining which files, commands, or observations justify it. - Keep the root marketplace entry `NOT_AVAILABLE` until at least those two real skills exist and validation passes. - Update root README, TODO, and this plan when the plugin becomes installable. -- Run `uv run scripts/validate_socket_metadata.py` after wiring the marketplace +- Run `just repo-validate` after wiring the marketplace entry. ## Open Questions diff --git a/docs/maintainers/python-skills-plugin-plan.md b/docs/maintainers/python-skills-plugin-plan.md deleted file mode 100644 index 22445bc7e..000000000 --- a/docs/maintainers/python-skills-plugin-plan.md +++ /dev/null @@ -1,467 +0,0 @@ -# Python Skills Plugin Expansion Plan - -This plan records the next durable shape for the Socket-hosted `python-skills` plugin. - -The plugin already has a useful scaffold and integration surface. The next job is to make it help agents keep working after the first project exists: choose the right Python project shape, write idiomatic Python, diagnose failures, validate package surfaces, align tooling, and keep CI and upgrades grounded in the same `uv` command vocabulary. - -## Intent - -The `python-skills` plugin should help agents do seven things: - -- choose a Python project shape before scaffolding or implementation starts -- bootstrap reproducible `uv` projects, services, workspaces, tests, FastAPI apps, and FastMCP servers -- write idiomatic Python that respects the repository's package layout, type-checking strictness, configuration model, and test boundaries -- run and explain Python test, lint, format, type-check, package, and diagnostics workflows -- maintain Python packaging metadata without accidentally publishing or relying on machine-local paths -- align local commands, CI checks, and upgrade work around `uv` -- keep FastAPI and FastMCP guidance grounded in official documentation and curated MCP ergonomics - -This remains a companion guidance plugin, not a runtime plugin. Do not add an MCP server, daemon, custom package registry, private template feed, or machine-local interpreter state unless a later plan explicitly approves that scope. - -## Packaging Direction - -Keep the guidance as a monorepo-owned child plugin under: - -```text -plugins/python-skills/ -``` - -The child plugin owns: - -- `.codex-plugin/plugin.json` -- `skills/` -- per-skill `agents/openai.yaml` -- child `AGENTS.md` -- child-local validation scripts and tests for plugin metadata, skill metadata, scaffold smoke tests, and exported workflow contracts - -Do not reintroduce a child `README.md` or per-skill `README.md` files by default. Socket's root README remains the user-facing catalog surface, child `AGENTS.md` remains the child operating contract, and this maintainer plan records expansion decisions. - -## Naming Convention - -Use names that describe what the skill asks the agent to do. - -Prefer action-first names when the skill is primarily a directed action: - -- `choose-python-project-shape` -- `build-python-project` -- `diagnose-python-project` - -Prefer subject-workflow names when the skill is primarily an ongoing maintenance or operating surface: - -- `python-package-workflow` -- `python-tooling-style-workflow` -- `python-ci-workflow` -- `python-upgrade-workflow` -- `python-testing-workflow` - -Keep existing names unless a cleanup slice explicitly renames the skill and removes the old duplicate surface in the same pass. Do not leave compatibility shims or duplicate overlapping skill paths behind unless Gale explicitly approves that compromise. - -## Documentation Sources - -Use official documentation first for Python behavior: - -- [uv documentation](https://docs.astral.sh/uv/) -- [Python packaging user guide](https://packaging.python.org/) -- [Writing `pyproject.toml`](https://packaging.python.org/guides/writing-pyproject-toml/) -- [pytest documentation](https://docs.pytest.org/en/stable/) -- [Ruff documentation](https://docs.astral.sh/ruff/) -- [mypy documentation](https://mypy.readthedocs.io/en/stable/) -- [FastAPI documentation](https://fastapi.tiangolo.com/) -- [FastMCP documentation](https://gofastmcp.com/getting-started/welcome) -- [GitHub Actions Python documentation](https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python) - -When a skill relies on documentation, translate the relevant rule into practical workflow guidance. Explain what the doc changes about command choice, package layout, validation scope, configuration, CI, or upgrade risk. - -## Current Skill Inventory - -The shipped inventory has fifteen skills. The original expansion plan below -predates the later local-first agent-service addition, so this list is the -current source of truth for the audit rather than the older twelve-skill -summary in the root plugin inventory. - -### `python-skills:bootstrap-uv-python-workspace` - -Keep this as the shared `uv` scaffolding basis. - -It should continue to own deterministic project and workspace generation, profile-driven scaffolding, initial `pytest`/`ruff`/`mypy` setup, safe `.env` defaults, and generated next-step commands. - -### `python-skills:bootstrap-python-service` - -Keep this as the FastAPI-first scaffold path. - -It should stay focused on creating new services. Ongoing endpoint, dependency, configuration, and deployment changes belong in a future service workflow rather than making this bootstrap skill too broad. - -### `python-skills:bootstrap-python-mcp-service` - -Keep this as the FastMCP-first scaffold path. - -It should stay focused on creating new MCP server projects and mapping existing APIs into MCP review reports. Ongoing MCP surface design can grow later if repeated FastMCP maintenance work needs its own skill. - -### `python-skills:integrate-fastapi-fastmcp` - -Keep this as the existing integration bridge. - -It should continue to cover mounted FastMCP apps, FastAPI-derived MCP surfaces, combined app shapes, lifespan boundaries, and promotion from generated MCP surfaces to curated MCP tools and resources. - -### `python-skills:build-python-agent-service` - -Keep this as the local-first Python agent-service implementation workflow. - -It owns framework selection, exact-model capability gates, typed tool and -result contracts, evaluation fixtures, and the draft-to-approved-write -promotion boundary. It is intentionally not a generic FastAPI/FastMCP service -maintenance skill, a local-model benchmark, or a multi-agent framework survey. - -### `python-skills:fastapi-service-workflow` - -Own ongoing FastAPI service work: route and dependency composition, typed -settings, lifespan, async and integration testing, OpenAPI review, and -deployment-readiness handoff. Keep package, CI, tooling, and FastMCP concerns -with their existing owners. - -### `python-skills:fastmcp-service-workflow` - -Own ongoing FastMCP server work: curated tools, resources, and prompts; -transport and lifespan behavior; authorization; client tests; generated-surface -review; and version-aware upgrade diagnostics. It is not a bundled FastMCP -documentation server or a generic deployment workflow. - -### `python-skills:python-testing-workflow` - -This replaces the earlier `uv-pytest-unit-testing` skill as the tested pytest -setup and execution surface. - -It owns test selection, fixtures, parametrization, async tests, integration -boundaries, coverage when requested, workspace targeting, failure triage, and -local-to-CI parity. The former name has no compatibility shim or duplicate -surface. - -## Proposed Skill Inventory - -### `python-skills:choose-python-project-shape` - -Help an agent decide how Python should fit into a user's project before implementation starts. - -This skill should classify the requested work: - -- single-package library -- command-line app -- FastAPI service -- FastMCP server -- combined FastAPI and FastMCP surface -- `uv` workspace -- test-only or tooling-only change -- packaging, CI, or upgrade pass -- mixed-language repository where Python is only one project member - -The output should recommend project shape, package layout, validation commands, dependency group strategy, generated files, and documentation updates. It should hand off to existing bootstrap skills when scaffolding is the next step. - -### `python-skills:build-python-project` - -Guide agents through implementation in an existing Python project. - -This skill should cover: - -- reading `pyproject.toml`, package layout, tests, and existing style before editing -- choosing module boundaries that keep imports straightforward -- preserving typed configuration and environment boundaries -- using small composable functions and explicit inputs/outputs where practical -- keeping framework adapters thin around reusable project logic -- adding focused tests around changed behavior -- running the narrowest useful `uv run` validation command first - -This is the general implementation skill, not a replacement for specialized FastAPI, FastMCP, package, test, or CI workflows. - -### `python-skills:diagnose-python-project` - -Help agents find the first meaningful cause of Python failures. - -This skill should cover: - -- missing or mismatched Python versions -- `uv` sync, lock, and dependency-group issues -- import path and package layout problems -- test discovery and fixture failures -- Ruff lint or format failures -- mypy configuration, missing stubs, and type-check failures -- FastAPI app import or lifespan failures -- FastMCP server startup and tool-registration failures -- packaging metadata and build failures - -Diagnostics should report what command failed, which phase failed, the likely cause, and the smallest useful next check. - -### `python-skills:python-package-workflow` - -Validate Python package surfaces before release or publication. - -This skill should cover: - -- package metadata in `pyproject.toml` -- build-system selection and package discovery -- dependency versus optional dependency versus dependency-group boundaries -- README, license, classifiers, project URLs, and package description expectations -- local build validation -- local package smoke checks -- semantic versioning and release notes -- PyPI or private index publication as an explicit release step only - -It should not publish packages unless the user explicitly asks for that release step or repo-local release automation owns it. - -### `python-skills:python-tooling-style-workflow` - -Align Python formatting, linting, type checking, and local tooling. - -This skill should cover: - -- Ruff formatter and linter setup -- mypy configuration and staged strictness -- `pytest` configuration when it intersects with tooling -- `pyproject.toml` versus dedicated config file choices -- dependency groups for maintainer tools -- pre-commit or editor integration only when the repo already uses it or the user asks -- keeping formatting-only sweeps separate from behavior changes when practical - -The workflow should preserve repo-local conventions and avoid forcing strictness upgrades into unrelated feature work. - -### `python-skills:python-ci-workflow` - -Guide agents through Python CI setup and maintenance. - -This skill should cover: - -- GitHub Actions setup for Python and `uv` -- dependency caching choices -- `uv sync --dev` or equivalent repo-local install commands -- `uv run pytest` -- `uv run ruff check .` -- `uv run ruff format --check .` when formatting is enforced -- `uv run mypy .` -- package build checks when package surfaces exist -- matrix decisions for Python versions and operating systems - -CI should prove the same behavior maintainers care about locally and avoid publishing as a side effect. - -### `python-skills:python-upgrade-workflow` - -Guide agents through Python, dependency, framework, and tooling upgrades. - -This skill should cover: - -- current Python version requirements -- `uv.lock` -- dependency groups and optional dependencies -- FastAPI, FastMCP, Pydantic, Ruff, mypy, and pytest upgrade notes -- staged validation -- contributor setup or package-consumer migration notes when requirements change - -Use this when changing Python version support, package versions, lockfiles, or framework major versions. - -## First Implementation Slice - -The first slice should repair the current child contract and add the core missing operating skills: - -- [x] Fix `plugins/python-skills/scripts/validate_repo_metadata.py` so it validates the child `AGENTS.md`, plugin manifest, and skill metadata without expecting a removed child `README.md`. -- [x] Update child tests so they assert the current no-child-README contract. -- [x] Record this expansion plan. -- [x] Add `python-skills:choose-python-project-shape`. -- [x] Add `python-skills:build-python-project`. -- [x] Add `python-skills:diagnose-python-project`. -- [x] Add `python-skills:python-package-workflow`. -- [x] Add `python-skills:python-tooling-style-workflow`. -- [x] Update `plugins/python-skills/.codex-plugin/plugin.json` default prompts and long description after the new skills exist. -- [x] Run child validation with `uv run scripts/validate_repo_metadata.py`, `uv run pytest`, `uv run ruff check .`, and `uv run mypy .`. -- [x] Run root Socket metadata validation with `uv run scripts/validate_socket_metadata.py`. - -## Second Implementation Slice - -The second slice should cover repeated project operations that become more valuable after the core skill set lands: - -- [x] Add `python-skills:python-ci-workflow`. -- [x] Add `python-skills:python-upgrade-workflow`. -- [x] Replace `uv-pytest-unit-testing` with `python-testing-workflow` in one cleanup pass, retaining the tested scripts under the replacement skill. -- [x] Add `fastapi-service-workflow` for ongoing FastAPI service maintenance. -- [x] Add `fastmcp-service-workflow` for ongoing FastMCP server maintenance. -- [x] Add install testing with a temporary `CODEX_HOME` if the exported skill surface or plugin metadata changes enough to need plugin-install verification. - -## Deferred Scope - -After the first two slices prove useful, consider deeper specialized workflows: - -- data science and notebook workflows -- Django workflows -- Typer or Click CLI workflows -- async service performance diagnostics -- package publishing automation -- Python MCP server runtime diagnostics beyond FastMCP guidance -- generated project-template maintenance beyond the current shell scaffold scripts -- bundled MCP servers or app connectors - -## Open Decisions Before Implementation - -### Testing Skill Name - -Decision: replace the narrow unit-testing name with -`python-testing-workflow`. - -The replacement covers the broader testing workflow and retains the useful -setup and package-targeting scripts. The old directory, profile paths, routing, -and discovery surface were removed in the same pass. - -### Service Workflow Timing - -Decision: add separate FastAPI and FastMCP service-maintenance workflows. - -The bootstrap and integration skills remain focused on new service creation and -combined architecture. The dedicated maintenance workflows own existing service -behavior without absorbing package, CI, testing, tooling, or upgrade work. - -### Script Depth - -Decision for the first slice: keep the new operating skills as guidance-first. - -The existing bootstrap and pytest skills already own deterministic shell entrypoints. Add scripts to new skills only when repeated command generation or validation behavior becomes mechanical enough to test directly. - -### Versioning And Marketplace Timing - -Decision for this expansion: treat the full first implementation slice as a likely Socket minor release candidate. - -The validator repair alone is a maintenance fix. Adding the new skill inventory is user-facing plugin capability and should likely publish as a minor release when the branch is ready. - -## Follow-Up Audit: 2026-07-29 - -### Audit Result - -The plugin has a coherent general Python path now: choose a shape, bootstrap -it, build it, diagnose failures, test it, align tooling, validate packaging, -maintain CI, and plan upgrades. The next pass should be a focused cleanup and -service-maintenance expansion, not another broad scaffold expansion. - -The existing bootstrap skills remain distinct user-facing entry points: - -- `bootstrap-uv-python-workspace` owns generic package or service scaffolds. -- `bootstrap-python-service` owns FastAPI-first scaffolds. -- `bootstrap-python-mcp-service` owns FastMCP-first scaffolds. - -Do not merge or rename those three installed skill names. They express three -different starting intents and already share their actual scaffold mechanics. -Instead, consolidate their repeated prose, validation vocabulary, configuration -policy, and handoff matrix through shared references or a small common -bootstrap-contract asset. That preserves clear discovery without maintaining -three copies of the same policy. - -### Immediate Contract Repairs - -Complete these in one small maintenance slice before adding a new workflow: - -- [x] Correct the maintainer inventory to include - `build-python-agent-service`, then refresh the root architecture model in a - dedicated cross-plugin architecture pass. Do not hand-edit only the Python - entries: the current architecture audit reports stale targets in other - plugins too. -- [x] Remove the packaged dependency claim for `fastmcp_docs`, or add a - deliberately approved bundled MCP declaration plus its required Hermes - translation. The current plugin has two skills that require that server in - their metadata, but it does not ship the corresponding `.mcp.json` source. - The preferred narrow repair is to say “use `fastmcp_docs` when the host has - configured it; otherwise use the official FastMCP documentation,” and make - that dependency optional in the skill metadata. -- [x] Update `python-ci-workflow` to distinguish local development checks from - reproducible locked CI. For a repository that commits `uv.lock`, the default - CI example should use `uv sync --locked --all-extras --dev` only when those - extras and development groups are part of the tested contract; it should not - imply that every project needs all extras. -- [x] Add a concrete isolated wheel and sdist smoke-check recipe to - `python-package-workflow`, while keeping publication explicitly outside the - workflow. The current prose asks for a temporary consumer but leaves the - most important artifact-install proof underspecified. -- [x] Make all agent-service Python execution examples `uv`-based and remove - the unused unrestricted `Bash(python:*)` tool allowance from - `build-python-agent-service` unless a tested workflow truly needs it. - -### Consolidation Slice - -Create one shared bootstrap contract reference used by all three bootstrap -skills. It should own only the common policy: - -- `uv` command and dependency-group vocabulary; -- safe configuration defaults, secret boundaries, and typed settings; -- `pytest`, Ruff, mypy, and optional formatting-check command selection; -- project versus workspace decision and handoff rules; -- generated-artifact, git-initialization, and temporary-scaffold cleanup - boundaries. - -Each existing skill should retain only its special behavior: generic profile -selection, FastAPI overlay, or FastMCP overlay and API-mapping review. This is -a durable documentation building block, not a runtime abstraction or another -scaffold layer. - -### Next New Workflows - -Prioritize these two workflows after the contract repairs. They answer the two -open decisions from the original expansion plan and cover real work that the -current bootstrap and integration skills intentionally stop before. - -1. `fastapi-service-workflow` - - Own existing-service route composition, typed settings and dependency - overrides, lifespan, async and integration testing, OpenAPI boundary - review, deployment-readiness handoff, and service-specific diagnostics. - - Hand package, CI, general tooling, and FastMCP work back to their current - owners instead of duplicating them. -2. `fastmcp-service-workflow` - - Own existing-server transport and lifespan behavior, tool/resource/prompt - curation, authorization and input boundaries, client integration tests, - generated-surface review, and upgrade diagnostics. - - Pin implementation decisions to the installed FastMCP version and its - release documentation; the public FastMCP docs track `main` and can - describe unreleased behavior. - -### Testing Workflow Decision - -Replace `uv-pytest-unit-testing` with `python-testing-workflow` in one -deliberate cleanup release. The new skill should cover test selection, -fixtures, parametrization, async tests, integration boundaries, coverage when -requested, workspace-member targeting, failure triage, and local-to-CI parity. -Remove the old skill in that same pass and update every routing, prompt, -Hermes-export, architecture, and compatibility surface. Do not keep two -overlapping testing skills or add a compatibility shim unless Gale explicitly -approves a temporary migration window. - -### Deliberately Deferred Expansions - -These are useful only after evidence of repeated demand; they should not be -folded into the general implementation skill: - -- a CLI workflow for Typer or Click; -- a data and notebook workflow, preferably including reproducibility and - environment/kernel boundaries; -- a Django workflow; -- a background-job and task-queue workflow; -- a persistence workflow for SQLAlchemy and migration ownership; -- publishing automation, which remains an explicit release decision rather - than a normal package-validation feature. - -### Definition Of Done For The Follow-Up - -- [x] Every shipped Python skill appears in the child plan, plugin discovery - metadata and portable export decision. -- [x] Each declared MCP dependency is packaged and translated, explicitly - host-provided, or removed. -- [x] Bootstrap policies have one shared source while each entry point keeps a - narrow, distinct purpose. -- [x] CI guidance demonstrates both fast local iteration and locked, - reproducible verification without overgeneralizing either command. -- [x] FastAPI and FastMCP maintenance each have a clear owning workflow. -- [x] The renamed testing workflow replaces, rather than shadows, the current - unit-testing skill. - -## Definition Of Done - -The expansion is ready when: - -- [x] The child validator passes without requiring a child `README.md`. -- [x] The plugin has a documented skill naming convention and expansion plan. -- [x] The first new skill set covers project choice, implementation, diagnostics, packaging, and tooling/style alignment. -- [x] The second new skill set covers CI and upgrade workflows. -- [x] The guidance consistently uses `uv` for Python command examples. -- [x] The guidance uses official documentation as the source of truth for Python packaging, `uv`, pytest, Ruff, mypy, FastAPI, FastMCP, and CI behavior. -- [x] Root Socket docs, plugin metadata, child validation, and root validation agree on the exported Python skill surface. diff --git a/docs/maintainers/release-workflow.md b/docs/maintainers/release-workflow.md index 562fdeb9c..0d97b6858 100644 --- a/docs/maintainers/release-workflow.md +++ b/docs/maintainers/release-workflow.md @@ -1,135 +1,72 @@ # Socket Release Workflow -Socket has one release lifecycle. Every patch, minor, major, catalog-refresh, -and child-affecting release travels through the same branch-backed -`prepare` → `inspect` → `advance` entrypoint. +Socket has one branch-backed release lifecycle: `prepare` → `inspect` → +`advance`. The managed repository-skills runtime owns every stage, and all +operator entrypoints are root Just recipes backed by FSX. ## Authority And Ownership -- Run release preparation from a named feature worktree. -- Treat the separate clean `main` checkout as the post-merge verification, - tagging, and publication surface. -- Never commit or push a version bump directly on `main`. -- Use `scripts/release.sh` as the only public release command. The Python files - under `scripts/` are internal implementation modules. -- A release request authorizes the PR, merge, annotated tag, GitHub release, - final marketplace refresh, and safe merged-branch/worktree cleanup after all - gates pass. It does not authorize deleting unmerged or unaccounted history. - -## Before Prepare - -1. Finish and commit the intended implementation on its feature branch. -2. Add reviewed release notes at `docs/releases/vX.Y.Z.md`. State actual user - changes, breaking changes, and migration steps. Do not claim that the tag, - GitHub release, marketplace refresh, or cleanup already happened. -3. Run focused validation while implementing. `prepare` runs the consolidated - full profile again after applying the version. -4. For child payload changes, determine whether the child is monorepo-owned, - an external Git-backed catalog entry, or a genuine subtree sync target. - Socket currently has no release-time subtree target. - -Inspect the current shared version when needed: - -```bash -scripts/release.sh inventory -``` +- Prepare from a named, clean feature branch with reviewed release notes at + `docs/releases/vX.Y.Z.md`. +- Keep a separate clean worktree on `main` for post-merge verification, + tagging, publication, and safe cleanup. +- Never commit a release bump directly on `main`. +- Account for every unmerged branch before cleanup. Release authorization does + not authorize deleting unaccounted history. ## Prepare -Choose the exact next semantic version. For the current release: - ```bash -scripts/release.sh prepare 10.0.0 +just repo-release-prepare 10.0.0 ``` -`prepare`: - -- refuses `main`, detached HEAD, or a dirty worktree; -- requires checked-in release notes; -- aligns every maintained manifest and adjacent lockfile to the requested - version; -- runs `uv run scripts/validate_socket.py --profile full` against that version; -- commits the version as `release: prepare Socket vX.Y.Z`; -- pushes the feature branch and verifies its exact remote commit; -- opens the release PR or preserves the existing PR body and reviewer content; and -- prints one bounded PR snapshot plus a continuation packet when GitHub state - is not ready. +Prepare verifies the branch and worktree, requires checked-in release notes, +runs canonical validation, applies the deterministic version bump, commits and +pushes it, verifies the remote commit, and creates or reuses the release pull +request. If GitHub is not ready, it emits a bounded continuation packet. ## Inspect -Never watch or poll CI, reviews, or provider state. Inspect once: - ```bash -scripts/release.sh inspect 10.0.0 +just repo-release-inspect 10.0.0 ``` -If GitHub is still pending, reuse one matching host-native continuation no -sooner than five minutes later. On wakeup, run `inspect` again before any -mutation. Failed or pending checks, requested changes, and unreviewed comments -block the release. After reviewing and resolving comments, pass -`--review-comments-addressed` to `advance`; the flag does not bypass failed or -pending checks, requested changes, or commit-identity validation. - -The required GitHub `validate` job runs the same full Socket profile used by -`prepare`; release PRs do not rely on a weaker compatibility-only check. +Inspect performs one bounded read of the pull request, required checks, +reviews, comments, and commit identity. Do not poll. If state is pending, use +one matching continuation no sooner than five minutes later and inspect again +before mutation. ## Advance -Only after the PR snapshot is green and comments are resolved: - ```bash -scripts/release.sh advance 10.0.0 +just repo-release-advance 10.0.0 ``` -If a local branch remains outside `main`, classify it explicitly rather than -using a blanket override: +Classify any unmerged branch explicitly when required: ```bash -scripts/release.sh advance 10.0.0 \ +just repo-release-advance 10.0.0 \ --branch-accounting feature/example=in-progress ``` Allowed classifications are `preserved`, `in-progress`, `archived`, `merged`, -and `safe-to-delete`. - -`advance` verifies the feature branch and PR commit identities, uses GitHub -auto-merge with the repository's merge method, finds the worktree that owns -`main`, fast-forwards it from the current remote, and proves local and remote -commit equality. It then: - -1. verifies the shared version and release notes on reviewed `main`; -2. completes structured branch and child-sync accounting; -3. reruns the full validation profile on reviewed `main`; -4. captures commit-bound temporary-marketplace and Dependabot evidence; -5. creates and pushes an annotated `vX.Y.Z` tag at that exact commit; -6. creates and verifies the GitHub release using checked-in notes plus only - pre-publication evidence; -7. reports the final branch accounting; and -8. runs `codex plugin marketplace upgrade socket` as the final release action. - -The marketplace refresh retains the bounded fallback for the known Codex clone -timeout, but it cannot run before the tag and GitHub release are verified. +and `safe-to-delete`. Advance verifies the reviewed commit, enables GitHub +auto-merge, fast-forwards the clean `main` worktree, reruns canonical +validation and the root E2E test, creates the annotated tag and GitHub release, +and performs the final marketplace refresh. ## Child Synchronization -Child synchronization is a conditional pre-tag gate inside this one workflow, -not a separate release mode. - -- Monorepo-owned plugin directories require no separate child push. -- `plugins/apple-dev-skills` is canonical in Socket; the standalone repository - is a compatibility pointer and must not receive a subtree push. -- `speak-swiftly` is an external Git-backed catalog entry; its standalone - release normally requires no Socket source sync. -- If a real subtree target is deliberately introduced later, register its gate - in the release implementation and document its pull/push ownership in - `subtree-workflow.md` before releasing it. - -## Completion And Cleanup +- Monorepo-owned plugins need no separate child push. +- `plugins/apple-dev-skills` is canonical in Socket; its standalone repository + remains a compatibility pointer. +- `speak-swiftly` is an external Git-backed catalog entry and normally needs no + Socket source synchronization. +- A future subtree target must define its ownership gate before release. -A release is complete only when the reviewed `main` commit, annotated remote -tag, GitHub release, branch accounting, child-sync accounting, and final local -marketplace refresh are all verified. +## Completion -After that verification, prune stale remote refs and remove only merged release -branches and their worktrees. Preserve or explicitly classify every unmerged -branch before deleting any branch, worktree, archive ref, or rescue ref. +A release is complete only after the reviewed `main` commit, remote annotated +tag, GitHub release, branch accounting, child synchronization accounting, and +final marketplace refresh are verified. Remove only merged release branches +and worktrees after that evidence exists. diff --git a/docs/maintainers/reverse-engineering-skills-plugin-plan.md b/docs/maintainers/reverse-engineering-skills-plugin-plan.md index ec87b819d..eda36b0fd 100644 --- a/docs/maintainers/reverse-engineering-skills-plugin-plan.md +++ b/docs/maintainers/reverse-engineering-skills-plugin-plan.md @@ -299,7 +299,7 @@ For every new skill: - give frontmatter descriptions concrete artifact, tool, and task triggers - generate or refresh matching `agents/openai.yaml` metadata - avoid bundling decompilers, sample binaries, private SDK material, machine-local paths, tool databases, or extracted proprietary artifacts -- validate the skill folder with the skill-authoring validator and validate the Socket marketplace with `uv run scripts/validate_socket_metadata.py` +- validate the skill folder with the skill-authoring validator and validate the Socket marketplace with `just repo-validate` - forward-test with small, redistributable or locally generated artifacts that exercise success, uncertainty, disagreement between tools, and unsupported-version failure paths - smoke-test GUI adapter steps through the available app integration when practical and retain a CLI or manual-GUI path - date and link any beta-sensitive claim, and require live confirmation before relying on it in a later analysis session diff --git a/docs/maintainers/rust-skills-plugin-plan.md b/docs/maintainers/rust-skills-plugin-plan.md index 45b9d40df..0f0640cac 100644 --- a/docs/maintainers/rust-skills-plugin-plan.md +++ b/docs/maintainers/rust-skills-plugin-plan.md @@ -196,7 +196,7 @@ This skill covers: - [x] Add package and CI workflow skills for publish-facing and automation guidance. - [x] Switch the root marketplace entry for `rust-skills` to installable only after real skill content exists. - [x] Update root README and TODO so users understand the new installable child plugin surface. -- [x] Run root metadata validation with `uv run scripts/validate_socket_metadata.py`. +- [x] Run root metadata validation with `just repo-validate`. ## Exit Criteria diff --git a/docs/maintainers/socket-xcode-workspace.md b/docs/maintainers/socket-xcode-workspace.md index 6cd003176..5084dfd79 100644 --- a/docs/maintainers/socket-xcode-workspace.md +++ b/docs/maintainers/socket-xcode-workspace.md @@ -18,7 +18,7 @@ The workspace references the root maintainer files plus the main authored source directories: - root docs such as `README.md`, `CONTRIBUTING.md`, and `ROADMAP.md`, - `AGENTS.md`, and `ACCESSIBILITY.md` + and `AGENTS.md` - the root marketplace file at `.agents/plugins/marketplace.json` - `docs/` - `plugins/` diff --git a/docs/maintainers/subtree-workflow.md b/docs/maintainers/subtree-workflow.md index 0b0e36964..91038f0ec 100644 --- a/docs/maintainers/subtree-workflow.md +++ b/docs/maintainers/subtree-workflow.md @@ -135,7 +135,7 @@ Run this audit whenever a child plugin is added, removed, moved, renamed, conver 3. For each Git-backed entry, verify the source kind matches the plugin location: `url` for a repository-root plugin and `git-subdir` for a plugin in a repository subdirectory. 4. Compare the marketplace entries against the real child directories under `plugins/` and confirm every public child plugin that ships `.codex-plugin/plugin.json` is listed or intentionally exposed by Git-backed reference. 5. Open each changed child repo's `AGENTS.md`, plugin manifest, optional public README, or maintainer docs and confirm the child still treats the marketplace path as its installable plugin root. -6. Run `uv run scripts/validate_socket_metadata.py`. +6. Run `just repo-validate`. 7. Update `README.md`, this maintainer workflow, and `ROADMAP.md` when the audit finds a packaging-model change rather than only a metadata typo. The audit is about the installable plugin roots that Codex can actually see. Do not rewrite marketplace paths to follow an invented uniform layout when the child repo still packages from a different root. @@ -151,7 +151,7 @@ Use this checklist before removing a public child repository from `socket` or fr 3. Remove the child directory only when the source repo is no longer meant to be imported here, or when the child has been explicitly moved elsewhere. 4. Remove the marketplace entry in the same commit as the directory removal when the plugin is no longer installable from `socket`. 5. Update `README.md`, `ROADMAP.md`, and any maintainer docs that listed the child as active. -6. Run `uv run scripts/validate_socket_metadata.py`. +6. Run `just repo-validate`. 7. Account for local branches not contained by `main` before cleanup. If the removed Socket entry points at `SpeakSwiftlyServer`, do not use this checklist as permission to delete or rewrite the standalone live-service repository. That repo's standalone release, validation, and live-refresh path stays outside ordinary `socket` cleanup. diff --git a/docs/maintainers/unified-swift-workspace-and-cloud-boundary-plan.md b/docs/maintainers/unified-swift-workspace-and-cloud-boundary-plan.md index 08421d0ea..b7180edc4 100644 --- a/docs/maintainers/unified-swift-workspace-and-cloud-boundary-plan.md +++ b/docs/maintainers/unified-swift-workspace-and-cloud-boundary-plan.md @@ -267,7 +267,7 @@ The ordering prevents new guidance from pointing at incomplete runtime support. | Soto fixtures | Dependency products, one-client lifecycle, Lambda warm reuse, shutdown, exception record. | | Cloud contract tests | No local Linux build path, hosted artifact identity, `test` and `production` environment separation, OIDC, rollback identity. | | Metadata and portability | Plugin manifests, prompts, skill inventory, Hermes tap export/validation, host-specific boundary notes. | -| Root validation | `uv run scripts/validate_socket.py --profile compatibility`, then the relevant full profile before release. | +| Root validation | `just repo-validate`, then the relevant full profile before release. | Build and test tools must run serially on Gale's machine. The end-to-end tests must not start containers, create VMs, deploy AWS resources, or mutate Homebrew installations. diff --git a/docs/maintainers/xcode-27-agentic-tooling-plan.md b/docs/maintainers/xcode-27-agentic-tooling-plan.md index c04d5357c..a4688752a 100644 --- a/docs/maintainers/xcode-27-agentic-tooling-plan.md +++ b/docs/maintainers/xcode-27-agentic-tooling-plan.md @@ -302,7 +302,7 @@ Update with Xcode 27 Icon Composer notes for: Validation: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate ``` ### Slice 2: First Skill @@ -320,8 +320,8 @@ Updated on 2026-06-23 after a live Xcode 27 beta probe. The beta app produced co Validation: ```bash -uv run scripts/validate_socket_metadata.py -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +just repo-validate +just repo-validate uv run pytest ``` @@ -335,8 +335,8 @@ uv run pytest Validation: ```bash -uv run scripts/validate_socket_metadata.py -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +just repo-validate +just repo-validate uv run pytest ``` @@ -349,8 +349,8 @@ uv run pytest Validation: ```bash -uv run scripts/validate_socket_metadata.py -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +just repo-validate +just repo-validate uv run pytest ``` diff --git a/docs/maintainers/xcode-plugin-install-support-plan.md b/docs/maintainers/xcode-plugin-install-support-plan.md index 89b182e96..2fdf3b758 100644 --- a/docs/maintainers/xcode-plugin-install-support-plan.md +++ b/docs/maintainers/xcode-plugin-install-support-plan.md @@ -156,7 +156,7 @@ matching import contract. Validation: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate uv run pytest ``` @@ -177,7 +177,7 @@ The fixture should never be committed as an installed user artifact, and it shou Validation: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate ``` Manual evidence to capture: diff --git a/docs/releases/v10.0.2.md b/docs/releases/v10.0.2.md index 1327ca4b3..15d59e1ed 100644 --- a/docs/releases/v10.0.2.md +++ b/docs/releases/v10.0.2.md @@ -36,5 +36,5 @@ ## Verification - `uv run pytest tests/test_repository_maintenance_workflow.py tests/test_unified_swift_workspace_contracts.py -q` -- `uv run scripts/export_hermes_skills.py --check` -- `uv run scripts/validate_socket.py --profile compatibility` +- `just repo-sync` +- `just repo-validate` diff --git a/plugins/agent-portability-skills/.codex/agents/skills-repo-guidance-sync.toml b/plugins/agent-portability-skills/.codex/agents/skills-repo-guidance-sync.toml deleted file mode 100644 index 1b451b42c..000000000 --- a/plugins/agent-portability-skills/.codex/agents/skills-repo-guidance-sync.toml +++ /dev/null @@ -1,36 +0,0 @@ -name = "skills-repo-guidance-sync" -description = "Read-heavy skills and plugin repository guidance auditor for Codex docs freshness, plugin-root policy, discovery mirrors, subagent guidance, hooks guidance, and marketplace wording." -model = "gpt-5.6-terra" -sandbox_mode = "read-only" -model_reasoning_effort = "medium" -nickname_candidates = ["Guidance Sync", "Plugin Auditor", "Skill Mirror"] -developer_instructions = """ -You are skills-repo-guidance-sync, a bounded Codex subagent for skills-export and plugin-export repository guidance audits. - -Your job is read-heavy discovery and review-packet planning. Inspect the target repository's Codex plugin guidance, skill metadata, discovery mirrors, docs, packaging files, hooks guidance, and marketplace wording, then return concise findings to the parent Codex thread. Do not apply edits, bootstrap files, rewrite guidance, commit, push, open pull requests, tag, release, or run destructive commands. - -Default scope: -- AGENTS.md, README.md, CONTRIBUTING.md, ROADMAP.md, maintainer docs, and generated guidance snippets -- skills/*/SKILL.md, skills/*/agents/openai.yaml, skill discovery mirrors, and `.agents/skills` surfaces -- `.codex-plugin/plugin.json`, root skills layout, hooks config, MCP/App config, assets, and marketplace entries -- install and update wording for Git-backed marketplace sources -- Codex plugin, skill, subagent, hook, and marketplace policy claims that may need current official docs before editing -- dependency-provenance guidance, machine-local path risks, stale installer-era wording, and unsupported non-Codex surfaces - -Use current repo evidence and current official OpenAI Codex docs before making policy claims. If official docs cannot be checked, say so and mark any policy finding as needing parent-thread verification. - -Route findings to the owning workflow instead of duplicating it: -- sync-skills-repo-guidance for existing repository guidance drift -- bootstrap-skills-plugin-repo for new repository structure or broad structural alignment -- repository-skills document-maintenance workflows for README, CONTRIBUTING, AGENTS, ROADMAP, or API documentation edits - -Return a draft review packet in this shape: -1. Repository shape and confidence. -2. Files, manifests, mirrors, docs, and official sources inspected. -3. Findings grouped by owning workflow. -4. Proposed patch set with one entry per draft edit, including target file, change summary, reason, and whether the main agent should save for later, edit, or apply after review. -5. Validation handoff with commands for the main agent to run after applying any patch. -6. Apply boundary, blockers, ambiguity, or user decisions. - -Keep output concise. Include file references, official-source links, and evidence, not raw logs or replacement repo guidance. -""" diff --git a/plugins/agent-portability-skills/AGENTS.md b/plugins/agent-portability-skills/AGENTS.md index ecdfa5186..cb2495e90 100644 --- a/plugins/agent-portability-skills/AGENTS.md +++ b/plugins/agent-portability-skills/AGENTS.md @@ -18,22 +18,13 @@ This file is the Agent Portability Skills child-repo override for work done from - Keep Codex-specific marketplace, plugin manifest, hook, app, and MCP behavior distinct from host-native surfaces such as Zed skills, Xcode plug-ins, OpenCode skills, Claude Code skills, and future adapter packages. - Default user-facing install and update guidance to Git-backed marketplace sources. Do not recreate nested staged plugin directories, manual-first local install stories, `skills/install-plugin-to-socket`, or `skills/validate-plugin-install-surfaces`. - Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories that another contributor can fetch. Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly. -- When a skill contract changes, update the nearby skill docs, maintainer docs, and tests in the same pass. -- Use the `skills-repo-guidance-sync` custom-agent role only for explicit-trigger subagent workflows: broad read-heavy skills/plugin repo guidance audits, Codex docs freshness checks, discovery mirror drift, marketplace wording checks, and review-packet planning. Keep final edits, validation, commits, pushes, PRs, and releases in the main thread. +- When a skill contract changes, update nearby skill and maintainer docs in the same pass. ## Validation -Run from the Socket repository root so the shared maintainer environment and -cache policy apply: +Run the essential Socket integration path from the repository root: ```bash -uv run python -B -m pytest \ - plugins/agent-portability-skills/tests \ - plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/tests \ - plugins/agent-portability-skills/skills/sync-skills-repo-guidance/tests \ - -o cache_dir=.codex/.cache/pytest -uv run ruff check --cache-dir .codex/.cache/ruff/agent-portability-skills \ - plugins/agent-portability-skills -uv run mypy --cache-dir .codex/.cache/mypy/agent-portability-skills \ - plugins/agent-portability-skills +just repo-validate +just test ``` diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/SKILL.md b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/SKILL.md deleted file mode 100644 index abcbd55ca..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: bootstrap-skills-plugin-repo -description: Bootstrap or align a source-first Agent Skills repository with root `skills/`, discovery mirrors, maintainer docs, and explicit Codex plugin boundaries. Use for new skills repos or structural alignment, not narrow docs or host-adapter work. -metadata: - hermes: - category: agent-portability - tags: [agent-skills, codex, plugin, portability] ---- - -# Bootstrap Skills Plugin Repo - -Bootstrap or align a source-first Agent Skills repository. - -This is the Codex-ready bootstrap workflow inside Agent Portability Skills. Use it for the shared skills repository shape first, then hand off to future host-adapter workflows when a target such as Zed Agent, Xcode, OpenCode, or Claude Code needs additional package or config decisions. - -## Codex Model Note - -State plainly that OpenAI's documented Codex plugin system exposes repo-visible plugins through marketplace catalogs and does not document a richer repo-private scoping model beyond that. This repository pattern allows root `.codex-plugin` packaging. Do not normalize nested staged plugin directories or installer-era helper workflows for this repo family. - -Before adding detailed guidance about Codex Plugins, Skills, MCP, Hooks, marketplaces, or subagents, refresh the relevant OpenAI Codex docs. Keep generated repo guidance focused on durable local policy and link to the official docs for details that can drift. - -## Codex Plugin Root Structure - -When bootstrapping or aligning a plugin repo, follow the current OpenAI plugin structure: - -- every plugin has a manifest at `.codex-plugin/plugin.json` -- only `plugin.json` belongs in `.codex-plugin/` -- `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` belong at the plugin root -- plugin manifests should point to bundled skill folders with `"skills": "./skills/"` -- plugin manifests may point to bundled lifecycle hooks with `"hooks": "./hooks/hooks.json"`; if hooks live at `./hooks/hooks.json`, Codex checks that default path automatically -- plugin-bundled hooks are non-managed hooks, so installing or enabling a plugin does not make those hooks trusted automatically -- marketplace `source.path` should point at the plugin root directory - -## Dependency Provenance - -When creating or aligning `AGENTS.md`, include strict dependency guidance: - -- shared project dependencies must resolve from GitHub repository URLs, package managers, package registries, or other real remote repositories -- committed dependency declarations, lockfiles, scripts, docs, examples, generated project files, and CI config must not point at machine-local paths -- machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly - -## Codex Subagent Guidance - -For existing repositories that need broad guidance drift discovery before edits, prefer `sync-skills-repo-guidance` and its `skills-repo-guidance-sync` custom-agent role. Use this bootstrap skill for new repository structure or structural alignment after the main thread has reviewed any audit or subagent findings. - -When creating or aligning skills that can benefit from parallel support work, add optional `Codex Subagent Fit` guidance that matches OpenAI's current Codex subagent docs: - -- Current Codex releases enable subagent workflows by default, but Codex only spawns subagents when there is an explicit trigger: the user asks for subagents or parallel agent work, or a narrower skill/plugin workflow instructs the agent to ask first and the user grants explicit permission. -- Built-in agents include `default`, `worker`, and `explorer`; mention project-scoped custom agents under `.codex/agents/` only when the repo intentionally owns agent configuration. -- Good fits are bounded read-heavy discovery, docs pulling, tests, triage, log analysis, and summarization. -- Subagents should return concise findings, evidence, links, or file references instead of raw intermediate output. -- Apply-mode or implementation edits should stay in the main thread unless the user explicitly asks for parallel implementation and each worker has a disjoint write scope. -- Plugin-specific guidance can be stricter. For example, Codex Security repository-wide scans may require asking for subagent use because the scan quality depends on parallel file-pass review. - -Do not add subagent guidance to every skill by default. Use -`references/codex-subagent-skill-guidance.md` to decide whether the target skill -has real parallelizable support work. - -## Codex Install Guidance - -Bootstrap docs should make the Git-backed marketplace path the default user install/update story: - -```bash -codex plugin marketplace add <owner>/<repo> -codex plugin marketplace upgrade <marketplace-name> -``` - -Use explicit refs such as `<owner>/<repo>@vX.Y.Z` only for pinned reproducible installs. Use manual local marketplace or copied-payload instructions only for local development, unpublished testing, or fallback cases. - -Keep discovery mirrors, plugin packaging, marketplace catalogs, plugin payload directories, installed cache paths, and config-state separate. Do not blur "where Codex can see a plugin", "where the plugin payload lives", "how Codex updates the marketplace", and "whether the plugin is enabled" into one sentence. - -If you mention project-scoped `.codex/config.toml`, describe it as a general Codex config surface from the config reference, not as a separate documented plugin install surface. - -## GitHub Repository Settings - -When the bootstrapped repository has a GitHub remote, use -`repository-skills:maintain-github-repository` to audit or apply the current -recommended GitHub repository settings. Keep local structure bootstrap separate -from server-side settings mutation, keep visibility changes approval-gated, and -preserve any documented maintainer direct-push workflow. - -The GitHub settings pass should cover repository features, merge modes, -Dependabot and security settings, private vulnerability reporting for public -repos, web commit sign-off when DCO applies, and branch protection that requires -the actual CI check context without requiring unavailable reviewers. diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/agents/openai.yaml b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/agents/openai.yaml deleted file mode 100644 index cfb87e6ed..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/agents/openai.yaml +++ /dev/null @@ -1,2 +0,0 @@ -interface: - default_prompt: "Use $bootstrap-skills-plugin-repo to audit a skills-export repository first, then create or align the source-first repo structure with root `skills/`, root `.codex-plugin` packaging, repo-local discovery mirrors, maintainer docs, AGENTS guidance, Git-backed marketplace install/update guidance, and clear Codex plugin-boundary wording. Refresh current OpenAI Codex docs before making plugin, skill, MCP, hooks, marketplace, or subagent policy claims. Do not recreate nested staged plugin directories, manual-first local install stories, installer workflows, or install-validation workflows for this repo family." diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md deleted file mode 100644 index 8b1fb2c0e..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md +++ /dev/null @@ -1,16 +0,0 @@ -# Bootstrap Contract - -A bootstrapped skills-export repository should include: - -- root `skills/` -- `.agents/skills -> ../skills` -- `README.md` -- `AGENTS.md` -- `ROADMAP.md` -- `docs/maintainers/reality-audit.md` -- maintainer Python tooling guidance -- strict dependency-provenance guidance in `AGENTS.md` requiring shared dependencies to resolve from GitHub, package managers, package registries, or other real remote repositories -- an explicit `AGENTS.md` prohibition on machine-local dependency paths in public or publicly shared projects -- an explicit instruction to refresh current OpenAI Codex docs before changing plugin, skill, MCP, hooks, marketplace, or subagent guidance - -It may include root `.codex-plugin` packaging. When it does, `.codex-plugin/plugin.json` should point at bundled skills with `"skills": "./skills/"`. User-facing install and update examples should default to Git-backed marketplace sources and official marketplace add/upgrade commands. It should not include a nested staged plugin directory, manual-first local install story, installer skill, or install-validation skill for itself. diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md deleted file mode 100644 index 1ead0f734..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md +++ /dev/null @@ -1,62 +0,0 @@ -# Codex Subagent Skill Guidance - -Use this reference when bootstrapping or auditing guidance about Codex subagents -in skills-export and plugin-export repositories. It does not replace OpenAI's -Codex documentation; it records the narrow Socket house pattern for optional -subagent guidance. - -Date checked: 2026-07-19. - -## Official Model - -- Codex calls delegated agents `subagents` and their coordinated use a - `subagent workflow`. -- Subagent workflows require an explicit trigger: the user asks for subagents - or parallel work, or narrower workflow guidance asks first and the user grants - permission. -- Built-in roles include `default`, `worker`, and `explorer`. Project-scoped - custom roles belong under `.codex/agents/` only when the repository - intentionally owns that configuration. -- Bounded read-heavy discovery, tests, triage, log analysis, documentation - lookup, and summarization are the normal fit. -- Parallel writes require disjoint ownership because shared edits create merge - conflicts and coordination overhead. - -## What Skills Should Say - -Add a `Codex Subagent Fit` section only when the workflow has independently -useful support work. Good candidates include documentation verification, -metadata or packaging audits, broad codebase exploration, test or CI triage, -and migration checks with separate evidence surfaces. - -Avoid subagent guidance for narrow single-file changes, one sequential command, -workflows where each output determines the next input, or tightly bounded write -targets with no independent discovery phase. - -When subagent guidance is present, require: - -- bounded, independently useful jobs; -- concise findings, evidence, links, or file references instead of raw logs; -- main-thread ownership of apply-mode edits unless the user explicitly requests - parallel implementation with disjoint write scopes; -- local model choices only when a repository intentionally owns them, without - turning one role's choice into a global rule; and -- stricter plugin-specific policy when the owning workflow requires it. - -## Review Checklist - -Flag guidance that: - -- implies Codex delegates automatically; -- recommends delegation merely because work is long or complex; -- recommends parallel writes without separate ownership; -- hides token, latency, or coordination costs; -- requests raw exploratory dumps instead of distilled findings; or -- uses vague `multi-agent` wording where current Codex documentation uses - `subagent`. - -## Official References - -- [OpenAI Codex Subagents](https://developers.openai.com/codex/subagents) -- [OpenAI Codex Subagent concepts](https://developers.openai.com/codex/concepts/subagents) -- [OpenAI Codex subagent model guidance](https://learn.chatgpt.com/docs/agent-configuration/subagents#choosing-models-and-reasoning) diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md deleted file mode 100644 index 9a48d49c4..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md +++ /dev/null @@ -1,7 +0,0 @@ -# POSIX Symlink Policy - -Use POSIX symlink mirrors for local source-skill discovery in this repo family: - -- `.agents/skills -> ../skills` - -Do not replace those mirrors with duplicate nested skill trees. diff --git a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py b/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py deleted file mode 100644 index ba8bde120..000000000 --- a/plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -from __future__ import annotations - -import argparse -import json -import os -import sys -from dataclasses import asdict, dataclass -from pathlib import Path - - -EXACT_NO_FINDINGS = "No findings." - - -@dataclass -class Finding: - path: str - issue_id: str - message: str - - -def infer_plugin_name(repo_root: Path, explicit: str | None) -> str: - return explicit or repo_root.name - - -def expected_files(repo_root: Path, _plugin_name: str) -> dict[Path, str]: - return { - repo_root / ".gitignore": """.venv/ -__pycache__/ -.pytest_cache/ -*.pyc -""", - repo_root / "README.md": f"# {repo_root.name}\n\nInstallable maintainer skills for skills-export and plugin-export repositories.\n", - repo_root / "AGENTS.md": """# AGENTS.md - -Root `skills/` is canonical. - -Before changing Codex plugin, skill, MCP, hooks, marketplace, or subagent guidance, check the current OpenAI Codex docs. Keep repo guidance focused on durable local policy rather than copying the full upstream docs. - -Only `plugin.json` belongs in `.codex-plugin/`. Keep `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` at the plugin root. Plugin manifests should point to bundled skill folders with `"skills": "./skills/"`. - -Default user-facing Codex plugin install and update guidance to Git-backed marketplace sources with `codex plugin marketplace add <owner>/<repo>` and `codex plugin marketplace upgrade <marketplace-name>`. Explicit refs such as `<owner>/<repo>@vX.Y.Z` are for pinned reproducible installs. Manual local marketplace roots and copied plugin payloads are development, unpublished-testing, or fallback paths. - -Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories that another contributor can fetch. - -Do not commit dependency declarations, lockfiles, scripts, docs, examples, generated project files, or CI config that point at machine-local paths such as `/Users/...`, `~/...`, `../...`, local worktrees, or private checkout paths. - -Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly. If local integration is needed, keep it uncommitted or convert it to a tagged release, branch, or registry dependency before sharing. -""", - repo_root / "ROADMAP.md": "# Project Roadmap\n\n## Vision\n\n- Define the long-term outcome for this skills-export repository.\n", - repo_root / "docs" / "maintainers" / "reality-audit.md": "# Repo Reality Audit\n\nRoot `skills/` is canonical.\n", - } - - -def expected_symlinks(repo_root: Path, _plugin_name: str) -> dict[Path, str]: - return { - repo_root / ".agents" / "skills": "../skills", - } - - -def audit_repo(repo_root: Path, plugin_name: str) -> list[Finding]: - findings: list[Finding] = [] - for path in expected_files(repo_root, plugin_name): - if not path.exists(): - findings.append(Finding(str(path.relative_to(repo_root)), "missing-path", "Required bootstrap path is missing.")) - for path, target in expected_symlinks(repo_root, plugin_name).items(): - rel = str(path.relative_to(repo_root)) - if not path.exists() and not path.is_symlink(): - findings.append(Finding(rel, "missing-symlink", f"Expected symlink to {target}.")) - continue - if not path.is_symlink(): - findings.append(Finding(rel, "not-symlink", f"Expected POSIX symlink to {target}.")) - continue - actual_target = os.readlink(path) - if actual_target != target: - findings.append(Finding(rel, "wrong-symlink-target", f"Expected {target}, found {actual_target}.")) - if (repo_root / "plugins").exists(): - findings.append(Finding("plugins", "forbidden-path", "Nested plugin directories are forbidden for this repo model.")) - if (repo_root / ".agents" / "plugins" / "marketplace.json").exists(): - findings.append( - Finding( - ".agents/plugins/marketplace.json", - "forbidden-path", - "Repo marketplace files are forbidden for this repo model.", - ) - ) - return findings - - -def _ensure_parent(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - - -def apply_repo(repo_root: Path, plugin_name: str) -> tuple[list[dict[str, str]], list[str]]: - actions: list[dict[str, str]] = [] - created_paths: list[str] = [] - for path, content in expected_files(repo_root, plugin_name).items(): - if path.exists(): - continue - _ensure_parent(path) - path.write_text(content, encoding="utf-8") - actions.append({"action": "create-file", "path": str(path.relative_to(repo_root))}) - created_paths.append(str(path.relative_to(repo_root))) - for directory in [repo_root / "skills", repo_root / "docs" / "maintainers"]: - if directory.exists(): - continue - directory.mkdir(parents=True, exist_ok=True) - actions.append({"action": "create-dir", "path": str(directory.relative_to(repo_root))}) - created_paths.append(str(directory.relative_to(repo_root))) - for path, target in expected_symlinks(repo_root, plugin_name).items(): - if path.is_symlink() and os.readlink(path) == target: - continue - if path.exists() and not path.is_symlink(): - actions.append( - { - "action": "skip-existing-path", - "path": str(path.relative_to(repo_root)), - "reason": "Existing non-symlink path must be reviewed manually.", - } - ) - continue - _ensure_parent(path) - if path.is_symlink(): - path.unlink() - os.symlink(target, path) - actions.append({"action": "create-symlink", "path": str(path.relative_to(repo_root)), "target": target}) - created_paths.append(str(path.relative_to(repo_root))) - return actions, created_paths - - -def build_report(repo_root: Path, plugin_name: str, run_mode: str, findings: list[Finding], apply_actions: list[dict[str, str]], created_paths: list[str], errors: list[str]) -> dict[str, object]: - return { - "run_context": {"repo_root": str(repo_root), "plugin_name": plugin_name, "run_mode": run_mode}, - "findings": [asdict(item) for item in findings], - "apply_actions": apply_actions, - "created_paths": created_paths, - "errors": errors, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--repo-root", required=True) - parser.add_argument("--run-mode", choices=("check-only", "apply"), required=True) - parser.add_argument("--plugin-name") - parser.add_argument("--print-md", action="store_true") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - repo_root = Path(args.repo_root).resolve() - if not repo_root.exists() or not repo_root.is_dir(): - print("Repository root does not exist or is not a directory.", file=sys.stderr) - return 1 - plugin_name = infer_plugin_name(repo_root, args.plugin_name) - errors: list[str] = [] - findings = audit_repo(repo_root, plugin_name) - apply_actions: list[dict[str, str]] = [] - created_paths: list[str] = [] - if args.run_mode == "apply": - apply_actions, created_paths = apply_repo(repo_root, plugin_name) - findings = audit_repo(repo_root, plugin_name) - report = build_report(repo_root, plugin_name, args.run_mode, findings, apply_actions, created_paths, errors) - if args.print_md and not findings and not apply_actions and not errors: - print(EXACT_NO_FINDINGS) - else: - print(json.dumps(report, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/agent-portability-skills/skills/hermes-agent-compatibility/SKILL.md b/plugins/agent-portability-skills/skills/hermes-agent-compatibility/SKILL.md index f105c3c07..d84db6a99 100644 --- a/plugins/agent-portability-skills/skills/hermes-agent-compatibility/SKILL.md +++ b/plugins/agent-portability-skills/skills/hermes-agent-compatibility/SKILL.md @@ -40,8 +40,7 @@ export manually. 3. Add `metadata.hermes.category` and `metadata.hermes.tags` when they improve Hermes discovery without changing the skill's meaning. 4. Add the skill name to the relevant root `skills.sh.json` grouping. -5. Run `uv run scripts/export_hermes_skills.py` and - `uv run scripts/validate_hermes_compatibility.py`. +5. Run `just repo-sync` and `just repo-validate` from the Socket root. 6. Review the generated root `skills/` diff with the authored source. The validator requires an exact mirror so a GitHub tap installs the reviewed content. diff --git a/plugins/agent-portability-skills/skills/operate-acp-agent-integration/SKILL.md b/plugins/agent-portability-skills/skills/operate-acp-agent-integration/SKILL.md index 724517bff..fb0b311d2 100644 --- a/plugins/agent-portability-skills/skills/operate-acp-agent-integration/SKILL.md +++ b/plugins/agent-portability-skills/skills/operate-acp-agent-integration/SKILL.md @@ -20,7 +20,7 @@ for the current connection and failure map. Check the capabilities negotiated by the actual pair; do not infer wire compatibility from SDK package versions or implement a draft RFD as stable. 3. Check the canonical ACP Registry with - `scripts/check_acp_registry.py <agent-id>`. + the managed ACP registry checker. 4. If the agent is missing, use its official local executable only when the client supports custom agents. Keep registry absence distinct from missing ACP support. diff --git a/plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx b/plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx new file mode 100644 index 000000000..eb1f03429 --- /dev/null +++ b/plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx @@ -0,0 +1,41 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Net.Http +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 |> Array.toList +let optionValue name = args |> List.tryFindIndex ((=) name) |> Option.bind (fun index -> args |> List.tryItem(index + 1)) +let positional = args |> List.filter (fun value -> not (value.StartsWith("--")) && Some value <> optionValue "--registry-url" && Some value <> optionValue "--format") +let query = positional |> List.tryHead |> Option.defaultWith (fun () -> failwith "Pass an exact ACP agent id or display name.") +let url = optionValue "--registry-url" |> Option.defaultValue "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json" +let format = optionValue "--format" |> Option.defaultValue "text" + +let client = new HttpClient(Timeout = TimeSpan.FromSeconds(15.0)) +client.DefaultRequestHeaders.UserAgent.ParseAdd("socket-acp-registry-check/1") +let content = client.GetStringAsync(url).GetAwaiter().GetResult() +let document = JsonDocument.Parse(content) +let root = document.RootElement +let mutable agents = Unchecked.defaultof<JsonElement> +if not (root.TryGetProperty("agents", &agents)) || agents.ValueKind <> JsonValueKind.Array then + failwith $"The ACP registry response from {url} does not contain an agents array." +let matches = + agents.EnumerateArray() + |> Seq.filter (fun (agent: JsonElement) -> + let exact (property: string) = + let mutable value = Unchecked.defaultof<JsonElement> + agent.TryGetProperty(property, &value) && value.ValueKind = JsonValueKind.String && String.Equals(value.GetString(), query, StringComparison.OrdinalIgnoreCase) + exact "id" || exact "name") + |> Seq.toArray +let version = let mutable value = Unchecked.defaultof<JsonElement> in if root.TryGetProperty("version", &value) then value.ToString() else "" +if format = "json" then + let serializedMatches = matches |> Array.map (fun item -> JsonSerializer.Deserialize<JsonElement>(item.GetRawText())) + let payload = {| query = query; registry_url = url; registry_version = version; present = not (Array.isEmpty matches); matches = serializedMatches |} + printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +elif Array.isEmpty matches then + printfn "ACP Registry does not currently contain an exact id or name match for '%s'." query +else + for agent in matches do + let field (name: string) fallback = let mutable value = Unchecked.defaultof<JsonElement> in if agent.TryGetProperty(name, &value) then value.ToString() else fallback + printfn "ACP Registry contains %s (%s) at version %s." (field "name" "(unnamed)") (field "id" "(no id)") (field "version" "(unknown)") +if Array.isEmpty matches then exit 1 diff --git a/plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check_acp_registry.py b/plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check_acp_registry.py deleted file mode 100755 index e56056e58..000000000 --- a/plugins/agent-portability-skills/skills/operate-acp-agent-integration/scripts/check_acp_registry.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -"""Check the canonical ACP Registry for an exact agent identifier or name.""" - -from __future__ import annotations - -import argparse -import json -import sys -from typing import Any -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - - -DEFAULT_REGISTRY = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json" - - -def load_registry(url: str) -> dict[str, Any]: - request = Request(url, headers={"User-Agent": "socket-acp-registry-check/1"}) - try: - with urlopen(request, timeout=15) as response: # noqa: S310 - caller controls reviewed registry URL - payload = json.load(response) - except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error: - raise RuntimeError( - f"The ACP registry could not be read from {url}: {error}. " - "Check network access and the registry URL before changing client configuration." - ) from error - if not isinstance(payload, dict) or not isinstance(payload.get("agents"), list): - raise RuntimeError( - f"The ACP registry response from {url} does not contain an agents array. " - "The registry schema may have changed or the URL may not be canonical." - ) - return payload - - -def find_agents(payload: dict[str, Any], query: str) -> list[dict[str, Any]]: - folded = query.casefold() - matches: list[dict[str, Any]] = [] - for value in payload["agents"]: - if not isinstance(value, dict): - continue - identifier = value.get("id") - name = value.get("name") - if (isinstance(identifier, str) and identifier.casefold() == folded) or ( - isinstance(name, str) and name.casefold() == folded - ): - matches.append(value) - return matches - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Check whether an agent is currently published in the canonical ACP Registry." - ) - parser.add_argument("query", help="Exact ACP agent id or display name") - parser.add_argument("--registry-url", default=DEFAULT_REGISTRY) - parser.add_argument("--format", choices=("text", "json"), default="text") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - try: - payload = load_registry(args.registry_url) - matches = find_agents(payload, args.query) - except RuntimeError as error: - print(str(error), file=sys.stderr) - return 2 - - result = { - "query": args.query, - "registry_url": args.registry_url, - "registry_version": payload.get("version"), - "present": bool(matches), - "matches": matches, - } - if args.format == "json": - print(json.dumps(result, indent=2, sort_keys=True)) - elif matches: - for match in matches: - print( - f"ACP Registry contains {match.get('name', '(unnamed)')} " - f"({match.get('id', '(no id)')}) at version {match.get('version', '(unknown)')}." - ) - else: - print( - f"ACP Registry does not currently contain an exact id or name match for {args.query!r}. " - "Use an official custom launch command only if the client supports one." - ) - return 0 if matches else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/SKILL.md b/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/SKILL.md deleted file mode 100644 index ce6d23e8c..000000000 --- a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: sync-skills-repo-guidance -description: Audit Agent Skills or Codex plugin guidance and discovery mirrors. Use for stale policy, missing mirrors, or unclear portable-skill and host-plugin boundaries; defer narrow docs work. -metadata: - hermes: - category: agent-portability - tags: [agent-skills, codex, plugin, guidance] ---- - -# Sync Skills Repo Guidance - -Audit an existing Agent Skills or Codex plugin repository against the current house guidance and upstream standards. - -This is the Codex and shared-skills guidance-sync workflow inside Agent Portability Skills. It should preserve the difference between portable Agent Skills and host-specific packaging such as Codex plugins, Xcode plug-ins, Zed extensions, OpenCode config, Claude Code settings, MCP declarations, hooks, apps, and custom agents. - -## Codex Model Note - -When syncing Codex guidance, state clearly that OpenAI's documented Codex plugin system exposes repo-visible plugins through marketplace catalogs and does not document a richer repo-private scoping model beyond that. - -Before making policy claims about Codex Plugins, Skills, MCP, Hooks, marketplaces, or subagents, refresh the relevant OpenAI Codex docs. Keep this skill's local guidance focused on durable repo policy and remove copied upstream detail when the official docs already cover it clearly. - -## Codex Plugin Root Structure - -When this skill touches Codex packaging guidance, keep the plugin-root structure aligned with the current OpenAI docs: - -- every plugin has a manifest at `.codex-plugin/plugin.json` -- only `plugin.json` belongs in `.codex-plugin/` -- `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` belong at the plugin root -- plugin manifests should point to bundled skill folders with `"skills": "./skills/"` -- plugin manifests may point to bundled lifecycle hooks with `"hooks": "./hooks/hooks.json"`; if hooks live at `./hooks/hooks.json`, Codex checks that default path automatically -- plugin-bundled hooks are non-managed hooks, so installing or enabling a plugin does not make those hooks trusted automatically -- marketplace entries point `source.path` at the plugin root directory, not at `.codex-plugin/` - -## Codex Install Guidance - -Default user-facing install and update guidance to the official Git-backed marketplace commands. Use explicit refs such as `<owner>/<repo>@vX.Y.Z` only for pinned reproducible installs. Use manual local marketplace or copied-payload instructions only for local development, testing unpublished changes, or fallback cases where the Git-backed path is not available. - -Keep marketplace sources, marketplace catalogs, plugin payload directories, installed cache paths, and config-state distinct instead of collapsing them into one vague "plugin install" concept. Do not reproduce the full install-surface map unless the target repo truly needs a maintainer reference; link to the OpenAI docs for the full current details. - -When a workflow depends on a companion skill or plugin, first route through the Codex harness surfaces that are already available in the current session. Name the current-session skill to use, such as `repository-skills:maintain-project-repo`, before giving install advice. If the companion skill is missing from the session, tell the user to add or update the marketplace and install the plugin through Codex's plugin directory for future sessions; do not imply that editing `config.toml`, copying payload folders, or searching an arbitrary checkout is the standard way to make a skill callable from Codex. - -For `socket`, prefer: - -```bash -codex plugin marketplace add gaelic-ghost/socket -codex plugin marketplace upgrade socket -``` - -For standalone plugin repositories that carry their own repo marketplace, prefer the same pattern with that repository, for example: - -```bash -codex plugin marketplace add gaelic-ghost/apple-dev-skills -codex plugin marketplace add gaelic-ghost/SpeakSwiftlyServer -``` - -Do not describe `config.toml` as the place plugins install into. Do not describe a marketplace file as the install destination. Keep the wording explicit: marketplace sources are tracked by Codex, marketplaces are catalogs, plugin roots are payload directories, the cache is Codex's installed copy, and `config.toml` stores enabled-state. - -If you mention project-scoped `.codex/config.toml`, label it as a general Codex config capability from the config reference rather than as part of the documented plugin install-surface map. - -## Dependency Provenance - -When syncing `AGENTS.md`, include strict dependency guidance: - -- shared project dependencies must resolve from GitHub repository URLs, package managers, package registries, or other real remote repositories -- committed dependency declarations, lockfiles, scripts, docs, examples, generated project files, and CI config must not point at machine-local paths -- machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly - -## Codex Subagent Guidance - -When the user explicitly requests subagents, `skills-repo-guidance-sync`, review-packet planning, or asks to keep working while broad skills-repo guidance discovery happens in parallel, use the `skills-repo-guidance-sync` custom-agent role for bounded read-heavy discovery before this skill applies guidance sync. When the target is the Socket superproject itself, include root docs, marketplace metadata, and validation scripts in the bounded audit. - -Good `skills-repo-guidance-sync` jobs for this skill: - -- inspect AGENTS, README, maintainer docs, discovery mirrors, and plugin metadata for drift -- compare plugin, skill, hook, marketplace, and subagent claims against current official Codex docs -- inventory stale install-surface wording, unsupported non-Codex surfaces, and machine-local dependency guidance -- return a review packet with proposed patch set, validation handoff, affected files, and blockers - -Keep apply-mode edits in the main thread. The guidance sync worker may return proposed patch-set entries, but the main agent should review them with the user before saving, editing, or applying any edits. - -When auditing target skills, treat subagent guidance as useful only when it is explicit, bounded, and tied to real parallel support work. Match OpenAI's current Codex wording: - -- use `subagent` and `subagent workflow` rather than vague older `multi-agent` language -- say current Codex releases enable subagent workflows by default, but Codex only spawns subagents when there is an explicit trigger: the user asks for subagents or parallel agent work, or a narrower skill/plugin workflow instructs the agent to ask first and the user grants explicit permission -- mention built-in `default`, `worker`, and `explorer` agents only when agent configuration matters; avoid turning custom `.codex/agents/` setup into default skill boilerplate -- use `gpt-5.6-terra` as the current soft default only for explicitly pinned, bounded read-heavy roles; prefer `gpt-5.6` for harder reasoning or leave the model unpinned when Codex should choose -- prefer subagents for read-heavy discovery, docs pulling, tests, triage, log analysis, and summarization -- ask workers for concise findings, evidence, links, or file references instead of raw intermediate output -- keep write-heavy apply work in the main thread unless the user explicitly requests parallel implementation with disjoint write scopes -- preserve plugin-specific guidance that is stricter about subagent use, such as Codex Security repository-wide scan workflows that ask for subagents because the file-pass review depends on parallel workers - -Flag skill guidance that implies automatic delegation, recommends parallel writes without ownership boundaries, adds subagent advice to narrow single-file or sequential workflows, or suppresses narrower plugin guidance that explicitly calls for subagents. - -## Codex Hooks Guidance - -When auditing target skills or plugin-repo docs that mention OpenAI Codex Hooks, keep hooks conceptually separate from marketplace and install-surface guidance. Hooks are Codex runtime lifecycle scripts; plugins may bundle lifecycle config, but hooks are not themselves a plugin install surface. - -Flag hooks guidance that uses deprecated `features.codex_hooks` wording instead of canonical `features.hooks`, refers to removed or legacy plugin-hook gates such as `features.plugin_hooks`, implies hooks are disabled by default, implies project-local hooks load without a trusted `.codex/` layer, treats `PreToolUse` or `PostToolUse` as complete enforcement for every tool path, omits non-managed hook trust review, or confuses Codex Hooks with git pre-commit hooks or repo-maintenance hook scripts. - -## GitHub Repository Settings - -When the target repository has a GitHub remote, include repository settings in -the sync audit and route the canonical baseline through -`repository-skills:maintain-github-repository`. Report drift in repository -features, merge modes, Dependabot and security settings, private vulnerability -reporting, web commit sign-off, and branch protection. - -Keep this audit read-only unless the user requested settings changes. Do not -infer visibility changes, do not require reviewers a single-maintainer repo -does not have, and do not block a documented maintainer direct-push workflow. diff --git a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/agents/openai.yaml b/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/agents/openai.yaml deleted file mode 100644 index 89b846c63..000000000 --- a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Sync Skills Repo Guidance" - short_description: "Audit repo-wide guidance and mirror drift for skills repositories." - default_prompt: "Use $sync-skills-repo-guidance to audit ongoing guidance drift in this existing skills repository. Start with the current local audit script for AGENTS, optional README, `.gitignore`, optional maintainer-doc snippets, and discovery mirrors, refresh official OpenAI sources before making policy claims, keep Codex guidance explicit about marketplace-based plugin distribution, and do not overstate the current script's automation scope. When the user asks for subagents, skills-repo-guidance-sync, review-packet planning, or parallel skills-repo guidance discovery, delegate the read-heavy scan to the skills-repo-guidance-sync custom-agent role before the main thread reviews and applies any edits." diff --git a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/source-order.md b/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/source-order.md deleted file mode 100644 index 60f525401..000000000 --- a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/source-order.md +++ /dev/null @@ -1,7 +0,0 @@ -# Source Order - -1. root `skills/` -2. skill-local runtime files -3. repo docs: `AGENTS.md`, optional `README.md`, `ROADMAP.md` -4. maintainer docs under `docs/maintainers/` -5. local discovery mirrors: `.agents/skills` diff --git a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/sync-checklist.md b/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/sync-checklist.md deleted file mode 100644 index 16b3d5731..000000000 --- a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/references/sync-checklist.md +++ /dev/null @@ -1,13 +0,0 @@ -# Sync Checklist - -- root `skills/` is canonical -- `.codex-plugin/plugin.json` points at root `skills/` with `"skills": "./skills/"` -- `.agents/skills -> ../skills` -- AGENTS describes the repo as a source-first skills-export repository -- AGENTS keeps the Codex plugin-boundary note explicit -- README is optional; if present, it should stay public-facing and avoid duplicating manifest or AGENTS content -- AGENTS says to refresh current OpenAI Codex docs before changing plugin, skill, MCP, hooks, marketplace, or subagent guidance -- no nested staged plugin-directory guidance remains -- user-facing install and update guidance defaults to Git-backed marketplace sources and official marketplace add/upgrade commands -- no installer or install-validation guidance remains -- maintainer tooling guidance stays explicit diff --git a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py b/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py deleted file mode 100644 index 519468e82..000000000 --- a/plugins/agent-portability-skills/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -from __future__ import annotations - -import argparse -import json -import os -import sys -from dataclasses import asdict, dataclass -from pathlib import Path - - -EXACT_NO_FINDINGS = "No findings." -README_SNIPPETS = [ - "Installable maintainer skills for skills-export and plugin-export repositories.", - "does not document a richer repo-private scoping model", - "codex plugin marketplace add gaelic-ghost/socket", - "codex plugin marketplace upgrade socket", - "`agent-portability-skills` entry points at `./plugins/agent-portability-skills`", - "Git-backed marketplace sources", - "dev dependencies in `pyproject.toml`", - "`pytest`, `ruff`, and `mypy`", - "`\"skills\": \"./skills/\"`", - "Only `plugin.json` belongs in `.codex-plugin/`", - "refresh the official OpenAI docs", -] -AGENTS_SNIPPETS = [ - "canonical authored and exported surface", - 'manifest points to bundled skills with `"skills": "./skills/"`', - "`hooks/`", - "Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories", - "Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly", - "Default user-facing install and update guidance to Git-backed marketplace sources", - "`skills/install-plugin-to-socket`", - "`skills/validate-plugin-install-surfaces`", - "check the current OpenAI Codex docs", -] -AUDIT_SNIPPETS = [ - "This repository ships root `.codex-plugin` packaging and does not track a nested staged plugin directory for itself.", - 'Its plugin manifest must declare `"skills": "./skills/"`', - "user installs normally come through the Git-backed `socket` marketplace", - "This repository does not ship `install-plugin-to-socket`.", - "This repository does not ship `validate-plugin-install-surfaces`.", -] -INSTALL_SURFACES_SNIPPETS = [ - "only `plugin.json` belongs in `.codex-plugin/`", - 'plugin manifests point to bundled skill folders with a root-relative `"skills": "./skills/"` field', - "Tracked marketplace source", - "Preferred User Install And Update Path", - "codex plugin marketplace add gaelic-ghost/socket", - "codex plugin marketplace upgrade socket", - "Documented plugin path: `~/.codex/config.toml`", - "project-scoped `.codex/config.toml`, label it as a general Codex config capability", - "first route through the Codex harness surfaces that are already available in the current session", - "install the plugin through Codex's plugin directory for future sessions", -] -GITIGNORE_SNIPPETS: list[str] = [] - -@dataclass -class Finding: - path: str - issue_id: str - message: str - - -def infer_plugin_name(repo_root: Path, explicit: str | None) -> str: - return explicit or repo_root.name - - -def _check_file_contains(repo_root: Path, path: Path, snippets: list[str], issue_prefix: str) -> list[Finding]: - findings: list[Finding] = [] - if not path.exists(): - findings.append(Finding(str(path.relative_to(repo_root)), "missing-path", "Expected repo guidance file is missing.")) - return findings - text = path.read_text(encoding="utf-8") - for snippet in snippets: - if snippet not in text: - findings.append(Finding(str(path.relative_to(repo_root)), f"{issue_prefix}-missing-snippet", f"Expected to mention: {snippet}")) - return findings - - -def _check_symlink(repo_root: Path, path: Path, target: str) -> list[Finding]: - rel = str(path.relative_to(repo_root)) - if not path.exists() and not path.is_symlink(): - return [Finding(rel, "missing-symlink", f"Expected symlink to {target}.")] - if not path.is_symlink(): - return [Finding(rel, "not-symlink", f"Expected POSIX symlink to {target}.")] - actual = os.readlink(path) - if actual != target: - return [Finding(rel, "wrong-symlink-target", f"Expected {target}, found {actual}.")] - return [] - - -def audit_repo(repo_root: Path, plugin_name: str) -> list[Finding]: - findings: list[Finding] = [] - readme = repo_root / "README.md" - if readme.exists(): - findings.extend(_check_file_contains(repo_root, readme, README_SNIPPETS, "readme")) - findings.extend(_check_file_contains(repo_root, repo_root / "AGENTS.md", AGENTS_SNIPPETS, "agents")) - findings.extend(_check_file_contains(repo_root, repo_root / ".gitignore", GITIGNORE_SNIPPETS, "gitignore")) - reality_audit = repo_root / "docs" / "maintainers" / "reality-audit.md" - if reality_audit.exists(): - findings.extend(_check_file_contains(repo_root, reality_audit, AUDIT_SNIPPETS, "reality-audit")) - install_surfaces = repo_root / "docs" / "maintainers" / "codex-plugin-install-surfaces.md" - if install_surfaces.exists(): - findings.extend(_check_file_contains(repo_root, install_surfaces, INSTALL_SURFACES_SNIPPETS, "install-surfaces")) - findings.extend(_check_symlink(repo_root, repo_root / ".agents" / "skills", "../skills")) - manifest_path = repo_root / ".codex-plugin" / "plugin.json" - if not manifest_path.exists(): - findings.append( - Finding( - ".codex-plugin/plugin.json", - "missing-plugin-manifest", - "Expected source-repo plugin packaging at `.codex-plugin/plugin.json`.", - ) - ) - else: - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - findings.append( - Finding( - ".codex-plugin/plugin.json", - "invalid-plugin-manifest", - f"Expected valid JSON plugin manifest: {exc.msg}.", - ) - ) - else: - if manifest.get("skills") != "./skills/": - findings.append( - Finding( - ".codex-plugin/plugin.json", - "missing-skills-component", - 'Expected plugin manifest to declare bundled skills with `"skills": "./skills/"`.', - ) - ) - if (repo_root / "plugins").exists(): - findings.append(Finding("plugins", "forbidden-path", "Nested staged plugin directories are forbidden for this repo model.")) - return findings - - -def build_report(repo_root: Path, plugin_name: str, run_mode: str, findings: list[Finding], errors: list[str]) -> dict[str, object]: - return {"run_context": {"repo_root": str(repo_root), "plugin_name": plugin_name, "run_mode": run_mode}, "findings": [asdict(item) for item in findings], "errors": errors} - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--repo-root", required=True) - parser.add_argument("--run-mode", choices=("check-only", "apply"), required=True) - parser.add_argument("--plugin-name") - parser.add_argument("--print-md", action="store_true") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - repo_root = Path(args.repo_root).resolve() - if not repo_root.exists() or not repo_root.is_dir(): - print("Repository root does not exist or is not a directory.", file=sys.stderr) - return 1 - plugin_name = infer_plugin_name(repo_root, args.plugin_name) - findings = audit_repo(repo_root, plugin_name) - report = build_report(repo_root, plugin_name, args.run_mode, findings, []) - if args.print_md and not findings: - print(EXACT_NO_FINDINGS) - else: - print(json.dumps(report, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/agentdeck/hooks/capture-session-start.sh b/plugins/agentdeck/hooks/capture-session-start.sh deleted file mode 100644 index 5431f21d7..000000000 --- a/plugins/agentdeck/hooks/capture-session-start.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -set -eu - -hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) - -exec sh "$hook_dir/run-thread-title-hook.sh" diff --git a/plugins/agentdeck/hooks/hooks.json b/plugins/agentdeck/hooks/hooks.json index c9e00c05b..371e6c956 100644 --- a/plugins/agentdeck/hooks/hooks.json +++ b/plugins/agentdeck/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "sh ${PLUGIN_ROOT}/hooks/run-thread-title-hook.sh", + "command": "dotnet fsi ${PLUGIN_ROOT}/hooks/run-thread-title-hook.fsx", "statusMessage": "Running AgentDeck SessionStart hook", "timeout": 10 } @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "command": "sh ${PLUGIN_ROOT}/hooks/run-thread-title-hook.sh", + "command": "dotnet fsi ${PLUGIN_ROOT}/hooks/run-thread-title-hook.fsx", "statusMessage": "Running AgentDeck Stop hook", "timeout": 10 } @@ -30,7 +30,7 @@ "hooks": [ { "type": "command", - "command": "sh ${PLUGIN_ROOT}/hooks/run-thread-title-hook.sh", + "command": "dotnet fsi ${PLUGIN_ROOT}/hooks/run-thread-title-hook.fsx", "statusMessage": "Logging AgentDeck tool-use hook", "timeout": 10 } diff --git a/plugins/agentdeck/hooks/run-thread-title-hook.fsx b/plugins/agentdeck/hooks/run-thread-title-hook.fsx new file mode 100644 index 000000000..1ef1cc6d1 --- /dev/null +++ b/plugins/agentdeck/hooks/run-thread-title-hook.fsx @@ -0,0 +1,13 @@ +#!/usr/bin/env -S dotnet fsi + +open System.Diagnostics +open System.IO + +let pluginRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) +let info = ProcessStartInfo("node") +info.WorkingDirectory <- pluginRoot +info.UseShellExecute <- false +info.ArgumentList.Add(Path.Combine(pluginRoot, "scripts", "session-start-hook.mjs")) +use child = Process.Start(info) +child.WaitForExit() +exit child.ExitCode diff --git a/plugins/agentdeck/hooks/run-thread-title-hook.sh b/plugins/agentdeck/hooks/run-thread-title-hook.sh deleted file mode 100644 index 4c43abec9..000000000 --- a/plugins/agentdeck/hooks/run-thread-title-hook.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -set -eu - -hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -plugin_root=$(dirname -- "$hook_dir") - -exec node "$plugin_root/scripts/session-start-hook.mjs" diff --git a/plugins/apple-creator-studio-skills/AGENTS.md b/plugins/apple-creator-studio-skills/AGENTS.md index a97e1f5a3..56d2f07a8 100644 --- a/plugins/apple-creator-studio-skills/AGENTS.md +++ b/plugins/apple-creator-studio-skills/AGENTS.md @@ -35,5 +35,5 @@ uv run python "$HOME/.codex/skills/.system/skill-creator/scripts/quick_validate. When manifest, marketplace, or root documentation changes, also run: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate ``` diff --git a/plugins/apple-dev-skills/.github/scripts/sync_shared_snippets.sh b/plugins/apple-dev-skills/.github/scripts/sync_shared_snippets.sh deleted file mode 100755 index 5b8da680c..000000000 --- a/plugins/apple-dev-skills/.github/scripts/sync_shared_snippets.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -MODE="${1:-sync}" - -if [[ "$MODE" != "sync" && "$MODE" != "--check" && "$MODE" != "check" ]]; then - echo "Usage: $0 [--check|check]" >&2 - exit 2 -fi - -check_mode=false -if [[ "$MODE" == "--check" || "$MODE" == "check" ]]; then - check_mode=true -fi - -sync_one() { - local source="$1" - shift - local targets=("$@") - - [[ -f "$source" ]] || { - echo "Missing source snippet: $source" >&2 - exit 1 - } - - for target in "${targets[@]}"; do - local target_dir - target_dir="$(dirname "$target")" - [[ -d "$target_dir" ]] || { - echo "Missing target directory: $target_dir" >&2 - exit 1 - } - if "$check_mode"; then - [[ -f "$target" ]] || { - echo "Missing target snippet: $target" >&2 - exit 1 - } - cmp -s "$source" "$target" || { - echo "Snippet drift detected between $source and $target" >&2 - exit 1 - } - else - cp "$source" "$target" - fi - done -} - -sync_one \ - "$ROOT_DIR/shared/agents-snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/apple-ui-accessibility-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/xcode-build-run-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/xcode-testing-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/format-swift-sources/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/explore-apple-swift-docs/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/avfaudio-session-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/avaudio-engine-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/avfoundation-media-pipeline-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/coremedia-timing-samplebuffer-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/coreaudio-modernization-repair-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/core-image-processing-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/apple-image-representation-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/vision-image-analysis-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/vision-coreml-recognition-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/camera-capture-depth-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/arkit-spatial-sensing-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/arkit-face-body-tracking-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/video-codec-processing-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/photos-library-editing-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/core-animation-layer-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/apple-typography-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/sf-symbols-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/swiftui-animation-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/tipkit-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/safari-extension-control-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/app-extension-architecture-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/mailkit-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/file-provider-and-finder-sync-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/devicecheck-app-attest-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/apple-developer-provisioning-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/swiftui-app-architecture-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/swiftui-component-audit-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/swiftdata-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/appkit-app-architecture-workflow/references/snippets/apple-xcode-project-core.md" \ - "$ROOT_DIR/skills/xcode-coding-intelligence-workflow/references/snippets/apple-xcode-project-core.md" - -sync_one \ - "$ROOT_DIR/shared/agents-snippets/apple-swift-package-core.md" \ - "$ROOT_DIR/skills/swift-package-extension-workflow/references/snippets/apple-swift-package-core.md" \ - "$ROOT_DIR/skills/swift-package-build-run-workflow/references/snippets/apple-swift-package-core.md" \ - "$ROOT_DIR/skills/swift-package-testing-workflow/references/snippets/apple-swift-package-core.md" - -if "$check_mode"; then - echo "Shared snippet skill-local copies are in sync." -else - echo "Synchronized shared snippet set to skill-local copies." -fi diff --git a/plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh b/plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh deleted file mode 100644 index ce3abf59d..000000000 --- a/plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$ROOT_DIR" - -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -require_contains() { - local file="$1" - local needle="$2" - grep -Fq -- "$needle" "$file" || fail "Missing required string in $file: $needle" -} - -echo "Validating root docs presence..." -[[ -f README.md ]] || fail "Missing README.md at repo root." -[[ -f CONTRIBUTING.md ]] || fail "Missing CONTRIBUTING.md at repo root." -[[ -f AGENTS.md ]] || fail "Missing AGENTS.md at repo root." -[[ -f docs/maintainers/reality-audit.md ]] || fail "Missing docs/maintainers/reality-audit.md." - -echo "Validating local discovery mirrors..." -[[ -L ".agents/skills" ]] || fail "Expected .agents/skills to be a symlink to ../skills" -[[ "$(readlink .agents/skills)" == "../skills" ]] || fail "Expected .agents/skills -> ../skills" - -echo "Validating root README contract..." -require_contains "README.md" 'Treat `repository-skills` as the default baseline layer for general repo-doc and maintenance work' -require_contains "README.md" 'This repository is the canonical source of truth for Gale'"'"'s Apple, Swift, and Xcode workflow skills.' -require_contains "README.md" 'Most Apple Dev Skills workflows are useful as a standalone plugin.' -require_contains "README.md" 'The [`socket`](https://github.com/gaelic-ghost/socket) repository is Gale'"'"'s plugin superproject and marketplace catalog.' -require_contains "README.md" 'Treat root [`skills/`](./skills/) as the canonical authored surface.' -require_contains "README.md" 'Keep shared reusable assets in [`shared/`](./shared/)' -require_contains "README.md" 'Run the repository test suite for skill and metadata changes:' -require_contains "README.md" 'Use [`CONTRIBUTING.md`](./CONTRIBUTING.md) for maintainer workflow details' - -echo "Validating CONTRIBUTING contract..." -require_contains "CONTRIBUTING.md" 'Use this guide when preparing changes so the repository stays understandable, testable, and truthful about the Apple workflow surface it actually ships.' -require_contains "CONTRIBUTING.md" '## Contribution Workflow' -require_contains "CONTRIBUTING.md" '## Local Setup' -require_contains "CONTRIBUTING.md" '## Development Expectations' -require_contains "CONTRIBUTING.md" 'bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh' -require_contains "CONTRIBUTING.md" 'uv run python -B -m pytest plugins/apple-dev-skills/tests' - -echo "Validating AGENTS contract..." -require_contains "AGENTS.md" 'This repository is the canonical home for Gale'"'"'s Apple, Swift, and Xcode workflow skills.' -require_contains "AGENTS.md" 'Treat `repository-skills` as the default baseline maintainer layer' -require_contains "AGENTS.md" 'Preserve standalone-install guidance for public users who install only `apple-dev-skills`' -require_contains "AGENTS.md" 'Root `skills/` is the canonical authored and exported surface.' -require_contains "AGENTS.md" 'Keep shared reusable assets in [`shared/`](./shared/) and maintainer tests in [`tests/`](./tests/).' -require_contains "AGENTS.md" 'require reading the relevant Apple documentation before proposing implementation changes.' -require_contains "AGENTS.md" 'Keep `explore-apple-swift-docs` as the canonical docs-routing surface' - -echo "Validating reality audit guide..." -audit_doc="docs/maintainers/reality-audit.md" -require_contains "$audit_doc" "## Source-of-Truth Order" -require_contains "$audit_doc" "## Audit Procedure" -require_contains "$audit_doc" "## Local Discovery Smoke Test Flow" -require_contains "$audit_doc" "## Reporting Shape" -require_contains "$audit_doc" '`repository-skills` owns the reusable `maintain-project-repo` toolkit contract' -require_contains "$audit_doc" 'standalone `apple-dev-skills` installs from installs that also include the `repository-skills` companion plugin' -require_contains "$audit_doc" 'this repository owns only the Apple-specific profile selection and Xcode MCP registration contract' - -echo "Validating skill directory layout..." -active_skill_mds=( - "./skills/xcode-build-run-workflow/SKILL.md" - "./skills/xcode-testing-workflow/SKILL.md" - "./skills/swift-package-build-run-workflow/SKILL.md" - "./skills/swift-package-testing-workflow/SKILL.md" - "./skills/swift-package-extension-workflow/SKILL.md" - "./skills/author-swift-docc-docs/SKILL.md" - "./skills/avfaudio-session-workflow/SKILL.md" - "./skills/avaudio-engine-workflow/SKILL.md" - "./skills/avfoundation-media-pipeline-workflow/SKILL.md" - "./skills/coremedia-timing-samplebuffer-workflow/SKILL.md" - "./skills/coreaudio-modernization-repair-workflow/SKILL.md" - "./skills/core-image-processing-workflow/SKILL.md" - "./skills/apple-image-representation-workflow/SKILL.md" - "./skills/vision-image-analysis-workflow/SKILL.md" - "./skills/vision-coreml-recognition-workflow/SKILL.md" - "./skills/camera-capture-depth-workflow/SKILL.md" - "./skills/arkit-spatial-sensing-workflow/SKILL.md" - "./skills/arkit-face-body-tracking-workflow/SKILL.md" - "./skills/video-codec-processing-workflow/SKILL.md" - "./skills/photos-library-editing-workflow/SKILL.md" - "./skills/core-animation-layer-workflow/SKILL.md" - "./skills/apple-typography-workflow/SKILL.md" - "./skills/sf-symbols-workflow/SKILL.md" - "./skills/swiftui-animation-workflow/SKILL.md" - "./skills/safari-extension-control-workflow/SKILL.md" - "./skills/safari-mcp-workflow/SKILL.md" - "./skills/app-extension-architecture-workflow/SKILL.md" - "./skills/mailkit-workflow/SKILL.md" - "./skills/file-provider-and-finder-sync-workflow/SKILL.md" - "./skills/devicecheck-app-attest-workflow/SKILL.md" - "./skills/apple-developer-provisioning-workflow/SKILL.md" - "./skills/appkit-app-architecture-workflow/SKILL.md" - "./skills/app-intents-workflow/SKILL.md" - "./skills/ios-runtime-forensics-workflow/SKILL.md" - "./skills/macos-distribution-workflow/SKILL.md" - "./skills/swiftui-app-architecture-workflow/SKILL.md" - "./skills/swiftui-component-audit-workflow/SKILL.md" - "./skills/swiftui-liquid-glass/SKILL.md" - "./skills/swiftui-performance-audit/SKILL.md" - "./skills/swiftdata-workflow/SKILL.md" - "./skills/tipkit-workflow/SKILL.md" - "./skills/tips-helpviewer-workflow/SKILL.md" - "./skills/apple-ui-accessibility-workflow/SKILL.md" - "./skills/explore-apple-swift-docs/SKILL.md" - "./skills/format-swift-sources/SKILL.md" - "./skills/structure-swift-sources/SKILL.md" - "./skills/bootstrap-xcode-workspace/SKILL.md" - "./skills/xcode-coding-intelligence-workflow/SKILL.md" - "./skills/xcode-localization-workflow/SKILL.md" - "./skills/choose-macos-virtualization-shape/SKILL.md" - "./skills/virtualization-framework-workflow/SKILL.md" - "./skills/linux-development-vm-workflow/SKILL.md" - "./skills/macos-development-vm-workflow/SKILL.md" - "./skills/macos-privacy-permissions-workflow/SKILL.md" - "./skills/macos-sandbox-file-access-workflow/SKILL.md" - "./skills/diagnose-apple-entitlements/SKILL.md" - "./skills/tvos-app-experience-workflow/SKILL.md" - "./skills/tvos-media-playback-workflow/SKILL.md" -) -[[ ${#active_skill_mds[@]} -eq 58 ]] || fail "Expected exactly 58 active skills, found ${#active_skill_mds[@]}." - -shared_xcode_snippet="./shared/agents-snippets/apple-xcode-project-core.md" -shared_package_snippet="./shared/agents-snippets/apple-swift-package-core.md" -[[ -f "$shared_xcode_snippet" ]] || fail "Missing shared snippet: $shared_xcode_snippet" -[[ -f "$shared_package_snippet" ]] || fail "Missing shared snippet: $shared_package_snippet" - -echo "Validating shared snippet sync..." -bash .github/scripts/sync_shared_snippets.sh --check - -for skill_md in "${active_skill_mds[@]}"; do - skill_dir="${skill_md%/SKILL.md}" - [[ -f "$skill_dir/agents/openai.yaml" ]] || fail "Missing $skill_dir/agents/openai.yaml" - [[ -d "$skill_dir/references" ]] || fail "Missing $skill_dir/references/" - - case "$skill_dir" in - ./skills/structure-swift-sources|./skills/bootstrap-xcode-workspace) - ;; - *) - [[ -f "$skill_dir/references/customization.template.yaml" ]] || fail "Missing $skill_dir/references/customization.template.yaml" - [[ -f "$skill_dir/references/customization-flow.md" ]] || fail "Missing $skill_dir/references/customization-flow.md" - [[ -f "$skill_dir/scripts/customization_config.py" ]] || fail "Missing $skill_dir/scripts/customization_config.py" - ;; - esac - - for heading in \ - "^## Purpose$" \ - "^## When To Use$" \ - "^## Single-Path Workflow$" \ - "^## Inputs$" \ - "^## Outputs$" \ - "^## Guards and Stop Conditions$" \ - "^## Fallbacks and Handoffs$" \ - "^## Customization$" \ - "^## References$" - do - grep -q "$heading" "$skill_md" || fail "Missing required heading in $skill_md: ${heading#^}" - done - - # Some skills are policy-only and intentionally do not ship scripts. - if grep -q "scripts/" "$skill_md"; then - [[ -d "$skill_dir/scripts" ]] || fail "Missing $skill_dir/scripts/ (referenced by $skill_md)" - fi - - case "$skill_dir" in - ./skills/swift-package-build-run-workflow|./skills/swift-package-testing-workflow|./skills/swift-package-extension-workflow) - local_snippet="$skill_dir/references/snippets/apple-swift-package-core.md" - shared_snippet="$shared_package_snippet" - snippet_ref='references/snippets/apple-swift-package-core.md' - ;; - ./skills/structure-swift-sources|./skills/bootstrap-xcode-workspace|./skills/author-swift-docc-docs|./skills/safari-mcp-workflow|./skills/tvos-app-experience-workflow|./skills/tvos-media-playback-workflow) - local_snippet="" - shared_snippet="" - snippet_ref="" - ;; - *) - local_snippet="$skill_dir/references/snippets/apple-xcode-project-core.md" - shared_snippet="$shared_xcode_snippet" - snippet_ref='references/snippets/apple-xcode-project-core.md' - ;; - esac - - if [[ -n "$local_snippet" ]]; then - [[ -f "$local_snippet" ]] || fail "Missing $local_snippet" - cmp -s "$shared_snippet" "$local_snippet" || fail "Snippet drift detected between $shared_snippet and $local_snippet" - - grep -Fq "$snippet_ref" "$skill_md" || fail "Missing local snippet reference in $skill_md" - grep -Eiq "recommend.{0,120}$snippet_ref|$snippet_ref.{0,120}recommend" "$skill_md" || fail "Missing snippet recommendation guidance in $skill_md" - fi -done - -echo "Validating Dash docs exploration references..." -dash_skill_dir="./skills/explore-apple-swift-docs" -[[ -f "$dash_skill_dir/references/dash_call_library.md" ]] || fail "Missing $dash_skill_dir/references/dash_call_library.md" -require_contains "$dash_skill_dir/SKILL.md" 'Prefer direct docs access methods in this order: Xcode MCP `DocumentationSearch` first, Dash.app MCP second, Dash localhost HTTP only when the Dash.app MCP is unavailable or incomplete, then checked-out source, generated DocC, GitHub/source repositories, or release notes, and finally readable online documentation.' -require_contains "$dash_skill_dir/SKILL.md" 'Do not present `scripts/run_workflow.py` as the required first step' -require_contains "$dash_skill_dir/references/dash_call_library.md" '## Dash MCP Examples' -require_contains "$dash_skill_dir/references/dash_call_library.md" '## Dash Local HTTP Examples' -require_contains "$dash_skill_dir/references/dash_call_library.md" '## High-Value Docset Targets' - -echo "Validating maintain-project-repo delegation..." -require_contains "./skills/bootstrap-xcode-workspace/SKILL.md" 'repository-skills:maintain-project-repo' -require_contains "./skills/bootstrap-xcode-workspace/scripts/run_workflow.py" 'maintain-project-repo' - -echo "Validating preserved guidance in AGENTS assets..." -package_agents_assets=( - "./skills/bootstrap-xcode-workspace/assets/managed-guidance/AGENTS-packages.md" -) -for agents_asset in "${package_agents_assets[@]}"; do - require_contains "$agents_asset" '`swift-package-build-run-workflow`' - require_contains "$agents_asset" '`swift-package-testing-workflow`' - require_contains "$agents_asset" '`swift-package-extension-workflow`' - require_contains "$agents_asset" 'Resource.process(...)' - require_contains "$agents_asset" 'Resource.copy(...)' - require_contains "$agents_asset" 'Resource.embedInCode(...)' - require_contains "$agents_asset" 'Bundle.module' - require_contains "$agents_asset" '.xctestplan' - require_contains "$agents_asset" 'Debug and Release' - require_contains "$agents_asset" 'root workspace' -done - -echo "Validating skill-creator contract..." -uv run python .github/scripts/validate_skill_creator_contract.py >/dev/null - -echo "All validation checks passed." diff --git a/plugins/apple-dev-skills/.github/scripts/validate_skill_creator_contract.py b/plugins/apple-dev-skills/.github/scripts/validate_skill_creator_contract.py deleted file mode 100644 index 66f6e6a52..000000000 --- a/plugins/apple-dev-skills/.github/scripts/validate_skill_creator_contract.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import re -import sys -from pathlib import Path - -import yaml - - -ROOT = Path(__file__).resolve().parents[2] -SKILLS_DIR = ROOT / "skills" -MAX_SKILL_NAME_LENGTH = 64 - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def validate_skill(skill_dir: Path) -> tuple[bool, str]: - skill_md = skill_dir / "SKILL.md" - if not skill_md.exists(): - return False, "SKILL.md not found" - - content = skill_md.read_text(encoding="utf-8") - if not content.startswith("---"): - return False, "No YAML frontmatter found" - - match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) - if not match: - return False, "Invalid frontmatter format" - - frontmatter_text = match.group(1) - - try: - frontmatter = yaml.safe_load(frontmatter_text) - except yaml.YAMLError as error: - return False, f"Invalid YAML in frontmatter: {error}" - - if not isinstance(frontmatter, dict): - return False, "Frontmatter must be a YAML dictionary" - - allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} - unexpected_keys = set(frontmatter.keys()) - allowed_properties - if unexpected_keys: - allowed = ", ".join(sorted(allowed_properties)) - unexpected = ", ".join(sorted(unexpected_keys)) - return ( - False, - f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", - ) - - if "name" not in frontmatter: - return False, "Missing 'name' in frontmatter" - if "description" not in frontmatter: - return False, "Missing 'description' in frontmatter" - - name = frontmatter["name"] - if not isinstance(name, str): - return False, f"Name must be a string, got {type(name).__name__}" - name = name.strip() - if name: - if not re.match(r"^[a-z0-9-]+$", name): - return ( - False, - f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", - ) - if name.startswith("-") or name.endswith("-") or "--" in name: - return ( - False, - f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", - ) - if len(name) > MAX_SKILL_NAME_LENGTH: - return ( - False, - f"Name is too long ({len(name)} characters). Maximum is {MAX_SKILL_NAME_LENGTH} characters.", - ) - - description = frontmatter["description"] - if not isinstance(description, str): - return False, f"Description must be a string, got {type(description).__name__}" - description = description.strip() - if description: - if "<" in description or ">" in description: - return False, "Description cannot contain angle brackets (< or >)" - if len(description) > 1024: - return ( - False, - f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", - ) - - return True, "Skill is valid!" - - -def load_frontmatter_name(skill_dir: Path) -> str: - skill_md = skill_dir / "SKILL.md" - content = skill_md.read_text(encoding="utf-8") - _, frontmatter_text, _ = content.split("---", 2) - frontmatter = yaml.safe_load(frontmatter_text) - if not isinstance(frontmatter, dict): - fail(f"Frontmatter in {skill_md} is not a YAML mapping") - name = frontmatter.get("name") - if not isinstance(name, str) or not name.strip(): - fail(f"Missing valid skill name in {skill_md}") - return name.strip() - - -def validate_openai_yaml(skill_dir: Path, skill_name: str) -> None: - openai_yaml_path = skill_dir / "agents" / "openai.yaml" - if not openai_yaml_path.exists(): - fail(f"Missing {openai_yaml_path}") - - payload = yaml.safe_load(openai_yaml_path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - fail(f"{openai_yaml_path} is not a YAML mapping") - - interface = payload.get("interface") - if not isinstance(interface, dict): - fail(f"Missing interface section in {openai_yaml_path}") - - for key in ("display_name", "short_description", "default_prompt"): - value = interface.get(key) - if not isinstance(value, str) or not value.strip(): - fail(f"Missing non-empty interface.{key} in {openai_yaml_path}") - - short_description = interface["short_description"].strip() - if not 25 <= len(short_description) <= 64: - fail( - f"interface.short_description in {openai_yaml_path} must be 25-64 characters; " - f"got {len(short_description)}" - ) - - default_prompt = interface["default_prompt"] - skill_token = f"${skill_name}" - if skill_token not in default_prompt: - fail( - f"interface.default_prompt in {openai_yaml_path} must explicitly mention {skill_token}" - ) - - -def main() -> None: - skill_dirs = sorted(path for path in SKILLS_DIR.iterdir() if (path / "SKILL.md").exists()) - if not skill_dirs: - fail("No skills found to validate") - - for skill_dir in skill_dirs: - valid, message = validate_skill(skill_dir) - if not valid: - fail(f"{skill_dir}: {message}") - - skill_name = load_frontmatter_name(skill_dir) - validate_openai_yaml(skill_dir, skill_name) - print(f"[OK] {skill_name}") - - print("Skill creator contract validation passed.") - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/AGENTS.md b/plugins/apple-dev-skills/AGENTS.md index b99a1a8f7..67329c4e1 100644 --- a/plugins/apple-dev-skills/AGENTS.md +++ b/plugins/apple-dev-skills/AGENTS.md @@ -40,7 +40,7 @@ Run from the Socket repository root so the shared maintainer environment and cache policy apply: ```bash -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +just repo-validate uv run python -B -m pytest plugins/apple-dev-skills/tests \ -o cache_dir=.codex/.cache/pytest ``` diff --git a/plugins/apple-dev-skills/CONTRIBUTING.md b/plugins/apple-dev-skills/CONTRIBUTING.md index f3da2224d..880dd15ea 100644 --- a/plugins/apple-dev-skills/CONTRIBUTING.md +++ b/plugins/apple-dev-skills/CONTRIBUTING.md @@ -40,17 +40,17 @@ Ask for review after the changed docs, validator expectations, and relevant test ### Runtime Config -Sync the local maintainer environment with: +Inspect the root managed commands with: ```bash -uv sync --dev +just --list ``` This repository does not require app secrets or background services for its normal docs and skill-validation workflow. ### Runtime Behavior -The repository is healthy when the docs validator passes, the pytest suite passes, and the root docs describe the same shipped surface as the active skill directories. Use [`docs/maintainers/reality-audit.md`](./docs/maintainers/reality-audit.md) when you need the repo's source-of-truth order or audit procedure. +The repository is healthy when root validation and the single root E2E test pass, and the root docs describe the same shipped surface as the active skill directories. Use [`docs/maintainers/reality-audit.md`](./docs/maintainers/reality-audit.md) when you need the repo's source-of-truth order or audit procedure. ## Development Expectations @@ -60,19 +60,18 @@ Keep skill names literal and workflow-oriented. Preserve the existing Apple-spec ### Accessibility Expectations -This repository does not currently maintain a separate root `ACCESSIBILITY.md`. When you change Apple accessibility guidance here, keep it grounded in current Apple documentation, update the relevant skill docs and tests in the same pass, and avoid presenting generic visual-design advice as if it were accessibility guidance. +When you change Apple accessibility guidance, keep it grounded in current Apple documentation, update the owning skill in the same pass, and avoid presenting generic visual-design advice as if it were accessibility guidance. ### Verification -Use the grounded repo checks: +Run only the root integration gates: ```bash -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh -uv run python -B -m pytest plugins/apple-dev-skills/tests \ - -o cache_dir=.codex/.cache/pytest +just repo-validate +just test ``` -Run additional targeted checks only when the changed surface has a narrower validation path worth calling out. +Do not add nested or unit tests for this plugin. ## Pull Request Expectations diff --git a/plugins/apple-dev-skills/README.md b/plugins/apple-dev-skills/README.md index bd5372b32..ec5a4ba36 100644 --- a/plugins/apple-dev-skills/README.md +++ b/plugins/apple-dev-skills/README.md @@ -124,7 +124,7 @@ Use [`CONTRIBUTING.md`](./CONTRIBUTING.md) for maintainer workflow details, and Run the repository test suite for skill and metadata changes: ```bash -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +just repo-validate uv run python -B -m pytest plugins/apple-dev-skills/tests \ -o cache_dir=.codex/.cache/pytest ``` diff --git a/plugins/apple-dev-skills/ROADMAP.md b/plugins/apple-dev-skills/ROADMAP.md index 3f06d480c..4099021da 100644 --- a/plugins/apple-dev-skills/ROADMAP.md +++ b/plugins/apple-dev-skills/ROADMAP.md @@ -243,7 +243,7 @@ Completed - [x] Maintainers have a verified, reality-based local discovery and top-level export story for this repo, with docs and tooling updated to match what the repository actually ships. -Completed Milestone 27 by keeping the top-level `skills/` export story, local discovery symlinks, maintainer docs, and repo validator aligned with the live shipped surface, and by confirming that `bash .github/scripts/validate_repo_docs.sh` plus `uv run pytest` cover the intended self-compliance checks. +Completed Milestone 27 by keeping the top-level `skills/` export story, local discovery symlinks, maintainer docs, and root integration validation aligned with the live shipped surface. ## Milestone 28: Use `Agent Portability Skills` plugin selectively for plugin and export-surface alignment diff --git a/plugins/apple-dev-skills/docs/maintainers/customization-consolidation-review.md b/plugins/apple-dev-skills/docs/maintainers/customization-consolidation-review.md deleted file mode 100644 index 61eb315de..000000000 --- a/plugins/apple-dev-skills/docs/maintainers/customization-consolidation-review.md +++ /dev/null @@ -1,202 +0,0 @@ -# Customization Consolidation Review - -Date: 2026-04-04 - -## Purpose - -Record the Milestone 20 audit of the current customization system, decide whether to keep or shrink it, and define the follow-up plan before any MCP App or other UI work is built on top of it. - -## Current State Summary - -- The active skill surface ships `58` separate `references/customization.template.yaml` files. -- The active skill surface ships `58` separate `scripts/customization_config.py` entrypoints. -- Those `customization_config.py` files are functionally identical and exist only because installed skills are expected to keep runtime resources inside the skill directory. -- The current templates expose `12` knobs total: - - `11` are documented as `runtime-enforced` - - `1` is documented as `policy-only` -- The current surface mixes together four different categories that should not all be presented as the same kind of user customization: - - durable user preference - - inference candidate - - maintainer tuning - - safety or invariants that should not be softened through ordinary customization - -## Original Audit Baseline - -Milestone 20 audited a larger surface before the implementation pass landed. - -- The original Milestone 20 audit counted `35` knobs total: - - `30` documented as `runtime-enforced` - - `5` documented as `policy-only` -- Milestone 27 applied the approved reduction so the live surface now reflects the smaller counts in the current-state summary above. -- Milestone 38 later added the narrower `author-swift-docc-docs` skill with one runtime-enforced tutorial-handling knob, which is included in the current-state counts above. -- The current-state counts also include `structure-swift-sources`, which now ships runtime-enforced header-policy and split-threshold knobs for the structural-cleanup workflow. -- The current-state counts include the active no-runtime-knob workflow surfaces; retired repository bootstrap, migration, and compatibility routers are excluded because `bootstrap-xcode-workspace` now owns the sole Swift repository lifecycle. - -## Decision - -Milestone 20 concludes that the repo should shrink the customization surface rather than expand it. - -The decision is: - -1. Keep the current per-skill file locations and CLI shape for now. - - `references/customization.template.yaml` - - `scripts/customization_config.py` - - commands `path`, `effective`, `apply`, and `reset` -2. Do not centralize the runtime helper into a shared imported module at repo root. - - Installed skills are meant to keep runtime resources inside the skill directory. - - A shared runtime import would make the shipped skill depend on repository structure outside the skill. -3. Treat the duplicated helper plumbing as an authoring and maintenance problem, not as a user-facing architecture problem. - - If the duplication is still worth reducing after the surface shrinks, use maintainer-time generation or sync into local per-skill copies. -4. Move toward an inference-first model for defaults that can be derived from the requested component and operation, tool availability, or current environment. -5. Remove safety invariants and low-value maintainer tuning from the ordinary user-facing customization surface. - -## Knob Classification - -### Retired Repository-Lifecycle Customization - -- The former standalone package bootstrap exposed: - - `defaultVersionProfile` - - `defaultTestingMode` - - `initializeGit` - - `copyAgentsMd` - - `defaultOrgIdentifier` - - `copyAgentsMd` -- Those knobs were removed with the standalone bootstrap. Repository creation, - adoption, component addition, and alignment now belong only to - `bootstrap-xcode-workspace`, with component-specific choices supplied as - explicit invocation inputs. - -### Implemented User-Meaningful Customization - -- `explore-apple-swift-docs` - - `defaultSourceOrder` -- `format-swift-sources` - - `defaultToolSelection` -These are the knobs most likely to reflect real user preference instead of hidden implementation detail. - -### Implemented As Inference, Fixed Workflow Defaults, Or Explicit Invocation Inputs - -- The retired standalone package bootstrap previously inferred: - - `defaultPackageType` - - `defaultPlatformPreset` -- `explore-apple-swift-docs` - - `troubleshootingPreference` -- `format-swift-sources` - - `defaultSurface` - - `preferSwiftLintPlugins` - - `preferSwiftFormatHostAppExport` -- `swift-package-build-run-workflow` - - no ordinary user-facing knobs -- `swift-package-testing-workflow` - - no ordinary user-facing knobs -- The retired broad Swift package compatibility router had no ordinary - user-facing knobs. Focused build, test, and extension workflows remain. - -These are now better derived from request wording, component context, available tools, explicit CLI input, or fixed workflow defaults than held as broad durable user state. - -### Removed From Ordinary User Customization - - - `defaultUIStack` - - `validationMode` -- `explore-apple-swift-docs` - - `defaultMaxResults` - - `defaultSearchSnippets` - - `dashInstallSourcePriority` - - `requireExplicitApprovalForDashInstallYes` - - `dashGenerationPolicy` -- `format-swift-sources` - - `preferProjectRootConfigFiles` - -These are either safety policy, implementation tuning, or maintainer defaults that should not be presented as if they were equally meaningful end-user preference. - -The former `sync-swift-package-guidance` customization surface was retired with -the standalone sync skill. Product guidance now aligns through the root -workspace's bounded `just align` contract. - -### Reintroduced Runtime Tuning Where It Changes Real Behavior - -- `xcode-build-run-workflow` -- `xcode-testing-workflow` - - `mcpRetryCount` - - `fallbackCommandMappingProfile` - -These remain because they now change actual runtime behavior in the narrower Xcode execution skills without weakening the direct `.pbxproj` warning boundary. - -## Sync Skill Simplification Decision - -The two guidance-sync skills currently expose: - -- `copyAgentsTemplateWhenMissing` -- `appendSectionWhenAgentsExists` -- `validationMode` - -This should collapse into one smaller write model: - -- implemented replacement: `writeMode` -- target values: - - `sync-if-needed` - - `create-missing-only` - - `append-existing-only` - - `report-only` - -`validationMode` should stop being ordinary user-facing customization and become a maintainer or implementation detail unless a real user need appears. - -## Shared Helper Decision - -The duplicated `customization_config.py` scripts should not be consolidated into a shared runtime import. - -Reason: - -- the repository guidance says skill runtime resources should stay inside the skill directory -- installed skills should not depend on repo-root Python modules that are not shipped as part of the skill -- the current duplication is noisy, but it is operationally safe - -If the repo still wants less duplication after the surface shrinks, the approved direction is: - -- keep one canonical maintainer source template -- generate or sync local per-skill `customization_config.py` copies during maintainer work -- keep the installed skill self-contained - -## Post-Extraction Note - -The Apple plugin now treats `repository-skills/maintain-project-repo` as the shipped local contract for reusable repo-maintenance behavior. - -- `apple-dev-skills` no longer owns a second bundled toolkit source -- The Xcode workspace bootstrap owns component creation and guidance alignment for every Swift product; repository maintenance consumes that single workspace contract without classifying repositories as Xcode or SwiftPM shapes. -- SwiftFormat profile assets live in `maintain-project-repo`, while the Apple formatting policy remains in the Apple skill guidance - -## Follow-Up Plan - -### Phase 1: Surface Reduction - -Status: complete - -- shrink each customization template to the user-meaningful knobs listed above -- reclassify or remove maintainer-only and invariant knobs -- update `references/customization-flow.md` files so their `Status` labels match the reduced model - -### Phase 2: Inference Pass - -Status: complete for the current approved scope - -- teach the relevant workflow docs and runtime wrappers to infer the approved inference-first defaults -- keep escape hatches only where inference is likely to be wrong often enough to matter - -### Phase 3: Helper Plumbing Review - -Status: deferred on purpose - -- after the surface is smaller, re-evaluate whether duplicated `customization_config.py` maintenance is still painful -- if yes, add maintainer-time generation or sync while preserving local per-skill shipped copies - -### Phase 4: UI Follow-On - -Status: not started - -- only after the smaller customization model is in place should the repo build MCP App or other UI surfaces on top of it - -## Outcome - -Milestone 20 is complete once the roadmap reflects this review and the repository treats this document as the source of truth for the next implementation pass. - -Milestone 27 is complete once the live customization templates, flow docs, runtime wrappers, tests, and roadmap all match the reduced surface described here. That implementation pass is now in place, with the narrower Xcode build/run and testing skills keeping the retry-count and fallback-profile runtime knobs. The hard `.pbxproj` warning boundary belongs directly to `xcode-build-run-workflow`; the former compatibility router is retired. diff --git a/plugins/apple-dev-skills/docs/maintainers/reality-audit.md b/plugins/apple-dev-skills/docs/maintainers/reality-audit.md index 1f1a746eb..e771d47d9 100644 --- a/plugins/apple-dev-skills/docs/maintainers/reality-audit.md +++ b/plugins/apple-dev-skills/docs/maintainers/reality-audit.md @@ -22,7 +22,7 @@ Root `skills/` is the canonical workflow-authoring surface. - `.codex-plugin/plugin.json` is the Codex plugin entrypoint - `.mcp.json` registers Xcode's built-in `xcrun mcpbridge` server for external Codex sessions - the Xcode MCP server remains Xcode-owned; this repo does not bundle a separate Xcode MCP server package -3. Repository validation rules in `.github/scripts/validate_repo_docs.sh` +3. Root integration rules in `scripts/repo-maintenance/validations/50-socket.fsx` 4. Root maintainer and discoverability docs - `README.md` - `AGENTS.md` @@ -33,11 +33,10 @@ Deprecated compatibility skills that remain on disk do not count as part of the ## Audit Procedure -1. Confirm the active public skill surface from `.github/scripts/validate_repo_docs.sh` and the matching sections in `README.md`. +1. Confirm the active public skill surface from the skill directories and matching sections in `README.md`. 2. Check each active skill for the documented contract: - required headings in `SKILL.md` - `agents/openai.yaml` - - `references/customization.template.yaml` - `references/` - the skill-appropriate local snippet copy under `references/snippets/` 3. Check export-surface docs for drift: @@ -45,19 +44,19 @@ Deprecated compatibility skills that remain on disk do not count as part of the - docs do not reintroduce a nested packaged plugin tree or any other second export surface under `plugins/` - docs do not tell maintainers to use removed installer or install-validator skills - docs describe top-level `skills/` as the active export surface today, with top-level `mcps/` or `apps/` only if those directories are added later -4. From the Socket root, run `bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh` and treat failures as documentation-contract drift unless code assets prove otherwise. +4. From the Socket root, run `just repo-validate` and treat failures as documentation-contract drift unless code assets prove otherwise. - for repo-maintenance drift inside this repo, compare Apple behavior against `repository-skills/skills/maintain-project-repo/` - when intentionally syncing ideas from another repo, reconcile them into `maintain-project-repo` first, then keep this repo limited to Apple guidance and profile selection - for Xcode MCP drift, compare the plugin metadata against Apple's documented `codex mcp add xcode -- xcrun mcpbridge` setup -5. Run `uv run --group dev pytest` and treat failures as runtime drift. -6. Reconcile root docs to the tested, shipped state instead of preserving stale historical wording. +5. Run `just test` for the root integration/E2E path. +6. Reconcile root docs to the shipped state instead of preserving stale historical wording. 7. Update `ROADMAP.md` in the same change when milestone or status text is no longer truthful. ## Local Discovery Smoke Test Flow Use this flow when validating the current top-level export surface and local discovery mirrors instead of checking a nested packaged plugin tree. -1. From the Socket root, run `bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh`. +1. From the Socket root, run `just repo-validate`. 2. Run `uv run python -B -m pytest plugins/apple-dev-skills/tests -o cache_dir=.codex/.cache/pytest`. 3. Confirm `.agents/skills` still points at `../skills`. 4. Confirm root docs, skill docs, and the roadmap all describe top-level `skills/` as the active export surface and do not mention a nested packaged plugin tree or removed installer workflows. diff --git a/plugins/apple-dev-skills/shared/workflow-planner.fsx b/plugins/apple-dev-skills/shared/workflow-planner.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/shared/workflow-planner.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/SKILL.md b/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/SKILL.md index 24cfc5f0d..d423a7bf1 100644 --- a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/SKILL.md @@ -119,12 +119,6 @@ It owns extension-point routing, target and process boundaries, activation, enti - Recommend `explore-apple-swift-docs` for a current Apple documentation pass before choosing an unfamiliar extension point. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable project-structure policy after the extension plan is settled. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the shared customization-file contract. This workflow has no runtime-enforced settings because extension-point and entitlement choices must stay evidence-driven for each app. - ## References ### Workflow References @@ -132,7 +126,6 @@ Use `references/customization-flow.md`. - `references/extension-points-targets-and-lifecycle.md` - `references/entitlements-shared-containers-and-data-flow.md` - `references/privacy-validation-signing-and-distribution.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -145,5 +138,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project guidance for containing-app and extension-target work. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization-flow.md deleted file mode 100644 index e46c8d564..000000000 --- a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# App Extension Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without turning extension-point, entitlement, or privacy decisions into persistent defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` maintains the common configuration shape. -- The workflow ignores persisted settings because every extension-point and capability decision needs current Apple documentation and project evidence. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after its stable behavior and safety boundary are documented. -3. Validate the YAML before persisting it. diff --git a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/scripts/customization_config.py deleted file mode 120000 index fe515f2db..000000000 --- a/plugins/apple-dev-skills/skills/app-extension-architecture-workflow/scripts/customization_config.py +++ /dev/null @@ -1 +0,0 @@ -../../safari-extension-control-workflow/scripts/customization_config.py \ No newline at end of file diff --git a/plugins/apple-dev-skills/skills/app-intents-workflow/SKILL.md b/plugins/apple-dev-skills/skills/app-intents-workflow/SKILL.md index 9143ed804..ad6f6c0fe 100644 --- a/plugins/apple-dev-skills/skills/app-intents-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/app-intents-workflow/SKILL.md @@ -57,15 +57,10 @@ Expose the smallest useful action and content surface outside an app without tur - Recommend `xcode-testing-workflow` for deterministic testing and UI automation. - Recommend `apple-developer-provisioning-workflow` only when documented capability or identifier provisioning becomes the actual blocker. -## Customization - -Use `references/customization-flow.md`. The first version exposes no runtime-enforced knobs; it preserves the shared customization contract without allowing a configuration to bypass documentation, privacy, or validation requirements. - ## References - `references/intent-entity-and-shortcut-shapes.md` - `references/system-surfaces-and-validation.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the app needs reusable Xcode-project policy alongside App Intents integration. - [App Intents](https://developer.apple.com/documentation/appintents) documents intents, entities, shortcuts, and system-surface integration. - [Adopting App Intents to support system experiences](https://developer.apple.com/documentation/appintents/adopting-app-intents-to-support-system-experiences) documents discovery across system experiences. diff --git a/plugins/apple-dev-skills/skills/app-intents-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/app-intents-workflow/references/customization-flow.md deleted file mode 100644 index 2e9f11c41..000000000 --- a/plugins/apple-dev-skills/skills/app-intents-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# TipKit Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/app-intents-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/app-intents-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/app-intents-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/app-intents-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/app-intents-workflow/scripts/customization_config.py deleted file mode 100755 index 5bf420d20..000000000 --- a/plugins/apple-dev-skills/skills/app-intents-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "app-intents-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md index 325492cc6..0142100da 100644 --- a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/SKILL.md @@ -221,13 +221,7 @@ root `Controllers/` directory. or test diagnosis. - Recommend `apple-ui-accessibility-workflow` when the next honest step is accessibility-specific implementation or review. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide -customization-file contract, but the first version of this skill defines no -runtime-enforced knobs. +## Fixed Policy Keep the first release focused on the decision model and the documented boundary. If future iterations add a real deterministic need for runtime knobs, @@ -248,7 +242,6 @@ document them explicitly before letting runtime behavior depend on them. - `references/mixed-appkit-swiftui-composition.md` - `references/architecture-decision-rules.md` - `references/anti-patterns-and-corrections.md` -- `references/customization-flow.md` ### Support References @@ -258,5 +251,3 @@ document them explicitly before letting runtime behavior depend on them. needs reusable repo policy rather than a one-off architecture recommendation. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization-flow.md deleted file mode 100644 index b164de269..000000000 --- a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization-flow.md +++ /dev/null @@ -1,22 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending the first -version of `appkit-app-architecture-workflow` already has runtime-tunable -behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/scripts/customization_config.py deleted file mode 100755 index fa9018065..000000000 --- a/plugins/apple-dev-skills/skills/appkit-app-architecture-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "appkit-app-architecture-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/SKILL.md b/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/SKILL.md index 771fcc1a2..f75dc83e9 100644 --- a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/SKILL.md @@ -102,11 +102,9 @@ The workflow never commits a `.p8` key, CloudKit token, JWT, profile payload, or - Use the Apple Developer Portal manually for App Groups, CloudKit-container registration or App-ID assignment, Service IDs, and any capability configuration Apple does not expose through the current REST API. - Recommend `references/snippets/apple-xcode-project-core.md` when a repository needs durable Xcode-project guidance for entitlements, signing, and project-integrity follow-through. -## Customization +## Fixed Policy -Use `references/customization-flow.md`. - -This workflow intentionally has no runtime-enforced mutation override. Customization can record a preferred discovery mode and CloudKit adapter, but it cannot bypass plan-first behavior, local-secret handling, portal-only classification, or explicit per-operation confirmation. +This workflow intentionally has no runtime-enforced mutation override. The fixed policy selects the discovery mode and CloudKit adapter and cannot bypass plan-first behavior, local-secret handling, portal-only classification, or explicit per-operation confirmation. ## References @@ -115,7 +113,6 @@ This workflow intentionally has no runtime-enforced mutation override. Customiza - `references/app-store-connect-provisioning.md` - `references/cloudkit-automation.md` - `references/portal-only-configuration.md` -- `references/customization-flow.md` ### Support References @@ -126,5 +123,3 @@ This workflow intentionally has no runtime-enforced mutation override. Customiza - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project policy. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization-flow.md deleted file mode 100644 index 39d68e106..000000000 --- a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Customization Flow - -The template records non-security preferences only. `preferredDiscoveryMode` may be `xcode-local` or `rest-first`; `preferredCloudKitAdapter` may be `cktool` or `cktool-js`. - -Preferences do not authorize mutation, relax secret handling, convert a portal-only operation into API work, or suppress operation-specific confirmation. Store a durable customization file outside the repository through `scripts/customization_config.py`. Use `effective` to inspect it, `apply --input <yaml-file>` to persist a validated override, and `reset` to remove it. diff --git a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization.template.yaml deleted file mode 100644 index fe64e87c9..000000000 --- a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: diff --git a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/scripts/customization_config.py deleted file mode 100755 index f91c485c0..000000000 --- a/plugins/apple-dev-skills/skills/apple-developer-provisioning-workflow/scripts/customization_config.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist safe per-skill customization preferences.""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path - -import yaml - -SKILL_NAME = "apple-developer-provisioning-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_SETTINGS = {"preferredDiscoveryMode", "preferredCloudKitAdapter"} - - -def fail(message: str) -> None: - raise SystemExit(f"ERROR: Apple Developer Provisioning Workflow customization: {message}") - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load(path: Path, *, required: bool) -> dict: - if not path.exists(): - if required: - fail(f"missing customization template at {path}") - return {} - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"invalid YAML in {path}: {error}") - if not isinstance(value, dict): - fail(f"top-level YAML must be a mapping in {path}") - if set(value) - {"schemaVersion", "isCustomized", "settings"}: - fail(f"unknown top-level keys in {path}") - settings = value.get("settings") or {} - if not isinstance(settings, dict) or set(settings) - ALLOWED_SETTINGS: - fail(f"settings in {path} must contain only {sorted(ALLOWED_SETTINGS)}") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail(f"schemaVersion in {path} must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail(f"isCustomized in {path} must be boolean") - return value - - -def effective() -> dict: - base = load(template_path(), required=True) - overlay = load(durable_path(), required=False) - settings = dict(base.get("settings") or {}) - settings.update(overlay.get("settings") or {}) - settings.setdefault("preferredDiscoveryMode", "xcode-local") - settings.setdefault("preferredCloudKitAdapter", "cktool") - result = { - "schemaVersion": 1, - "isCustomized": bool(overlay) or bool(base.get("isCustomized", False)), - "settings": settings, - } - if result["settings"].get("preferredDiscoveryMode") not in {"xcode-local", "rest-first"}: - fail("preferredDiscoveryMode must be xcode-local or rest-first") - if result["settings"].get("preferredCloudKitAdapter") not in {"cktool", "cktool-js"}: - fail("preferredCloudKitAdapter must be cktool or cktool-js") - return result - - -def write(config: dict) -> None: - path = durable_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") - - -def apply(override_path: Path) -> None: - override = load(override_path, required=True) - config = effective() - config["settings"].update(override.get("settings") or {}) - config["isCustomized"] = True - write(config) - print(durable_path()) - - -def reset() -> None: - path = durable_path() - if path.exists(): - path.unlink() - print(path) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Inspect or persist safe workflow preferences.") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_command = commands.add_parser("apply") - apply_command.add_argument("--input", required=True, type=Path) - commands.add_parser("reset") - set_command = commands.add_parser("set") - set_command.add_argument("--discovery-mode", choices=["xcode-local", "rest-first"]) - set_command.add_argument("--cloudkit-adapter", choices=["cktool", "cktool-js"]) - args = parser.parse_args() - - if args.command == "path": - print(durable_path()) - elif args.command == "effective": - print(yaml.safe_dump(effective(), sort_keys=False), end="") - elif args.command == "apply": - apply(args.input) - elif args.command == "reset": - reset() - else: - config = effective() - if args.discovery_mode: - config["settings"]["preferredDiscoveryMode"] = args.discovery_mode - if args.cloudkit_adapter: - config["settings"]["preferredCloudKitAdapter"] = args.cloudkit_adapter - config["isCustomized"] = True - write(config) - print(yaml.safe_dump(config, sort_keys=False), end="") - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/SKILL.md b/plugins/apple-dev-skills/skills/apple-image-representation-workflow/SKILL.md index f66ab810a..18881c818 100644 --- a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/apple-image-representation-workflow/SKILL.md @@ -82,18 +82,11 @@ Guide image source, destination, representation, and conversion work without fla - Recommend `xcode-testing-workflow` for image fixtures, metadata round trips, comparison tests, or performance baselines. - Recommend `explore-apple-swift-docs` when documentation lookup is the real need. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/image-io-decoding-encoding-and-metadata.md` - `references/apple-image-representations-and-bridging.md` -- `references/customization-flow.md` - `../../shared/references/apple-image-type-ownership.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization-flow.md deleted file mode 100644 index 5c151bda8..000000000 --- a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Apple Image Representation Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/apple-image-representation-workflow/scripts/customization_config.py deleted file mode 100755 index 0fccd9a97..000000000 --- a/plugins/apple-dev-skills/skills/apple-image-representation-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist image representation workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "apple-image-representation-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/apple-typography-workflow/SKILL.md b/plugins/apple-dev-skills/skills/apple-typography-workflow/SKILL.md index fb4d33de7..76dab5224 100644 --- a/plugins/apple-dev-skills/skills/apple-typography-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/apple-typography-workflow/SKILL.md @@ -110,11 +110,7 @@ It is not the generic visual-design workflow, not the SF Symbols workflow, and n - Recommend `explore-apple-swift-docs` when the user primarily needs raw Apple documentation lookup. - Recommend `references/snippets/apple-xcode-project-core.md` when repo policy or Xcode project-integrity guidance is needed before applying font resources. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on typography classification, system-font choices, Dynamic Type behavior, custom-font boundaries, and validation handoffs. If future iterations add deterministic font-bundle checks, document those helpers before relying on them. @@ -124,7 +120,6 @@ Keep the first release focused on typography classification, system-font choices - `references/system-typography-and-dynamic-type.md` - `references/custom-fonts-and-licensing.md` -- `references/customization-flow.md` ### Support References @@ -133,5 +128,3 @@ Keep the first release focused on typography classification, system-font choices - Apple documentation anchors to verify include Human Interface Guidelines Typography, SwiftUI Font, SwiftUI Applying custom fonts to text, UIKit Scaling fonts automatically, `UIFontDescriptor.SystemDesign`, `UIFontMetrics`, `UIFont.preferredFont(forTextStyle:)`, AppKit `NSFont`, `UIAppFonts`, and `ATSApplicationFontsPath`. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization-flow.md deleted file mode 100644 index 878ee6ff2..000000000 --- a/plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# SwiftUI App Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `swiftui-app-architecture-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first boundary and decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `swiftui-app-architecture-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/apple-typography-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/apple-typography-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/apple-typography-workflow/scripts/customization_config.py deleted file mode 100755 index 03f219a78..000000000 --- a/plugins/apple-dev-skills/skills/apple-typography-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-app-architecture-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/SKILL.md b/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/SKILL.md index 165edbfb0..8230c218b 100644 --- a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/SKILL.md @@ -102,11 +102,7 @@ It is not the Apple-docs router, not the SwiftUI app-structure workflow, and not - Recommend `swiftui-app-architecture-workflow` when the next honest step is app, scene, command, focus, environment, or dependency-flow architecture rather than accessibility semantics. - Recommend `arkit-spatial-sensing-workflow` or `arkit-face-body-tracking-workflow` for ARKit sensing while keeping labels, alternatives, motion, audio/haptic cues, and nonvisual interaction with this accessibility workflow. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on the decision model, the documented accessibility boundary, and explicit verification expectations. If future iterations add a real deterministic need for runtime knobs, document them explicitly before letting runtime behavior depend on them. @@ -120,7 +116,6 @@ Keep the first release focused on the decision model, the documented accessibili - `references/worked-swiftui-accessibility-examples.md` - `references/verification-expectations.md` - `references/common-accessibility-anti-patterns.md` -- `references/customization-flow.md` ### Support References @@ -128,5 +123,3 @@ Keep the first release focused on the decision model, the documented accessibili - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Apple-project baseline policy rather than a one-off accessibility recommendation. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization-flow.md deleted file mode 100644 index 94746cefc..000000000 --- a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Apple UI Accessibility Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `apple-ui-accessibility-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first boundary, Apple-specific accessibility semantics, and verification expectations before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `apple-ui-accessibility-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/scripts/customization_config.py deleted file mode 100644 index f96cb268d..000000000 --- a/plugins/apple-dev-skills/skills/apple-ui-accessibility-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "apple-ui-accessibility-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/SKILL.md b/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/SKILL.md index 93b7997ea..78f49a90c 100644 --- a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/SKILL.md @@ -75,18 +75,11 @@ Guide ARKit face and body tracking while keeping TrueDepth geometry, body skelet - Recommend `xcode-testing-workflow` for transform, coefficient, skeleton, session-generation, and device test plans. - Recommend `explore-apple-swift-docs` for current ARKit or Local Authentication research. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/face-geometry-blend-shapes-and-authentication-boundary.md` - `references/body-skeleton-scale-rendering-and-diagnostics.md` -- `references/customization-flow.md` - `../../shared/references/apple-spatial-data-privacy-contract.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization-flow.md deleted file mode 100644 index 816c0bae2..000000000 --- a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# ARKit Face and Body Tracking Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/scripts/customization_config.py deleted file mode 100755 index 9b070d394..000000000 --- a/plugins/apple-dev-skills/skills/arkit-face-body-tracking-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist ARKit face and body tracking workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "arkit-face-body-tracking-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/SKILL.md b/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/SKILL.md index 08f4063d0..4440c8e56 100644 --- a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/SKILL.md @@ -78,18 +78,11 @@ Guide ARKit environment sensing while preserving the platform-specific session m - Recommend `xcode-testing-workflow` for transform fixtures, provider state tests, saved-map fixtures, and device test plans. - Recommend `explore-apple-swift-docs` for current ARKit or related framework research. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/world-tracking-depth-meshes-and-maps.md` - `references/visionos-providers-rendering-and-diagnostics.md` -- `references/customization-flow.md` - `../../shared/references/apple-spatial-data-privacy-contract.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization-flow.md deleted file mode 100644 index 958ea9831..000000000 --- a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# ARKit Spatial Sensing Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/scripts/customization_config.py deleted file mode 100755 index bfecce658..000000000 --- a/plugins/apple-dev-skills/skills/arkit-spatial-sensing-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist ARKit spatial sensing workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "arkit-spatial-sensing-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/author-swift-docc-docs/SKILL.md b/plugins/apple-dev-skills/skills/author-swift-docc-docs/SKILL.md index df9805aa6..31d43c74b 100644 --- a/plugins/apple-dev-skills/skills/author-swift-docc-docs/SKILL.md +++ b/plugins/apple-dev-skills/skills/author-swift-docc-docs/SKILL.md @@ -7,7 +7,7 @@ description: Author and review DocC content in Swift components, including symbo ## Purpose -Provide the canonical DocC authoring-and-review workflow for Swift components. `scripts/run_workflow.py` classifies the task and, only when execution is needed, selects SwiftPM or Xcode according to the requested generation operation. +Provide the canonical DocC authoring-and-review workflow for Swift components. `scripts/run-workflow.fsx` classifies the task and, only when execution is needed, selects SwiftPM or Xcode according to the requested generation operation. ## When To Use @@ -29,7 +29,7 @@ Provide the canonical DocC authoring-and-review workflow for Swift components. ` - `structure` - `review` - `tutorial-aware-review` -2. Run `scripts/run_workflow.py` so task inference, tutorial-depth boundaries, and handoff rules resolve into one JSON contract. +2. Run `scripts/run-workflow.fsx` so task inference, tutorial-depth boundaries, and handoff rules resolve into one JSON contract. 4. If the request is actually broad Apple-docs lookup, hand off to `explore-apple-swift-docs`. 4. If the request is DocC generation, export, hosting, archive, or project-integrity follow-through, select the execution surface required by that operation and hand off to its owner. 6. Otherwise stay local to DocC authoring and review: @@ -46,11 +46,6 @@ Provide the canonical DocC authoring-and-review workflow for Swift components. ` - `task_type`: optional explicit override; use `symbol-docs`, `article`, `structure`, `review`, or `tutorial-aware-review` - `request`: optional free-text task description used for inference and handoff decisions - `needs_generation`: optional explicit flag for generation, export, archive, hosting, or other execution-heavy DocC follow-through -- Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` - - tutorial handling defaults to the configured first-pass policy in `references/customization-flow.md` - - execution-surface inference is used only for generation and prefers the requested operation, then the nearest package or workspace context - - task inference prefers an explicit override, then request wording ## Outputs @@ -86,13 +81,11 @@ Provide the canonical DocC authoring-and-review workflow for Swift components. ` - Hand off to `explore-apple-swift-docs` when the request is primarily about finding DocC or Apple documentation rather than writing or reviewing DocC content. - Hand off to `swift-package-build-run-workflow` when the requested generation operation is SwiftPM-owned. - Hand off to `xcode-build-run-workflow` when the requested generation operation needs `docbuild`, a scheme, export, archive, or project-integrity follow-through. -- `scripts/run_workflow.py` is the top-level runtime entrypoint and converts repo inspection plus request inference into the documented JSON contract. +- `scripts/run-workflow.fsx` is the top-level runtime entrypoint and converts repo inspection plus request inference into the documented JSON contract. -## Customization +## Fixed Policy -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` loads the runtime-safe tutorial-depth setting before shaping the final workflow contract. +- `scripts/run-workflow.fsx` uses the managed tutorial-depth policy. ## References @@ -106,7 +99,6 @@ Provide the canonical DocC authoring-and-review workflow for Swift components. ` ### Contract References - `references/automation-prompts.md` -- `references/customization-flow.md` ### Support References @@ -114,5 +106,4 @@ Provide the canonical DocC authoring-and-review workflow for Swift components. ` ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` diff --git a/plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization-flow.md b/plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization-flow.md deleted file mode 100644 index 135935d17..000000000 --- a/plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization-flow.md +++ /dev/null @@ -1,33 +0,0 @@ -# DocC Workflow Customization Contract - -## Purpose - -Tune the documented tutorial-handling posture for the DocC authoring-and-review workflow without broadening the first-release skill into a full tutorial-authoring specialist. - -## Knobs - -| Knob | Default | Status | Effect | -| --- | --- | --- | --- | -| `tutorialSupportLevel` | `light-review` | `runtime-enforced` | Controls whether tutorial-shaped requests get a first-pass conceptual review or an immediate defer-to-references recommendation. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the effective merged customization state at runtime. -- `tutorialSupportLevel=light-review` keeps tutorial requests inside the workflow long enough for a high-level conceptual review before deeper directive-specific work is handed off to fuller DocC references. -- `tutorialSupportLevel=defer` recognizes tutorial-shaped requests and recommends the fuller DocC references immediately. -- The setting does not change execution-surface handoff rules or the phase-one authoring-and-review boundary. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected references so they still describe the same tutorial-aware but tutorial-light boundary. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify `scripts/run_workflow.py --request "review this DocC tutorial draft" --repo-root . --dry-run` reflects the configured tutorial handling. - -## Validation - -1. Verify tutorial-shaped requests still stay within the first-release scope described in `SKILL.md`. -2. Verify build, export, and generation requests still hand off to the existing execution workflows instead of becoming local runtime behavior. -3. Verify `scripts/run_workflow.py` reflects the runtime-enforced knob above. diff --git a/plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization.template.yaml b/plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization.template.yaml deleted file mode 100644 index 405fb68a4..000000000 --- a/plugins/apple-dev-skills/skills/author-swift-docc-docs/references/customization.template.yaml +++ /dev/null @@ -1,4 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - tutorialSupportLevel: "light-review" diff --git a/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/customization_config.py b/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/customization_config.py deleted file mode 100755 index 95a7f8c27..000000000 --- a/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "author-swift-docc-docs" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run-workflow.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run-workflow.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run_workflow.py deleted file mode 100755 index dfb3dfff3..000000000 --- a/plugins/apple-dev-skills/skills/author-swift-docc-docs/scripts/run_workflow.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Runtime workflow policy engine for author-swift-docc-docs.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import customization_config - - -VALID_EXECUTION_SURFACES = {"swiftpm", "xcode"} -VALID_TASK_TYPES = { - "symbol-docs", - "article", - "structure", - "review", - "tutorial-aware-review", -} -VALID_TUTORIAL_LEVELS = {"light-review", "defer"} - - -def normalize_request_text(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def request_implies_docs_lookup(text: str) -> bool: - needles = ( - "search docs", - "look up docs", - "find docs", - "wwdc", - "apple docs", - "swift.org docs", - "directive reference", - "which directive", - "documentationsearch", - ) - return any(needle in text for needle in needles) - - -def request_implies_execution(text: str) -> bool: - needles = ( - "docbuild", - "xcodebuild", - "build documentation", - "generate archive", - "doccarchive", - "export docs", - "publish docs", - "host docs", - "archive export", - ) - return any(needle in text for needle in needles) - - -def infer_task_type_from_request(request: str | None) -> str | None: - text = normalize_request_text(request) - if not text: - return None - - if "tutorial" in text or "guided learning" in text or "walkthrough" in text: - return "tutorial-aware-review" - if "topic group" in text or "landing page" in text or "extension file" in text or "catalog structure" in text: - return "structure" - if "article" in text or "overview page" in text or "conceptual page" in text: - return "article" - if "symbol" in text or "doc comment" in text or "parameter docs" in text or "return docs" in text or "inline comments" in text: - return "symbol-docs" - if "review" in text or "accuracy" in text or "correctness" in text or "clarity" in text: - return "review" - return None - - -def detect_repo_state(repo_path: str | None) -> dict: - if not repo_path: - return { - "requested_root": None, - "resolved_root": None, - "package_manifest": None, - "workspace": None, - "project": None, - "docc_catalogs": [], - } - - requested = Path(repo_path).expanduser().resolve() - existing = requested - while not existing.exists() and existing != existing.parent: - existing = existing.parent - if not existing.exists(): - return { - "requested_root": str(requested), - "resolved_root": None, - "package_manifest": None, - "workspace": None, - "project": None, - "docc_catalogs": [], - } - - candidate = existing if existing.is_dir() else existing.parent - package_manifest = candidate / "Package.swift" - workspaces = sorted(candidate.rglob("*.xcworkspace"), key=str) - projects = sorted(candidate.rglob("*.xcodeproj"), key=str) - docc_catalogs = sorted(str(path) for path in candidate.rglob("*.docc")) - return { - "requested_root": str(requested), - "resolved_root": str(candidate), - "package_manifest": str(package_manifest) if package_manifest.exists() else None, - "workspace": str(workspaces[0]) if workspaces else None, - "project": str(projects[0]) if projects else None, - "docc_catalogs": docc_catalogs, - } - - -def infer_execution_surface(repo_state: dict, request: str | None) -> str | None: - text = normalize_request_text(request) - if any(token in text for token in ("xcodebuild", "workspace", "scheme", "doccarchive", "archive export")): - return "xcode" - if any(token in text for token in ("swift package", "swiftpm", "docc convert", "package.swift")): - return "swiftpm" - if repo_state.get("package_manifest"): - return "swiftpm" - if repo_state.get("workspace") or repo_state.get("project"): - return "xcode" - return None - - -def load_effective_config() -> dict: - return customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - -def recommended_execution_skill(execution_surface: str | None) -> str | None: - if execution_surface == "swiftpm": - return "swift-package-build-run-workflow" - if execution_surface == "xcode": - return "xcode-build-run-workflow" - return None - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-path") - parser.add_argument("--execution-surface", choices=sorted(VALID_EXECUTION_SURFACES)) - parser.add_argument("--task-type", choices=sorted(VALID_TASK_TYPES)) - parser.add_argument("--request") - parser.add_argument("--needs-generation", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - settings = load_effective_config().get("settings", {}) - tutorial_support_level = str(settings.get("tutorialSupportLevel", "light-review")) - if tutorial_support_level not in VALID_TUTORIAL_LEVELS: - tutorial_support_level = "light-review" - - repo_state = detect_repo_state(args.repo_path) - execution_surface = args.execution_surface or infer_execution_surface(repo_state, args.request) - execution_surface_source = "explicit" if args.execution_surface else ("inferred" if execution_surface else "missing") - task_type = args.task_type or infer_task_type_from_request(args.request) - task_type_source = "explicit" if args.task_type else ("inferred" if task_type else "missing") - text = normalize_request_text(args.request) - - if request_implies_docs_lookup(text): - payload = { - "status": "handoff", - "path_type": "primary", - "output": { - "execution_surface": execution_surface, - "execution_surface_source": execution_surface_source, - "task_type": task_type, - "task_type_source": task_type_source, - "repo_state": repo_state, - "tutorial_support_level": tutorial_support_level, - "correctness_model": ["content", "docc", "project"], - "recommended_skill": "explore-apple-swift-docs", - "next_step": "Use explore-apple-swift-docs because this request is primarily about finding DocC or Apple documentation rather than authoring or reviewing DocC content.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - if args.needs_generation or request_implies_execution(text): - recommended = recommended_execution_skill(execution_surface) - if not recommended: - payload = { - "status": "blocked", - "path_type": "fallback", - "output": { - "execution_surface": execution_surface, - "execution_surface_source": execution_surface_source, - "task_type": task_type, - "task_type_source": task_type_source, - "repo_state": repo_state, - "tutorial_support_level": tutorial_support_level, - "correctness_model": ["content", "docc", "project"], - "recommended_skill": None, - "next_step": "Provide --execution-surface or request a specific SwiftPM or Xcode DocC generation operation.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - payload = { - "status": "handoff", - "path_type": "primary" if execution_surface_source != "missing" else "fallback", - "output": { - "execution_surface": execution_surface, - "execution_surface_source": execution_surface_source, - "task_type": task_type, - "task_type_source": task_type_source, - "repo_state": repo_state, - "tutorial_support_level": tutorial_support_level, - "correctness_model": ["content", "docc", "project"], - "recommended_skill": recommended, - "next_step": f"Use {recommended} because the next step is DocC generation, export, archive, hosting, or other execution-heavy follow-through.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - if task_type is None: - payload = { - "status": "blocked", - "path_type": "fallback" if execution_surface_source != "missing" else "primary", - "output": { - "execution_surface": execution_surface, - "execution_surface_source": execution_surface_source, - "task_type": None, - "task_type_source": "missing", - "repo_state": repo_state, - "tutorial_support_level": tutorial_support_level, - "correctness_model": ["content", "docc", "project"], - "recommended_skill": None, - "next_step": "Pass --task-type explicitly or describe whether you need symbol docs, article work, structure work, review, or tutorial-aware review.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - next_step = "Stay in author-swift-docc-docs and revise or review the DocC content locally." - if task_type == "tutorial-aware-review" and tutorial_support_level == "defer": - next_step = "Keep the tutorial request recognized, but use the fuller DocC references before making directive-specific claims." - elif task_type == "tutorial-aware-review": - next_step = "Stay in author-swift-docc-docs for a light first-pass tutorial review focused on conceptual flow, and use the fuller DocC references before making directive-specific claims." - - payload = { - "status": "success", - "path_type": "primary", - "output": { - "execution_surface": execution_surface, - "execution_surface_source": execution_surface_source, - "task_type": task_type, - "task_type_source": task_type_source, - "repo_state": repo_state, - "tutorial_support_level": tutorial_support_level, - "correctness_model": ["content", "docc", "project"], - "recommended_skill": None, - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/SKILL.md b/plugins/apple-dev-skills/skills/avaudio-engine-workflow/SKILL.md index 1174dccea..cd98ef1c5 100644 --- a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/avaudio-engine-workflow/SKILL.md @@ -102,19 +102,12 @@ It is not the app audio-session workflow, not the AVFoundation capture or asset - Recommend `xcode-testing-workflow` for test design, repeatable engine checks, or regression coverage. - Recommend `explore-apple-swift-docs` when more docs lookup is the next honest step. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but this workflow defines no runtime-enforced knobs. - ## References ### Workflow References - `references/engine-graph-and-repair.md` - `references/realtime-rendering-safety.md` -- `references/customization-flow.md` ### Support References @@ -122,5 +115,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project baseline policy for apps that host audio engines. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/avaudio-engine-workflow/scripts/customization_config.py deleted file mode 100755 index 3b22f4e7c..000000000 --- a/plugins/apple-dev-skills/skills/avaudio-engine-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "avaudio-engine-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/SKILL.md b/plugins/apple-dev-skills/skills/avfaudio-session-workflow/SKILL.md index c47e4d42c..8ef65843e 100644 --- a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/avfaudio-session-workflow/SKILL.md @@ -104,19 +104,12 @@ It is not the audio-engine graph workflow, not the AVFoundation media-pipeline w - Recommend `xcode-build-run-workflow` for entitlements, `Info.plist`, target membership, build, run, or device setup. - Recommend `xcode-testing-workflow` for repeatable test plans, interruption test design, or runtime verification. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but this workflow defines no runtime-enforced knobs. - ## References ### Workflow References - `references/session-policy-and-repair.md` - `references/validation-and-handoffs.md` -- `references/customization-flow.md` ### Support References @@ -124,5 +117,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project baseline policy for audio-session apps. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/avfaudio-session-workflow/scripts/customization_config.py deleted file mode 100755 index c38ef5868..000000000 --- a/plugins/apple-dev-skills/skills/avfaudio-session-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "avfaudio-session-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/SKILL.md b/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/SKILL.md index 13979d362..d5619aa30 100644 --- a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/SKILL.md @@ -102,19 +102,12 @@ Guide AVFoundation capture, playback, asset loading, reader, writer, export, and - Recommend `xcode-testing-workflow` for repeatable media tests, fixtures, or runtime verification planning. - Recommend `explore-apple-swift-docs` when docs lookup is the real need. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but this workflow defines no runtime-enforced knobs. - ## References ### Workflow References - `references/media-pipeline-and-repair.md` - `references/async-loading-and-backpressure.md` -- `references/customization-flow.md` ### Support References @@ -122,5 +115,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project baseline policy for media apps. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/scripts/customization_config.py deleted file mode 100755 index 298397fc0..000000000 --- a/plugins/apple-dev-skills/skills/avfoundation-media-pipeline-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "avfoundation-media-pipeline-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/SKILL.md b/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/SKILL.md index a4316d7ff..ffc253855 100644 --- a/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/SKILL.md +++ b/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/SKILL.md @@ -13,7 +13,7 @@ materialized as the generated root `.xcodeproj`. `Apps/` contains platform-speci SwiftPM executables. This is the entrypoint for app-first, service-first, and combined products. -Run `scripts/run_workflow.py` before creating files. It generates the root +Run `scripts/run-workflow.fsx` before creating files. It generates the root XcodeGen project, creates the workspace wrapper, initializes the first local Swift package with SwiftPM, and installs the `xcode-workspace` maintenance profile through `repository-skills`. @@ -79,7 +79,7 @@ project migration entrypoint. ## Single-Path Workflow 1. Apply the Apple documentation gate through `explore-apple-swift-docs`. -2. Run `scripts/run_workflow.py --name <Name> --file-prefix <ABC>`. +2. Run `scripts/run-workflow.fsx --name <Name> --file-prefix <ABC>`. The default creates iOS and macOS targets, their Swift Testing and XCUITest bundles, plus `<Name>Core`. Start package-first with `--component-kind library` or service-first with @@ -141,7 +141,7 @@ project migration entrypoint. ## Guards and Stop Conditions - For an existing canonical workspace, run - `scripts/run_workflow.py --operation align --repo-root <root>` instead of + `scripts/run-workflow.fsx --operation align --repo-root <root>` instead of using a separate sync skill. It preserves local documentation and Justfile content outside Socket-managed markers. - Stop when a create destination product root is non-empty. @@ -171,7 +171,7 @@ project migration entrypoint. - When Xcode-only state is required, use the root workspace and Xcode workflows; otherwise run the nearest package operation directly. -## Customization +## Fixed Policy This skill intentionally has no independent customization template. Its explicit CLI inputs define product identity and component creation, while the generated diff --git a/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx new file mode 100644 index 000000000..33b7b0956 --- /dev/null +++ b/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx @@ -0,0 +1,72 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let has flag = args |> Array.contains flag +let value flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let operation = value "--operation" |> Option.defaultValue "create" +let name = value "--name" |> Option.orElseWith (fun () -> value "--component-name") +let repo = + match value "--repo-root", name with + | Some path, _ -> Path.GetFullPath path + | None, Some product -> Path.GetFullPath(Path.Combine(value "--destination" |> Option.defaultValue ".", product)) + | _ -> Path.GetFullPath "." +let dryRun = has "--dry-run" +let blocked message = + printfn "%s" (JsonSerializer.Serialize({| status = "blocked"; operation = operation; workspace_root = repo; error = message |}, JsonSerializerOptions(WriteIndented = true))) + exit 2 +let write (relative: string) (content: string) = + let path = Path.Combine(repo, relative) + Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore + File.WriteAllText(path, content.Replace("\r\n", "\n")) +let copyManaged (asset: string) (relative: string) = + let source = Path.Combine(__SOURCE_DIRECTORY__, "..", "assets", "managed-guidance", asset) + let target = Path.Combine(repo, relative) + Directory.CreateDirectory(Path.GetDirectoryName target) |> ignore + File.Copy(source, target, true) +let ensureCanonicalRoot () = + [ "Apps"; "Packages"; "Services"; "Configurations"; "docs"; "Scripts"; ".github/workflows" ] |> List.iter (fun path -> Directory.CreateDirectory(Path.Combine(repo, path)) |> ignore) + let product = name |> Option.defaultValue (DirectoryInfo(repo).Name) + write "project.yml" $"name: {product}\noptions:\n bundleIdPrefix: com.galewilliams\nconfigs:\n Debug: debug\n Staging: release\n Release: release\n AppStore: release\n DirectDistribution: release\n AltStore: release\ninclude:\n - path: Apps/apps-shared.yml\n - path: Packages/packages-shared.yml\n - path: Services/services-shared.yml\n" + write "Apps/apps-shared.yml" "targets: {}\n" + write "Packages/packages-shared.yml" "packages: {}\n" + write "Services/services-shared.yml" "packages: {}\n" + write $"{product}.xcworkspace/contents.xcworkspacedata" $"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace version=\"1.0\"><FileRef location=\"group:{product}.xcodeproj\"></FileRef></Workspace>\n" + copyManaged "AGENTS-root.md" "AGENTS.md" + copyManaged "AGENTS-apps.md" "Apps/AGENTS.md" + copyManaged "AGENTS-packages.md" "Packages/AGENTS.md" + copyManaged "AGENTS-services.md" "Services/AGENTS.md" + copyManaged "CONTRIBUTING.md" "CONTRIBUTING.md" + copyManaged "pre-commit" ".git/hooks/pre-commit" + write "justfile" "setup:\n xcodegen generate\n\nalign:\n dotnet fsi .socket/repo-maintenance/repo-maintenance.fsx sync\n xcodegen generate\n" +let addComponent () = + let componentName = value "--component-name" |> Option.defaultWith (fun () -> blocked "--component-name is required for add-component.") + let kind = value "--component-kind" |> Option.defaultWith (fun () -> blocked "--component-kind is required for add-component.") + match kind with + | "library" | "service" -> + let rootName = if kind = "library" then "Packages" else "Services" + let target = Path.Combine(rootName, componentName) + Directory.CreateDirectory(Path.Combine(repo, target, "Sources", componentName)) |> ignore + write (Path.Combine(target, "Package.swift")) $"// swift-tools-version: 6.2\nimport PackageDescription\nlet package = Package(name: \"{componentName}\", platforms: [.macOS(.v15)], products: [.library(name: \"{componentName}\", targets: [\"{componentName}\"])], targets: [.target(name: \"{componentName}\")])\n" + | "app" | "extension" -> + let target = Path.Combine("Apps", componentName) + Directory.CreateDirectory(Path.Combine(repo, target, "Sources")) |> ignore + let platform = value "--platform" |> Option.defaultValue "iOS" + write (Path.Combine(target, "target.yml")) $"targets:\n {componentName}:\n type: application\n platform: {platform}\n sources: [Sources]\n" + | other -> blocked $"Unsupported component kind: {other}" + +if operation = "create" && name.IsNone then blocked "--name is required for create." +if operation <> "create" && not (Directory.Exists repo) then blocked $"Repository does not exist: {repo}" +if operation = "create" && Directory.Exists repo && Directory.EnumerateFileSystemEntries(repo) |> Seq.isEmpty |> not then blocked $"Create destination is not empty: {repo}" +if operation = "adopt" && not (has "--apply") then + let components = Directory.GetFiles(repo, "Package.swift", SearchOption.AllDirectories) |> Array.map (fun path -> Path.GetRelativePath(repo, Path.GetDirectoryName path)) |> Array.sort + printfn "%s" (JsonSerializer.Serialize({| status = "success"; operation = operation; workspace_root = repo; components = components; migration_required = true; next_step = "Review the inventory, then rerun with --apply and an approved adoption map." |}, JsonSerializerOptions(WriteIndented = true))) +elif dryRun then + printfn "%s" (JsonSerializer.Serialize({| status = "success"; operation = operation; workspace_root = repo; dry_run = true; policy = "fixed-gale-workspace" |}, JsonSerializerOptions(WriteIndented = true))) +else + if operation = "create" || operation = "align" || operation = "adopt" then ensureCanonicalRoot () + if operation = "add-component" then addComponent () + printfn "%s" (JsonSerializer.Serialize({| status = "success"; operation = operation; workspace_root = repo; policy = "fixed-gale-workspace"; next_step = "Run just setup, then use just align for managed refreshes." |}, JsonSerializerOptions(WriteIndented = true))) diff --git a/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run_workflow.py deleted file mode 100755 index 959f2f4c6..000000000 --- a/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/scripts/run_workflow.py +++ /dev/null @@ -1,1200 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Create one Apple product workspace with a root XcodeGen project.""" - -from __future__ import annotations - -import argparse -import json -import re -import shutil -import subprocess -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Any - -SUPPORTED_PLATFORMS = { - "ios": "iOS", - "macos": "macOS", - "tvos": "tvOS", - "watchos": "watchOS", - "visionos": "visionOS", -} -CONFIGURATIONS = ("Debug", "Staging", "Release", "AppStore", "DirectDistribution", "AltStore") -XCODE_PRODUCT_TYPES = { - "com.apple.product-type.application": "app", - "com.apple.product-type.app-extension": "extension", - "com.apple.product-type.extensionkit-extension": "extension", - "com.apple.product-type.bundle.unit-test": "test", - "com.apple.product-type.bundle.ui-testing": "ui-test", -} - - -@dataclass -class Component: - """One concrete repository component; never a whole-repository classification.""" - - name: str - kind: str - current_owner: str - proposed_destination: str - evidence: list[str] = field(default_factory=list) - dependencies: list[str] = field(default_factory=list) - host_target: str | None = None - platform: str | None = None - product_type: str | None = None - extension_point_identifier: str | None = None - owned_paths: list[str] = field(default_factory=list) - unresolved: list[str] = field(default_factory=list) - - -def write(path: Path, content: str, executable: bool = False) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - if executable: - path.chmod(0o755) - - -def version_sort_key(path: Path) -> tuple[int, ...]: - return tuple(int(part) if part.isdigit() else -1 for part in path.name.split(".")) - - -def maintain_project_repo_runner() -> Path: - candidates: list[Path] = [] - seen: set[Path] = set() - for root in Path(__file__).resolve().parents: - paths = [root / "repository-skills" / "skills" / "maintain-project-repo" / "scripts" / "run_workflow.py"] - version_root = root / "repository-skills" - if version_root.is_dir(): - paths.extend( - version / "skills" / "maintain-project-repo" / "scripts" / "run_workflow.py" - for version in sorted(version_root.iterdir(), key=version_sort_key, reverse=True) - ) - for path in paths: - resolved = path.resolve() - if resolved not in seen: - candidates.append(resolved) - seen.add(resolved) - for candidate in candidates: - if candidate.is_file(): - return candidate - searched = "\n".join(f"- {candidate}" for candidate in candidates) - raise RuntimeError( - "bootstrap-xcode-workspace needs repository-skills/maintain-project-repo to install " - f"workspace maintenance files. Searched:\n{searched}" - ) - - -def server_component_runner() -> Path: - candidates: list[Path] = [] - for parent in Path(__file__).resolve().parents: - plugin_root = parent / "server-side-swift" - candidates.append(plugin_root / "skills" / "workspace-service-component" / "scripts" / "run_workflow.py") - if plugin_root.is_dir(): - candidates.extend( - version / "skills" / "workspace-service-component" / "scripts" / "run_workflow.py" - for version in sorted(plugin_root.iterdir(), key=version_sort_key, reverse=True) - if version.is_dir() - ) - for candidate in candidates: - if candidate.is_file(): - return candidate - searched = "\n".join(f"- {candidate}" for candidate in candidates) - raise RuntimeError(f"Adding a service requires the server-side-swift workspace-service-component adapter from the Socket marketplace. Searched:\n{searched}") - - -def parser() -> argparse.ArgumentParser: - result = argparse.ArgumentParser(description=__doc__) - result.add_argument("--name") - result.add_argument("--file-prefix", default="APP") - result.add_argument("--destination", default=".") - result.add_argument("--platforms", default="ios,macos") - result.add_argument("--org-identifier", default="com.galewilliams") - result.add_argument("--development-team", default="BC73766F69") - result.add_argument("--dry-run", action="store_true") - result.add_argument("--skip-validation", action="store_true") - result.add_argument("--repo-root", help="Adopt, add to, or align an existing Swift repository.") - result.add_argument("--operation", choices=("create", "adopt", "add-component", "align"), default="create") - result.add_argument("--component-kind", choices=("app", "extension", "library", "service")) - result.add_argument("--component-name") - result.add_argument("--platform", choices=tuple(SUPPORTED_PLATFORMS)) - result.add_argument("--framework", choices=("hummingbird", "vapor")) - result.add_argument("--host-target", help="Containing application target for an extension component.") - result.add_argument("--extension-product-type", choices=("app-extension", "extensionkit-extension")) - result.add_argument("--extension-point-identifier", help="Documented NSExtensionPointIdentifier for an extension component.") - result.add_argument("--adoption-map", help="Reviewed adoption-map JSON to apply after the read-only adopt inventory.") - result.add_argument("--apply", action="store_true", help="Apply --adoption-map. Adopt is read-only without this flag.") - return result - - -def blocked(message: str, inputs: dict[str, object]) -> int: - print(json.dumps({"status": "blocked", "path_type": "primary", "normalized_inputs": inputs, "stderr": message}, indent=2, sort_keys=True)) - return 1 - - -MANAGED_BEGIN = "<!-- socket-managed:begin" -MANAGED_END = "<!-- socket-managed:end" -JUST_BEGIN = "# socket-managed:begin just-recipes" -JUST_END = "# socket-managed:end just-recipes" - - -def marker_state(content: str, begin: str = MANAGED_BEGIN, end: str = MANAGED_END) -> str: - begins, ends = content.count(begin), content.count(end) - if begins == 0 and ends == 0: - return "absent" - if begins == 1 and ends == 1 and content.index(begin) < content.index(end): - return "valid" - return "invalid" - - -def workspace_findings(root: Path, allow_missing_services: bool = False) -> list[str]: - findings: list[str] = [] - if len(list(root.glob("*.xcworkspace"))) != 1: - findings.append("Expected exactly one root .xcworkspace.") - if len(list(root.glob("*.xcodeproj"))) != 1: - findings.append("Expected exactly one generated root .xcodeproj.") - required = ["project.yml", "Apps/apps-shared.yml", "Apps/Apps-shared.xcconfig", "Packages/packages-shared.yml"] - if not allow_missing_services: - required.append("Services/services-shared.yml") - findings.extend(f"Expected {path}." for path in required if not (root / path).is_file()) - components = list((root / "Apps").glob("**/target.y*ml")) + list((root / "Packages").glob("**/Package.swift")) + list((root / "Services").glob("**/Package.swift")) - if not components: - findings.append("Expected at least one component under Apps/, Packages/, or Services/.") - return findings - - -def managed_recipe_block() -> str: - return "\n".join(( - "# socket-managed:begin just-recipes", - "# Socket owns this bounded setup/alignment contract. Add project recipes outside it.", - "setup:", " sh .socket/managed/setup.sh", "align:", " sh .socket/managed/align.sh", - "# socket-managed:end just-recipes", "", - )) - - -def setup_script() -> str: - return "\n".join(( - "#!/usr/bin/env sh", "set -eu", - 'for tool in git just swift xcodegen xcodebuild; do command -v "$tool" >/dev/null 2>&1 || { echo "Missing required tool: $tool" >&2; exit 1; }; done', - "git config core.hooksPath .githooks", "", - )) - - -def align_script() -> str: - return "\n".join(( - "#!/usr/bin/env sh", "set -eu", - "base=${SOCKET_TEMPLATE_BASE_URL:-https://raw.githubusercontent.com/gaelic-ghost/socket/main/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/assets/managed-guidance}", - "tmp=$(mktemp -d)", "trap 'rm -r \"$tmp\"' EXIT HUP INT TERM", - "for file in AGENTS-root.md AGENTS-apps.md AGENTS-packages.md AGENTS-services.md CONTRIBUTING.md pre-commit; do curl --fail --silent --show-error \"$base/$file\" -o \"$tmp/$file\"; done", - "for file in AGENTS-root.md AGENTS-apps.md AGENTS-packages.md AGENTS-services.md CONTRIBUTING.md; do [ \"$(grep -c 'socket-managed:begin' \"$tmp/$file\")\" -eq 1 ] && [ \"$(grep -c 'socket-managed:end' \"$tmp/$file\")\" -eq 1 ] || { echo \"just align: remote $file has invalid managed markers; no files were changed.\" >&2; exit 1; }; done", - "[ -s \"$tmp/pre-commit\" ] || { echo \"just align: remote pre-commit hook is empty; no files were changed.\" >&2; exit 1; }", - "for file in AGENTS.md Apps/AGENTS.md Packages/AGENTS.md Services/AGENTS.md CONTRIBUTING.md; do [ \"$(grep -c 'socket-managed:begin' \"$file\")\" -eq 1 ] && [ \"$(grep -c 'socket-managed:end' \"$file\")\" -eq 1 ] || { echo \"just align: $file has invalid managed markers; no files were changed.\" >&2; exit 1; }; done", - "replace() { source=$1; destination=$2; awk -v replacement=\"$source\" '/<!-- socket-managed:begin/ { while ((getline line < replacement) > 0) { print line; if (line ~ /<!-- socket-managed:end/) break }; in_managed=1; next } in_managed { if (/<!-- socket-managed:end/) in_managed=0; next } { print }' \"$destination\" > \"$tmp/out\"; mv \"$tmp/out\" \"$destination\"; }", - "replace \"$tmp/AGENTS-root.md\" AGENTS.md", "replace \"$tmp/AGENTS-apps.md\" Apps/AGENTS.md", "replace \"$tmp/AGENTS-packages.md\" Packages/AGENTS.md", "replace \"$tmp/AGENTS-services.md\" Services/AGENTS.md", "replace \"$tmp/CONTRIBUTING.md\" CONTRIBUTING.md", - "cp \"$tmp/pre-commit\" .githooks/pre-commit", "chmod +x .githooks/pre-commit", "git config core.hooksPath .githooks", "xcodegen generate --spec project.yml", "", - )) - - -def managed_document(source: Path, existing: str | None) -> str: - template = source.read_text(encoding="utf-8") - if existing is None: - return template - state = marker_state(existing) - if state == "invalid": - raise RuntimeError(f"{source.name} has malformed Socket managed markers.") - return existing + ("" if existing.endswith("\n") else "\n") + "\n" + template if state == "absent" else existing - - -def install_alignment_runtime(root: Path, dry_run: bool = False) -> list[str]: - assets = Path(__file__).resolve().parents[1] / "assets" / "managed-guidance" - docs = (("AGENTS-root.md", root / "AGENTS.md"), ("AGENTS-apps.md", root / "Apps/AGENTS.md"), ("AGENTS-packages.md", root / "Packages/AGENTS.md"), ("AGENTS-services.md", root / "Services/AGENTS.md"), ("CONTRIBUTING.md", root / "CONTRIBUTING.md")) - planned: dict[Path, tuple[str, bool]] = {} - for source_name, destination in docs: - if destination.exists() and not destination.is_file(): - raise RuntimeError(f"{destination.relative_to(root)} exists but is not a regular file.") - existing = destination.read_text(encoding="utf-8") if destination.exists() else None - planned[destination] = (managed_document(assets / source_name, existing), False) - justfile = root / "Justfile" - if justfile.exists() and not justfile.is_file(): - raise RuntimeError("Justfile exists but is not a regular file.") - existing = justfile.read_text(encoding="utf-8") if justfile.exists() else 'set shell := ["sh", "-eu", "-c"]\n' - state = marker_state(existing, JUST_BEGIN, JUST_END) - if state == "invalid": - raise RuntimeError("Justfile has malformed Socket managed recipe markers.") - planned[justfile] = ((existing.rstrip() + "\n\n" + managed_recipe_block()) if state == "absent" else existing, False) - for path, content in ((root / ".socket/managed/setup.sh", setup_script()), (root / ".socket/managed/align.sh", align_script())): - if path.exists() and path.read_text(encoding="utf-8") != content: - raise RuntimeError(f"{path.relative_to(root)} conflicts with the Socket-managed alignment helper.") - planned[path] = (content, True) - hook = root / ".githooks/pre-commit" - if hook.exists() and not hook.is_file(): - raise RuntimeError(".githooks/pre-commit exists but is not a regular file.") - planned[hook] = ((assets / "pre-commit").read_text(encoding="utf-8"), True) - if not dry_run: - for destination, (content, executable) in planned.items(): - write(destination, content, executable) - subprocess.run(["git", "config", "core.hooksPath", ".githooks"], cwd=root, check=False) - return [f"install Socket-managed alignment surface at {path.relative_to(root)}" for path in planned] - - -def root_spec(name: str, platforms: list[str]) -> str: - includes = [" - path: Apps/apps-shared.yml\n relativePaths: false", " - path: Packages/packages-shared.yml\n relativePaths: false", " - path: Services/services-shared.yml\n relativePaths: false"] - includes.extend(f" - path: Apps/{name}{SUPPORTED_PLATFORMS[platform]}/target.yml\n relativePaths: false" for platform in platforms) - return "\n".join([ - f"name: {name}", "include:", *includes, "options:", - " minimumXcodeGenVersion: 2.46.0", " projectFormat: xcode16_3", " defaultConfig: Debug", - " defaultSourceDirectoryType: syncedFolder", " schemePathPrefix: ../", " localPackagesGroup: Packages", - " deploymentTarget:", " iOS: \"26.1\"", " macOS: \"26.1\"", " tvOS: \"26.1\"", " watchOS: \"26.1\"", " visionOS: \"26.1\"", - "configs:", *(f" {config}: {'debug' if config == 'Debug' else 'release'}" for config in CONFIGURATIONS), "configFiles:", - *(f" {config}: Configurations/{config}.xcconfig" for config in CONFIGURATIONS), - "fileGroups:", " - Apps", " - Packages", " - Services", " - Configurations", " - Scripts", " - docs", "", - ]) - - -def app_shared_spec() -> str: - return """targetTemplates: - SwiftUIApp: - type: application - settings: - base: - GENERATE_INFOPLIST_FILE: NO - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - preBuildScripts: - - name: SwiftFormat and SwiftLint - script: | - if command -v swiftformat >/dev/null 2>&1; then - swiftformat --lint --config "${SRCROOT}/.swiftformat" "${SRCROOT}/Apps/${TARGET_NAME}" - else - echo "warning: SwiftFormat is not installed; skipping lint." - fi - if command -v swiftlint >/dev/null 2>&1; then - swiftlint lint --config "${SRCROOT}/.swiftlint.yml" --force-exclude "${SRCROOT}/Apps/${TARGET_NAME}" - else - echo "warning: SwiftLint is not installed; skipping lint." - fi - SwiftTesting: - type: bundle.unit-test - settings: - base: - GENERATE_INFOPLIST_FILE: YES - preBuildScripts: - - name: SwiftFormat and SwiftLint - script: | - if command -v swiftformat >/dev/null 2>&1; then swiftformat --lint --config "${SRCROOT}/.swiftformat" "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftFormat is not installed; skipping lint."; fi - if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --config "${SRCROOT}/.swiftlint.yml" --force-exclude "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftLint is not installed; skipping lint."; fi - SwiftUIAutomation: - type: bundle.ui-testing - settings: - base: - GENERATE_INFOPLIST_FILE: YES - preBuildScripts: - - name: SwiftFormat and SwiftLint - script: | - if command -v swiftformat >/dev/null 2>&1; then swiftformat --lint --config "${SRCROOT}/.swiftformat" "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftFormat is not installed; skipping lint."; fi - if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --config "${SRCROOT}/.swiftlint.yml" --force-exclude "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftLint is not installed; skipping lint."; fi -schemeTemplates: - AppScheme: - run: { config: Debug } - test: - config: Debug - gatherCoverageData: true - archive: { config: Staging } - management: { shared: true } -""" - - -def package_spec(name: str) -> str: - return f"""packages: - {name}Core: - path: Packages/{name}Core -""" - - -def target_spec(name: str, platform: str, prefix: str, org: str, team: str, core_package: str | None = None) -> str: - display = SUPPORTED_PLATFORMS[platform] - target = f"{name}{display}" - suffix = {"ios": "ios", "macos": "mac", "tvos": "tv", "watchos": "watch", "visionos": "vision"}[platform] - spec = f"""targets: - {target}: - templates: [SwiftUIApp] - platform: {display} - sources: - - path: Apps/{target}/Sources - type: syncedFolder - - path: Apps/{target}/Resources - type: syncedFolder - info: - path: Apps/{target}/Resources/Info.plist - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()}.{suffix} - DEVELOPMENT_TEAM: {team} - CODE_SIGN_STYLE: Automatic - CODE_SIGN_ENTITLEMENTS: Apps/{target}/Resources/{target}.entitlements - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor - configFiles: - Debug: Apps/{target}/Configurations/Debug.xcconfig - Staging: Apps/{target}/Configurations/Staging.xcconfig - Release: Apps/{target}/Configurations/Release.xcconfig - AppStore: Apps/{target}/Configurations/AppStore.xcconfig - DirectDistribution: Apps/{target}/Configurations/DirectDistribution.xcconfig - AltStore: Apps/{target}/Configurations/AltStore.xcconfig - dependencies: - - package: {core_package or name + 'Core'} - {target}Tests: - templates: [SwiftTesting] - platform: {display} - sources: - - path: Apps/{target}Tests/Sources - type: syncedFolder - dependencies: - - target: {target} - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()}.{suffix}.tests - DEVELOPMENT_TEAM: {team} - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor -schemes: - {target}: - templates: [AppScheme] - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: - {target}: all - test: - targets: - - name: {target}Tests - parallelizable: true -""" - if platform != "watchos": - spec = spec.replace("schemes:\n", f""" {target}UITests: - templates: [SwiftUIAutomation] - platform: {display} - sources: - - path: Apps/{target}UITests/Sources - type: syncedFolder - dependencies: - - target: {target} - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()}.{suffix}.uitests - DEVELOPMENT_TEAM: {team} - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor -schemes: - {target} UI Tests: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - test: - config: Debug - targets: - - name: {target}UITests - parallelizable: true -""") - channels = [("Staging", "Staging"), ("App Store", "AppStore")] - if platform in {"ios", "visionos"}: - channels.append(("AltStore", "AltStore")) - if platform == "macos": - channels.append(("Direct Distribution", "DirectDistribution")) - for title, config in channels: - spec += f""" {target} {title}: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - archive: {{ config: {config} }} -""" - spec += f""" {target} Unit Tests: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - test: - config: Debug - targets: - - name: {target}Tests - parallelizable: true - {target} All Tests: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - test: - config: Debug - targets: - - name: {target}Tests - parallelizable: true -""" - if platform != "watchos": - spec += f""" - name: {target}UITests - parallelizable: true -""" - return spec - - -def workspace_name(root: Path) -> str: - match = re.search(r"^name:\s*([A-Za-z][A-Za-z0-9]*)\s*$", (root / "project.yml").read_text(encoding="utf-8"), re.MULTILINE) - if not match: - raise RuntimeError("project.yml does not declare a canonical alphanumeric workspace name.") - return match.group(1) - - -def add_root_include(root: Path, relative_path: str) -> None: - project = root / "project.yml" - content = project.read_text(encoding="utf-8") - if f"path: {relative_path}" in content: - return - anchor = "options:\n" - if anchor not in content: - raise RuntimeError("project.yml is missing the options section used as the managed include boundary.") - include = f" - path: {relative_path}\n relativePaths: false\n" - write(project, content.replace(anchor, include + anchor, 1)) - - -def ensure_services_surface(root: Path, dry_run: bool = False) -> list[str]: - actions: list[str] = [] - shared = root / "Services/services-shared.yml" - if not shared.is_file(): - actions.append("create Services/services-shared.yml") - if not dry_run: - write(shared, "packages: {}\n") - project = (root / "project.yml").read_text(encoding="utf-8") - if "path: Services/services-shared.yml" not in project: - actions.append("register Services/services-shared.yml in project.yml") - if not dry_run: - add_root_include(root, "Services/services-shared.yml") - return actions - - -def add_package_mapping(root: Path, group: str, name: str) -> None: - shared = root / group / f"{group.lower()}-shared.yml" - content = shared.read_text(encoding="utf-8") - entry = f" {name}:\n path: {group}/{name}\n" - if f" {name}:\n" in content: - return - if content.strip() == "packages: {}": - content = "packages:\n" - elif not content.endswith("\n"): - content += "\n" - write(shared, content + entry) - - -def create_library_component(root: Path, name: str) -> None: - component = root / "Packages" / name - if component.exists(): - raise RuntimeError(f"Packages/{name} already exists.") - component.mkdir(parents=True) - subprocess.run(["swift", "package", "init", "--type", "library", "--name", name, "--enable-swift-testing"], cwd=component, check=True, capture_output=True, text=True) - add_package_mapping(root, "Packages", name) - - -def create_app_component(root: Path, product_name: str, component_name: str, platform: str, prefix: str, org: str, team: str) -> None: - display = SUPPORTED_PLATFORMS[platform] - target = f"{component_name}{display}" - app_root = root / "Apps" / target - if app_root.exists(): - raise RuntimeError(f"Apps/{target} already exists.") - add_root_include(root, f"Apps/{target}/target.yml") - write(app_root / "target.yml", target_spec(component_name, platform, prefix, org, team, f"{product_name}Core")) - write(app_root / "Configurations/App.xcconfig", '#include "../../Apps-shared.xcconfig"\nMARKETING_VERSION = 0.0.1\nCODE_SIGN_STYLE = Automatic\n') - write(app_root / "Configurations/Version.xcconfig", "DEBUG_BUILD_NUMBER = 1\nRELEASE_BUILD_NUMBER = 1\n") - for config in CONFIGURATIONS: - build_number = "$(DEBUG_BUILD_NUMBER)" if config == "Debug" else "$(RELEASE_BUILD_NUMBER)" - content = '#include "App.xcconfig"\n#include "Version.xcconfig"\nCURRENT_PROJECT_VERSION = ' + build_number + "\n" - if config == "Debug": - content += "ONLY_ACTIVE_ARCH = YES\n" - else: - content += "SWIFT_OPTIMIZATION_LEVEL = -O\n" - write(app_root / f"Configurations/{config}.xcconfig", content) - write(app_root / f"Sources/{prefix}App.swift", f'import SwiftUI\n\n@main\nstruct {prefix}{display}App: App {{\n var body: some Scene {{ WindowGroup {{ Text("{target}") }} }}\n}}\n') - for folder in ("Views", "Datamodels", "Services"): - write(app_root / f"Sources/{folder}/.gitkeep", "") - write(app_root / "Resources/Info.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string><key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string></dict></plist>\n') - write(app_root / f"Resources/{target}.entitlements", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>\n') - write(app_root / "Resources/Assets.xcassets/Contents.json", '{"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", '{"images":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AccentColor.colorset/Contents.json", '{"colors":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Localizable.xcstrings", '{"sourceLanguage":"en","strings":{},"version":"1.0"}\n') - tests_root = root / "Apps" / f"{target}Tests" - write(tests_root / f"Sources/{target}Tests.swift", f'import Testing\n@testable import {target}\n\n@Test func example() {{ #expect(true) }}\n') - if platform != "watchos": - ui_root = root / "Apps" / f"{target}UITests" - write(ui_root / f"Sources/{target}UITests.swift", f'import XCTest\n\nfinal class {target}UITests: XCTestCase {{\n func testLaunch() {{}}\n}}\n') - - -def find_target_spec(root: Path, target_name: str) -> Path | None: - declaration = re.compile(rf"^ {re.escape(target_name)}:\s*$", re.MULTILINE) - for path in sorted((root / "Apps").glob("*/target.y*ml")): - if declaration.search(path.read_text(encoding="utf-8")): - return path - return None - - -def embed_extension_in_host(root: Path, host_target: str, extension_target: str) -> None: - spec = find_target_spec(root, host_target) - if spec is None: - raise RuntimeError(f"Host application target {host_target!r} was not found under Apps/.") - content = spec.read_text(encoding="utf-8") - if f"- target: {extension_target}" in content: - return - lines = content.splitlines() - target_start = next((index for index, line in enumerate(lines) if line == f" {host_target}:"), None) - if target_start is None: - raise RuntimeError(f"Could not locate the {host_target!r} target declaration in {spec.relative_to(root)}.") - target_end = next((index for index in range(target_start + 1, len(lines)) if re.match(r"^ \S.*:\s*$", lines[index])), len(lines)) - dependencies = next((index for index in range(target_start + 1, target_end) if lines[index] == " dependencies:"), None) - entry = [f" - target: {extension_target}", " embed: true"] - if dependencies is None: - lines[target_end:target_end] = [" dependencies:", *entry] - else: - dependency_end = next((index for index in range(dependencies + 1, target_end) if re.match(r"^ \S.*:\s*$", lines[index])), target_end) - lines[dependency_end:dependency_end] = entry - write(spec, "\n".join(lines) + "\n") - - -def extension_target_spec( - name: str, - platform: str, - org: str, - team: str, - product_type: str, -) -> str: - display = SUPPORTED_PLATFORMS[platform] - xcodegen_type = "app-extension" if product_type == "app-extension" else "extensionkit-extension" - return f"""targets: - {name}: - type: {xcodegen_type} - platform: {display} - sources: - - path: Apps/{name}/Sources - type: syncedFolder - - path: Apps/{name}/Resources - type: syncedFolder - info: - path: Apps/{name}/Resources/Info.plist - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()} - DEVELOPMENT_TEAM: {team} - CODE_SIGN_STYLE: Automatic - CODE_SIGN_ENTITLEMENTS: Apps/{name}/Resources/{name}.entitlements - configFiles: - Debug: Apps/{name}/Configurations/Debug.xcconfig - Staging: Apps/{name}/Configurations/Staging.xcconfig - Release: Apps/{name}/Configurations/Release.xcconfig - AppStore: Apps/{name}/Configurations/AppStore.xcconfig - DirectDistribution: Apps/{name}/Configurations/DirectDistribution.xcconfig - AltStore: Apps/{name}/Configurations/AltStore.xcconfig -""" - - -def create_extension_component( - root: Path, - name: str, - platform: str, - host_target: str, - product_type: str, - extension_point_identifier: str, - org: str, - team: str, -) -> None: - extension_root = root / "Apps" / name - if extension_root.exists(): - raise RuntimeError(f"Apps/{name} already exists.") - add_root_include(root, f"Apps/{name}/target.yml") - write(extension_root / "target.yml", extension_target_spec(name, platform, org, team, product_type)) - write(extension_root / "Configurations/Extension.xcconfig", '#include "../../Apps-shared.xcconfig"\nMARKETING_VERSION = 0.0.1\nCODE_SIGN_STYLE = Automatic\n') - for config in CONFIGURATIONS: - write(extension_root / f"Configurations/{config}.xcconfig", '#include "Extension.xcconfig"\n') - write(extension_root / "Sources/Extension.swift", "import Foundation\n\n// Implement the documented extension-point entry type here.\n") - write( - extension_root / "Resources/Info.plist", - '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>NSExtension</key><dict><key>NSExtensionPointIdentifier</key><string>' - + extension_point_identifier - + "</string></dict></dict></plist>\n", - ) - write(extension_root / f"Resources/{name}.entitlements", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>\n') - embed_extension_in_host(root, host_target, name) - - -def relative(path: Path, root: Path) -> str: - try: - return str(path.relative_to(root)) - except ValueError: - return str(path) - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8", errors="replace") - except OSError: - return "" - - -def pbx_target_records(text: str) -> list[tuple[str, str | None]]: - records: list[tuple[str, str | None]] = [] - for match in re.finditer(r"isa = PBXNativeTarget;(?P<body>[\s\S]{0,1800}?)\s*};", text): - body = match.group("body") - name_match = re.search(r"\bname = (?P<name>[^;]+);", body) - product_match = re.search(r"\bproductType = (?P<type>[^;]+);", body) - if name_match: - records.append((name_match.group("name").strip().strip('"'), product_match.group("type").strip().strip('"') if product_match else None)) - return records - - -def pbx_extension_hosts(text: str, app_names: list[str], extension_names: list[str]) -> dict[str, str]: - hosts: dict[str, str] = {} - for extension in extension_names: - if re.search(rf"\b{re.escape(extension)}\.appex in Embed App Extensions\b", text) and len(app_names) == 1: - hosts[extension] = app_names[0] - return hosts - - -def xcodegen_target_records(text: str) -> list[tuple[str, str | None, str | None, str | None]]: - records: list[tuple[str, str | None, str | None, str | None]] = [] - targets_match = re.search(r"^targets:\s*$", text, re.MULTILINE) - if not targets_match: - return records - tail = text[targets_match.end():] - section_end = re.search(r"^\S[^:]*:\s*$", tail, re.MULTILINE) - section = tail[:section_end.start()] if section_end else tail - matches = list(re.finditer(r"^ (?P<name>[^\s][^:]*):\s*$", section, re.MULTILINE)) - for index, match in enumerate(matches): - body_end = matches[index + 1].start() if index + 1 < len(matches) else len(section) - body = section[match.end():body_end] - type_match = re.search(r"^ type:\s*([^\s#]+)", body, re.MULTILINE) - platform_match = re.search(r"^ platform:\s*([^\s#]+)", body, re.MULTILINE) - dependency_match = re.search(r"^ - target:\s*([^\s#]+)[\s\S]{0,120}?^ embed:\s*true", body, re.MULTILINE) - records.append((match.group("name").strip().strip('"'), type_match.group(1) if type_match else None, platform_match.group(1).lower() if platform_match else None, dependency_match.group(1) if dependency_match else None)) - return records - - -def manifest_component(manifest: Path, root: Path) -> Component: - text = read_text(manifest) - name_match = re.search(r"Package\s*\(\s*name:\s*\"([^\"]+)\"", text) - name = name_match.group(1) if name_match else manifest.parent.name - executable = bool(re.search(r"\.(?:executable|executableTarget)\s*\(", text)) - framework = "hummingbird" if re.search(r"Hummingbird", text, re.IGNORECASE) else "vapor" if re.search(r"\bVapor\b", text) else None - kind = "service" if executable else "library" - destination = f"{'Services' if kind == 'service' else 'Packages'}/{name}" - owner = relative(manifest.parent, root) or "." - paths = [owner] if owner != "." else [relative(manifest, root)] - if owner == ".": - for child in ("Sources", "Tests", "Plugins"): - candidate = manifest.parent / child - if candidate.exists(): - paths.append(relative(candidate, root)) - evidence = [f"SwiftPM manifest {relative(manifest, root)}", "executable product or target" if executable else "library package"] - if framework: - evidence.append(f"{framework} dependency") - unresolved = [] if name_match else ["Package.swift does not expose a literal package name"] - return Component(name, kind, owner, destination, evidence, product_type=framework, owned_paths=paths, unresolved=unresolved) - - -def inventory_components(root: Path) -> tuple[list[Component], dict[str, Any]]: - projects = sorted(path for path in root.rglob("*.xcodeproj") if ".build" not in path.parts) - workspaces = sorted(path for path in root.rglob("*.xcworkspace") if ".build" not in path.parts) - specs = sorted(path for path in root.rglob("project.y*ml") if ".build" not in path.parts) - manifests = sorted(path for path in root.rglob("Package.swift") if ".build" not in path.parts) - components = [manifest_component(path, root) for path in manifests] - pbx_settings: set[str] = set() - target_records: list[tuple[str, str | None]] = [] - pbx_texts: list[str] = [] - for project in projects: - text = read_text(project / "project.pbxproj") - pbx_texts.append(text) - target_records.extend(pbx_target_records(text)) - pbx_settings.update(re.findall(r"\b(?:PRODUCT_BUNDLE_IDENTIFIER|CODE_SIGN_ENTITLEMENTS|DEVELOPMENT_TEAM|INFOPLIST_FILE|MARKETING_VERSION|CURRENT_PROJECT_VERSION|SWIFT_VERSION)\s*=", text)) - sdk_roots = {match.lower() for text in pbx_texts for match in re.findall(r"\bSDKROOT\s*=\s*([^;\s]+)", text)} - inferred_platform = "ios" if sdk_roots and sdk_roots <= {"iphoneos"} else "macos" if sdk_roots and sdk_roots <= {"macosx"} else None - app_names = [name for name, product in target_records if XCODE_PRODUCT_TYPES.get(product or "") == "app"] - extension_names = [name for name, product in target_records if XCODE_PRODUCT_TYPES.get(product or "") == "extension"] - hosts: dict[str, str] = {} - for text in pbx_texts: - hosts.update(pbx_extension_hosts(text, app_names, extension_names)) - flat_owned = [name for name in ("Sources", "Resources", "Tests", "Configurations", "Shared", "Extensions") if (root / name).exists()] - for name, product in target_records: - kind = XCODE_PRODUCT_TYPES.get(product or "") - if kind is None: - components.append(Component(name, "unsupported", ".", "", [f"PBX product type {product or 'missing'}"], product_type=product, unresolved=["unsupported or missing Xcode product type"])) - continue - destination = f"Apps/{name}" - host = hosts.get(name) - unresolved: list[str] = [] - if kind in {"app", "extension", "test", "ui-test"} and not inferred_platform: - unresolved.append("target platform requires reviewed mapping evidence") - if kind == "extension" and not host: - unresolved.append("extension host target is not explicit or is ambiguous") - owned = flat_owned if kind == "app" and len(app_names) == 1 else [] - components.append(Component(name, kind, ".", destination, [f"PBX native target product type {product}"], host_target=host, platform=inferred_platform, product_type=product, owned_paths=owned, unresolved=unresolved)) - discovered_names = {component.name for component in components} - xcodegen_records = [record for spec in specs for record in xcodegen_target_records(read_text(spec))] - xcodegen_apps = [name for name, product, _, _ in xcodegen_records if product == "application"] - xcodegen_hosts = {dependency: name for name, product, _, dependency in xcodegen_records if product == "application" and dependency} - for name, product, platform, _ in xcodegen_records: - if name in discovered_names: - continue - kind = {"application": "app", "app-extension": "extension", "extensionkit-extension": "extension", "bundle.unit-test": "test", "bundle.ui-testing": "ui-test"}.get(product or "") - unresolved: list[str] = [] - if kind is None: - components.append(Component(name, "unsupported", ".", "", [f"XcodeGen target type {product or 'missing'}"], product_type=product, unresolved=["unsupported or missing XcodeGen target type"])) - continue - normalized_platform = {"ios": "ios", "macos": "macos", "tvos": "tvos", "watchos": "watchos", "visionos": "visionos"}.get(platform or "") - if not normalized_platform: - unresolved.append("target platform requires reviewed mapping evidence") - host = xcodegen_hosts.get(name) - if kind == "extension" and not host: - unresolved.append("extension host target is not explicit or is ambiguous") - owned = flat_owned if kind == "app" and len(xcodegen_apps) == 1 else [] - canonical_product = "com.apple.product-type.app-extension" if product == "app-extension" else "com.apple.product-type.extensionkit-extension" if product == "extensionkit-extension" else product - components.append(Component(name, kind, ".", f"Apps/{name}", [f"XcodeGen target type {product}"], host_target=host, platform=normalized_platform, product_type=canonical_product, owned_paths=owned, unresolved=unresolved)) - inventory = { - "workspaces": [relative(path, root) for path in workspaces], - "projects": [relative(path, root) for path in projects], - "xcodegen_specs": [relative(path, root) for path in specs], - "swift_manifests": [relative(path, root) for path in manifests], - "xcconfigs": [relative(path, root) for path in sorted(root.rglob("*.xcconfig"))], - "entitlements": [relative(path, root) for path in sorted(root.rglob("*.entitlements"))], - "info_plists": [relative(path, root) for path in sorted(root.rglob("Info.plist"))], - "asset_catalogs": [relative(path, root) for path in sorted(root.rglob("*.xcassets"))], - "schemes": [relative(path, root) for path in sorted(root.rglob("*.xcscheme"))], - "test_plans": [relative(path, root) for path in sorted(root.rglob("*.xctestplan"))], - "pbx_settings_to_promote": sorted(item.removesuffix(" =") for item in pbx_settings), - "cloud_inputs": [relative(path, root) for pattern in ("Dockerfile*", "fly.toml") for path in sorted(root.rglob(pattern))], - } - return components, inventory - - -def proposed_adoption_map(root: Path, components: list[Component]) -> dict[str, Any]: - name = next((path.stem for path in sorted(root.glob("*.xcworkspace"))), None) or next((path.stem for path in sorted(root.glob("*.xcodeproj"))), None) or (components[0].name if components else None) or re.sub(r"[^A-Za-z0-9]", "", root.name.title()) or "Product" - return {"schema_version": 1, "workspace_name": name, "components": [asdict(component) for component in components]} - - -def validate_adoption_map(root: Path, mapping: dict[str, Any]) -> list[str]: - errors: list[str] = [] - if mapping.get("schema_version") != 1: - errors.append("adoption map schema_version must be 1") - name = mapping.get("workspace_name") - if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name): - errors.append("workspace_name must be an alphanumeric Xcode identifier") - components = mapping.get("components") - if not isinstance(components, list) or not components: - errors.append("adoption map must contain at least one component") - return errors - destinations: set[str] = set() - owned: dict[str, str] = {} - app_names = {item.get("name") for item in components if isinstance(item, dict) and item.get("kind") == "app"} - for index, item in enumerate(components): - label = f"components[{index}]" - if not isinstance(item, dict): - errors.append(f"{label} must be an object") - continue - kind, component_name, destination = item.get("kind"), item.get("name"), item.get("proposed_destination") - if kind not in {"app", "extension", "test", "ui-test", "library", "service"}: - errors.append(f"{label}.kind is unsupported: {kind!r}") - if not isinstance(component_name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", component_name): - errors.append(f"{label}.name must be an alphanumeric target or package name") - expected_prefix = "Apps/" if kind in {"app", "extension", "test", "ui-test"} else "Packages/" if kind == "library" else "Services/" - if not isinstance(destination, str) or not destination.startswith(expected_prefix) or ".." in Path(destination).parts: - errors.append(f"{label}.proposed_destination must be under {expected_prefix}") - elif destination in destinations: - errors.append(f"duplicate component destination: {destination}") - else: - destinations.add(destination) - if kind in {"app", "extension", "test", "ui-test"} and item.get("platform") not in SUPPORTED_PLATFORMS: - errors.append(f"{label}.platform requires explicit ios, macos, tvos, watchos, or visionos evidence") - if kind == "extension": - if item.get("host_target") not in app_names: - errors.append(f"{label}.host_target must name one mapped application target") - if item.get("product_type") not in {"com.apple.product-type.app-extension", "com.apple.product-type.extensionkit-extension"}: - errors.append(f"{label}.product_type must be a supported documented extension product type") - if not item.get("extension_point_identifier"): - errors.append(f"{label}.extension_point_identifier is required") - for source in item.get("owned_paths") or []: - if not isinstance(source, str) or Path(source).is_absolute() or ".." in Path(source).parts: - errors.append(f"{label}.owned_paths contains an unsafe path") - continue - if source in owned: - errors.append(f"{source} is assigned to both {owned[source]} and {component_name}") - owned[source] = str(component_name) - if not (root / source).exists(): - errors.append(f"mapped source does not exist: {source}") - if item.get("unresolved"): - errors.append(f"{label} still has unresolved evidence: {', '.join(item['unresolved'])}") - return errors - - -def move_owned_path(root: Path, source_name: str, destination_root: Path) -> None: - source = root / source_name - if source.name in {"Package.swift", "Sources", "Tests", "Plugins"}: - destination = destination_root / source.name - elif source.is_dir() and source_name.count("/") > 0: - destination = destination_root - else: - destination = destination_root / source.name - if destination.exists(): - raise RuntimeError(f"Adoption destination already exists: {relative(destination, root)}") - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(source), str(destination)) - - -def adopted_native_target_spec(item: dict[str, Any], org: str, team: str) -> str: - name, kind, platform = item["name"], item["kind"], item["platform"] - display = SUPPORTED_PLATFORMS[platform] - if kind == "app": - product_type = "application" - elif kind == "extension": - product_type = "app-extension" if item["product_type"] == "com.apple.product-type.app-extension" else "extensionkit-extension" - elif kind == "test": - product_type = "bundle.unit-test" - else: - product_type = "bundle.ui-testing" - destination = item["proposed_destination"] - lines = [ - "targets:", f" {name}:", f" type: {product_type}", f" platform: {display}", " sources:", - f" - path: {destination}/Sources", " type: syncedFolder", " optional: true", - f" - path: {destination}/Resources", " type: syncedFolder", " optional: true", - " settings:", " base:", f" PRODUCT_BUNDLE_IDENTIFIER: {item.get('bundle_identifier') or org + '.' + name.lower()}", - f" DEVELOPMENT_TEAM: {item.get('development_team') or team}", " CODE_SIGN_STYLE: Automatic", - ] - if kind == "extension": - lines.extend([" info:", f" path: {destination}/Resources/Info.plist"]) - dependencies = item.get("dependencies") or [] - if kind in {"test", "ui-test"} and item.get("host_target"): - dependencies = [*dependencies, item["host_target"]] - if dependencies: - lines.append(" dependencies:") - lines.extend(f" - target: {dependency}" for dependency in dependencies) - return "\n".join(lines) + "\n" - - -def stage_adoption(root: Path, mapping: dict[str, Any], org: str, team: str) -> dict[str, Any]: - snapshot = root / ".socket/adoption/original-inventory.json" - if snapshot.exists(): - raise RuntimeError("An adoption is already staged; review or revert .socket/adoption before applying another map.") - components, inventory = inventory_components(root) - write(snapshot, json.dumps({"inventory": inventory, "components": [asdict(item) for item in components]}, indent=2, sort_keys=True) + "\n") - original_spec = root / "project.yml" - if original_spec.exists(): - write(root / ".socket/adoption/original-project.yml", original_spec.read_text(encoding="utf-8")) - for directory in ("Apps", "Packages", "Services", "Configurations", "Scripts", "docs"): - (root / directory).mkdir(exist_ok=True) - name = mapping["workspace_name"] - write(root / "project.yml", root_spec(name, [])) - write(root / "Apps/apps-shared.yml", app_shared_spec()) - write(root / "Apps/Apps-shared.xcconfig", '#include "../Configurations/Project.xcconfig"\n') - write(root / "Packages/packages-shared.yml", "packages: {}\n") - write(root / "Services/services-shared.yml", "packages: {}\n") - write(root / "Configurations/Project.xcconfig", "SWIFT_VERSION = 6.0\nSWIFT_STRICT_CONCURRENCY = complete\n") - for config in CONFIGURATIONS: - write(root / f"Configurations/{config}.xcconfig", '#include "Project.xcconfig"\n') - native_items = [item for item in mapping["components"] if item["kind"] in {"app", "extension", "test", "ui-test"}] - for item in mapping["components"]: - destination = root / item["proposed_destination"] - for source in item.get("owned_paths") or []: - move_owned_path(root, source, destination) - if item["kind"] in {"library", "service"}: - add_package_mapping(root, "Packages" if item["kind"] == "library" else "Services", item["name"]) - else: - add_root_include(root, f"{item['proposed_destination']}/target.yml") - write(destination / "target.yml", adopted_native_target_spec(item, org, team)) - if item["kind"] == "extension": - info = destination / "Resources/Info.plist" - if not info.exists(): - write(info, '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>NSExtension</key><dict><key>NSExtensionPointIdentifier</key><string>' + item["extension_point_identifier"] + '</string></dict></dict></plist>\n') - for item in native_items: - if item["kind"] == "extension": - embed_extension_in_host(root, item["host_target"], item["name"]) - candidate = root / ".socket/adoption-candidate" - candidate.mkdir(parents=True, exist_ok=True) - generated = subprocess.run(["xcodegen", "generate", "--spec", "project.yml", "--project", str(candidate), "--project-root", str(root)], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"candidate XcodeGen generation failed:\n{generated.stderr}") - candidate_pbx = read_text(candidate / f"{name}.xcodeproj/project.pbxproj") - generated_targets = {target for target, _ in pbx_target_records(candidate_pbx)} - expected_targets = {item["name"] for item in native_items} - missing = sorted(expected_targets - generated_targets) - report = { - "expected_native_targets": sorted(expected_targets), - "generated_native_targets": sorted(generated_targets), - "missing_native_targets": missing, - "candidate_project": relative(candidate / f"{name}.xcodeproj", root), - "preserved_inventory": relative(snapshot, root), - } - write(root / ".socket/adoption/equivalence-report.json", json.dumps(report, indent=2, sort_keys=True) + "\n") - if missing: - raise RuntimeError("Candidate equivalence failed; missing native targets: " + ", ".join(missing)) - return report - - -def install(root: Path, name: str, prefix: str, platforms: list[str], org: str, team: str) -> None: - write(root / "project.yml", root_spec(name, platforms)) - write(root / "Apps/apps-shared.yml", app_shared_spec()) - write(root / "Apps/Apps-shared.xcconfig", "#include \"../Configurations/Project.xcconfig\"\nASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES\nLOCALIZATION_PREFERS_STRING_CATALOGS = YES\nSTRING_CATALOG_GENERATE_SYMBOLS = YES\n") - write(root / "Packages/packages-shared.yml", package_spec(name)) - write(root / "Services/services-shared.yml", "packages: {}\n") - write(root / "Configurations/Project.xcconfig", "SWIFT_VERSION = 6.0\nSWIFT_STRICT_CONCURRENCY = complete\nSWIFT_APPROACHABLE_CONCURRENCY = YES\nDEAD_CODE_STRIPPING = YES\nENABLE_USER_SCRIPT_SANDBOXING = NO\n") - for config in CONFIGURATIONS: - settings = '#include "Project.xcconfig"\n' - if config == "Debug": - settings += "SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited)\n" - else: - settings += "SWIFT_COMPILATION_MODE = wholemodule\nSWIFT_OPTIMIZATION_LEVEL = -O\n" - write(root / f"Configurations/{config}.xcconfig", settings) - write(root / ".gitignore", "Build/\nDerivedData/\nxcuserdata/\n*.xcuserstate\n") - write(root / f"{name}.xcworkspace/contents.xcworkspacedata", f'<?xml version="1.0" encoding="UTF-8"?>\n<Workspace version="1.0">\n <FileRef location="group:{name}.xcodeproj"/>\n</Workspace>\n') - (root / "docs").mkdir() - install_alignment_runtime(root) - write(root / "Justfile", (root / "Justfile").read_text(encoding="utf-8") + "\nvalidate:\n sh Scripts/validate.sh\npackage-test:\n for manifest in Packages/*/Package.swift Services/*/Package.swift; do [ -f \"$manifest\" ] || continue; (cd \"$(dirname \"$manifest\")\" && swift test); done\ntest target:\n xcodebuild -workspace *.xcworkspace -scheme \"{{target}}\" test\narchive target channel:\n sh Scripts/release.sh \"{{target}}\" \"{{channel}}\"\napp-store target:\n sh Scripts/release.sh \"{{target}}\" app-store\naltstore target:\n sh Scripts/release.sh \"{{target}}\" altstore\ndirect-distribution target:\n sh Scripts/release.sh \"{{target}}\" direct-distribution\n") - write(root / "Scripts/increment-build-version.sh", "#!/usr/bin/env sh\nset -eu\ntarget=${1:?target required}; configuration=${2:?configuration required}; label=$(printf '%s' \"$configuration\" | tr '[:upper:]' '[:lower:]')\nfile=\"Apps/$target/Configurations/Version.xcconfig\"\ngit rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo \"Build counter requires a Git repository.\" >&2; exit 1; }\n[ \"$configuration\" = Debug ] && key=DEBUG_BUILD_NUMBER || key=RELEASE_BUILD_NUMBER\nvalue=$(awk -F ' = ' -v key=\"$key\" '$1 == key { print $2 }' \"$file\")\n[ -n \"$value\" ] || { echo \"Missing $key in $file\" >&2; exit 1; }\nawk -F ' = ' -v key=\"$key\" -v next=$((value + 1)) 'BEGIN { OFS = \" = \" } $1 == key { $2 = next } { print }' \"$file\" > \"$file.tmp\" && mv \"$file.tmp\" \"$file\"\nstaged=false; unstaged=false; git diff --cached --quiet || staged=true; git diff --quiet || unstaged=true\nif $staged && $unstaged; then git add \"$file\"; echo \"warning: staged build counter update; commit it manually as soon as possible.\" >&2; exit 0; fi\nif $staged; then patch=$(mktemp); git diff --cached --binary > \"$patch\"; git restore --staged :/; git add \"$file\"; git commit -m \"build: increment $target $label build\"; git apply --cached \"$patch\"; rm -f \"$patch\"; else git add \"$file\"; git commit -m \"build: increment $target $label build\"; fi\n", True) - write(root / "Scripts/validate.sh", "#!/usr/bin/env sh\nset -eu\nswiftformat --lint --config .swiftformat Apps Packages Services\nswiftlint lint --config .swiftlint.yml --force-exclude Apps Packages Services\nxcodegen generate --spec project.yml\nworkspace=$(find . -maxdepth 1 -type d -name '*.xcworkspace' -print -quit)\nxcodebuild -list -workspace \"$workspace\"\nfor manifest in Packages/*/Package.swift Services/*/Package.swift; do [ -f \"$manifest\" ] || continue; (cd \"$(dirname \"$manifest\")\" && swift test); done\n", True) - write(root / "Scripts/release.sh", "#!/usr/bin/env sh\nset -eu\ntarget=${1:?target required}; channel=${2:?channel required}\nworkspace=$(find . -maxdepth 1 -type d -name '*.xcworkspace' -print -quit)\ncase \"$channel\" in\n staging) scheme=\"$target Staging\"; config=Staging ;;\n app-store) scheme=\"$target App Store\"; config=AppStore ;;\n altstore) scheme=\"$target AltStore\"; config=AltStore ;;\n direct-distribution) scheme=\"$target Direct Distribution\"; config=DirectDistribution ;;\n *) echo \"Unknown release channel: $channel\" >&2; exit 1 ;;\nesac\narchive=\"Build/$target-$channel.xcarchive\"\nxcodebuild -workspace \"$workspace\" -scheme \"$scheme\" -configuration \"$config\" -archivePath \"$archive\" archive\ncase \"$channel\" in\n app-store) xcodebuild -exportArchive -archivePath \"$archive\" -exportPath \"Build/$target-app-store\" -exportOptionsPlist Scripts/ExportOptions/AppStore.plist; artifact=$(find \"Build/$target-app-store\" -type f \\( -name '*.ipa' -o -name '*.pkg' \\) -print -quit); [ -n \"$artifact\" ] || { echo \"App Store export produced no IPA or PKG.\" >&2; exit 1; }; case \"$target\" in *macOS) type=osx ;; *) type=ios ;; esac; xcrun altool --upload-app -f \"$artifact\" -t \"$type\" ;;\n altstore) xcodebuild -exportArchive -archivePath \"$archive\" -exportPath \"Build/$target-altstore\" -exportOptionsPlist Scripts/ExportOptions/AltStore.plist ;;\n direct-distribution) xcodebuild -exportArchive -archivePath \"$archive\" -exportPath \"Build/$target-direct\" -exportOptionsPlist Scripts/ExportOptions/DirectDistribution.plist; app=$(find \"Build/$target-direct\" -type d -name '*.app' -print -quit); [ -n \"$app\" ] || { echo \"Direct export produced no app bundle.\" >&2; exit 1; }; dmg=\"Build/$target-direct/$target.dmg\"; hdiutil create -volname \"$target\" -srcfolder \"$app\" -ov -format UDZO \"$dmg\"; xcrun notarytool submit \"$dmg\" --keychain-profile notarytool --wait; xcrun stapler staple \"$dmg\" ;;\nesac\n", True) - write(root / "Scripts/ExportOptions/AppStore.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>method</key><string>app-store</string></dict></plist>\n') - write(root / "Scripts/ExportOptions/AltStore.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>method</key><string>development</string></dict></plist>\n') - write(root / "Scripts/ExportOptions/DirectDistribution.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>method</key><string>developer-id</string></dict></plist>\n') - write(root / "ROADMAP.md", "# Roadmap\n\n## Managed workspace expansion\n\n- [ ] Extend `just align` ownership to additional documentation, templates, and repository scripts when each surface has a safe managed boundary.\n") - for platform in platforms: - display = SUPPORTED_PLATFORMS[platform] - target = f"{name}{display}" - app_root = root / "Apps" / target - write(app_root / "target.yml", target_spec(name, platform, prefix, org, team)) - write(app_root / "Configurations/App.xcconfig", '#include "../../Apps-shared.xcconfig"\nMARKETING_VERSION = 0.0.1\nCODE_SIGN_STYLE = Automatic\n') - write(app_root / "Configurations/Version.xcconfig", "DEBUG_BUILD_NUMBER = 1\nRELEASE_BUILD_NUMBER = 1\n") - for config in CONFIGURATIONS: - build_number = "$(DEBUG_BUILD_NUMBER)" if config == "Debug" else "$(RELEASE_BUILD_NUMBER)" - content = '#include "App.xcconfig"\n#include "Version.xcconfig"\nCURRENT_PROJECT_VERSION = ' + build_number + "\n" - if config == "Debug": - content += "ONLY_ACTIVE_ARCH = YES\n" - else: - content += "SWIFT_OPTIMIZATION_LEVEL = -O\n" - write(app_root / f"Configurations/{config}.xcconfig", content) - write(app_root / f"Sources/{prefix}App.swift", f'import SwiftUI\n\n@main\nstruct {prefix}{display}App: App {{\n var body: some Scene {{ WindowGroup {{ Text("{target}") }} }}\n}}\n') - write(app_root / "Sources/Views/.gitkeep", "") - write(app_root / "Sources/Datamodels/.gitkeep", "") - write(app_root / "Sources/Services/.gitkeep", "") - write(app_root / "Resources/Info.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string><key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string></dict></plist>\n') - write(app_root / f"Resources/{target}.entitlements", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>\n') - write(app_root / "Resources/Assets.xcassets/Contents.json", '{"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", '{"images":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AccentColor.colorset/Contents.json", '{"colors":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Localizable.xcstrings", '{"sourceLanguage":"en","strings":{},"version":"1.0"}\n') - tests_root = root / "Apps" / f"{target}Tests" - write(tests_root / f"Sources/{target}Tests.swift", f'import Testing\n@testable import {target}\n\n@Test func example() {{ #expect(true) }}\n') - if platform != "watchos": - ui_root = root / "Apps" / f"{target}UITests" - write(ui_root / f"Sources/{target}UITests.swift", f'import XCTest\n\nfinal class {target}UITests: XCTestCase {{\n func testLaunch() {{}}\n}}\n') - package_root = root / "Packages" / f"{name}Core" - package_root.mkdir(parents=True) - subprocess.run(["swift", "package", "init", "--type", "library", "--name", f"{name}Core", "--enable-swift-testing"], cwd=package_root, check=True, capture_output=True, text=True) - write(package_root / "Package.swift", f'''// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "{name}Core", - platforms: [.iOS(.v26), .macOS(.v26), .tvOS(.v26), .watchOS(.v26), .visionOS(.v26)], - products: [.library(name: "{name}Core", targets: ["{name}Core"])], - targets: [ - .target(name: "{name}Domain"), - .target(name: "{name}UI", dependencies: ["{name}Domain"]), - .target(name: "{name}Services", dependencies: ["{name}Domain"]), - .target(name: "{name}Core", dependencies: ["{name}Domain", "{name}UI", "{name}Services"]), - .testTarget(name: "{name}DomainTests", dependencies: ["{name}Domain"]), - .testTarget(name: "{name}UITests", dependencies: ["{name}UI"]), - .testTarget(name: "{name}ServicesTests", dependencies: ["{name}Services"]), - .testTarget(name: "{name}CoreTests", dependencies: ["{name}Core"]), - ] -) -''') - write(package_root / f"Sources/{name}Core/{name}Core.swift", f"@_exported import {name}Domain\n@_exported import {name}UI\n@_exported import {name}Services\n") - for module, folders in ((f"{name}Domain", ("Datamodels", "Actions")), (f"{name}UI", ("Components", "Styles")), (f"{name}Services", ("Clients", "DTOs"))): - for folder in folders: - write(package_root / f"Sources/{module}/{folder}/.gitkeep", "") - write(package_root / f"Sources/{module}/{module}.swift", f"public enum {module} {{}}\n") - for module in (f"{name}Core", f"{name}Domain", f"{name}UI", f"{name}Services"): - write(package_root / f"Tests/{module}Tests/{module}Tests.swift", f"import Testing\n@testable import {module}\n\n@Test func example() {{ #expect(true) }}\n") - - -def main() -> int: - args = parser().parse_args() - platforms = [item.strip().lower() for item in args.platforms.split(",") if item.strip()] - root = Path(args.repo_root).expanduser().resolve() if args.operation in {"adopt", "align", "add-component"} and args.repo_root else ((Path(args.destination).expanduser() / args.name).resolve() if args.name else Path(args.destination).expanduser().resolve()) - inputs = {"operation": args.operation, "name": args.name, "file_prefix": args.file_prefix, "destination": args.destination, "repo_root": args.repo_root, "platforms": platforms, "component_kind": args.component_kind, "component_name": args.component_name, "platform": args.platform, "framework": args.framework, "host_target": args.host_target, "extension_product_type": args.extension_product_type, "extension_point_identifier": args.extension_point_identifier, "adoption_map": args.adoption_map, "apply": args.apply, "org_identifier": args.org_identifier, "development_team": args.development_team, "dry_run": args.dry_run, "skip_validation": args.skip_validation} - if args.operation == "adopt": - if not args.repo_root: - return blocked("--repo-root is required with --operation adopt.", inputs) - if not root.is_dir(): - return blocked("The requested adoption root is not a directory.", inputs) - if not workspace_findings(root): - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "components": [], "migration_required": False, "next_step": "The repository is already canonical; use --operation align."}, indent=2, sort_keys=True)) - return 0 - components, inventory = inventory_components(root) - mapping = proposed_adoption_map(root, components) - if not components: - return blocked("No SwiftPM manifest or Xcode native target evidence was found to adopt.", inputs) - if not args.apply: - unresolved = [f"{item.name}: {reason}" for item in components for reason in item.unresolved] - payload = {"status": "blocked" if unresolved else "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "inventory": inventory, "components": [asdict(item) for item in components], "adoption_map": mapping, "migration_required": True, "unresolved": unresolved, "next_step": "Review the adoption_map, add required explicit ownership/host/platform/extension-point evidence, save it as JSON, then rerun with --adoption-map <path> --apply."} - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 if unresolved else 0 - if not args.adoption_map: - return blocked("--adoption-map is required with --operation adopt --apply.", inputs) - map_path = Path(args.adoption_map).expanduser().resolve() - if not map_path.is_file(): - return blocked("The reviewed --adoption-map file does not exist.", inputs) - try: - reviewed = json.loads(map_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return blocked(f"Could not read the reviewed adoption map: {exc}", inputs) - errors = validate_adoption_map(root, reviewed) - if errors: - return blocked("Reviewed adoption map is not safe to apply: " + "; ".join(errors), inputs) - if not shutil.which("xcodegen"): - return blocked("XcodeGen is required to generate the adoption candidate project.", inputs) - try: - report = stage_adoption(root, reviewed, args.org_identifier, args.development_team) - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "components": reviewed["components"], "equivalence": report, "migration_required": True, "next_step": "Review .socket/adoption/equivalence-report.json and candidate project before finalizing removal of superseded project files; no original project was deleted."}, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - return blocked(str(exc), inputs) - if args.operation == "add-component": - if not args.repo_root or not args.component_kind or not args.component_name: - return blocked("--repo-root, --component-kind, and --component-name are required with --operation add-component.", inputs) - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", args.component_name): - return blocked("--component-name must be an alphanumeric Swift identifier beginning with a letter.", inputs) - findings = workspace_findings(root, allow_missing_services=True) if root.is_dir() else ["The requested workspace root is not a directory."] - if findings: - return blocked(" ".join(findings), inputs) - if args.component_kind == "app" and not args.platform: - return blocked("--platform is required when adding an app component.", inputs) - if args.component_kind == "app" and not re.fullmatch(r"[A-Z]{3}", args.file_prefix): - return blocked("--file-prefix must contain exactly three uppercase ASCII letters.", inputs) - if args.component_kind == "extension" and (not args.platform or not args.host_target or not args.extension_product_type or not args.extension_point_identifier): - return blocked("--platform, --host-target, --extension-product-type, and --extension-point-identifier are required when adding an extension component.", inputs) - if args.component_kind == "service" and not args.framework: - return blocked("--framework is required when adding a service component.", inputs) - if not args.dry_run and not shutil.which("xcodegen"): - return blocked("XcodeGen is required to regenerate the workspace after adding a component.", inputs) - actions = ensure_services_surface(root, dry_run=True) + [f"add {args.component_kind} component {args.component_name}", "regenerate the root XcodeGen project"] - if args.dry_run: - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "actions": actions}, indent=2, sort_keys=True)) - return 0 - try: - ensure_services_surface(root) - product = workspace_name(root) - if args.component_kind == "library": - create_library_component(root, args.component_name) - elif args.component_kind == "app": - create_app_component(root, product, args.component_name, args.platform, args.file_prefix, args.org_identifier, args.development_team) - elif args.component_kind == "extension": - create_extension_component(root, args.component_name, args.platform, args.host_target, args.extension_product_type, args.extension_point_identifier, args.org_identifier, args.development_team) - else: - adapter = subprocess.run([str(server_component_runner()), "--repo-root", str(root), "--name", args.component_name, "--framework", args.framework], capture_output=True, text=True, check=False) - if adapter.returncode != 0: - raise RuntimeError(f"server component adapter failed:\n{adapter.stdout}\n{adapter.stderr}") - generated = subprocess.run(["xcodegen", "generate", "--spec", "project.yml"], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"xcodegen generate failed:\n{generated.stderr}") - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "actions": actions, "next_step": "Open the existing root workspace; the new component is part of the same product entrypoint."}, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - return blocked(str(exc), inputs) - if args.operation == "align": - if not args.repo_root: - return blocked("--repo-root is required with --operation align.", inputs) - if not args.dry_run and not shutil.which("xcodegen"): - return blocked("XcodeGen is required to regenerate an aligned workspace.", inputs) - findings = workspace_findings(root, allow_missing_services=True) if root.is_dir() else ["The requested workspace root is not a directory."] - if findings: - return blocked(" ".join(findings), inputs) - try: - actions = ensure_services_surface(root, args.dry_run) + install_alignment_runtime(root, args.dry_run) - if not args.dry_run: - generated = subprocess.run(["xcodegen", "generate", "--spec", "project.yml"], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"xcodegen generate failed:\n{generated.stderr}") - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "actions": actions + ["regenerate the root XcodeGen project"], "next_step": "Run just setup once, then use just align as the single managed-guidance refresh command."}, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - return blocked(str(exc), inputs) - if not args.name: - return blocked("--name is required when creating a new workspace.", inputs) - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", args.name): - return blocked("--name must be an alphanumeric Swift/Xcode identifier beginning with a letter.", inputs) - if not re.fullmatch(r"[A-Z]{3}", args.file_prefix): - return blocked("--file-prefix must contain exactly three uppercase ASCII letters.", inputs) - service_first = args.component_kind == "service" - library_first = args.component_kind == "library" - component_first = service_first or library_first - if component_first: - platforms = [] - inputs["platforms"] = platforms - if service_first and not args.framework: - return blocked("--framework is required when creating a service-first workspace.", inputs) - if (not component_first and not platforms) or any(platform not in SUPPORTED_PLATFORMS for platform in platforms): - return blocked("--platforms must be a comma-separated subset of ios,macos,tvos,watchos,visionos.", inputs) - if root.exists() and (not root.is_dir() or any(root.iterdir())): - return blocked("The product root already contains files; use --operation align --repo-root <existing-root> for a canonical workspace.", inputs) - xcodegen = shutil.which("xcodegen") - if not xcodegen: - return blocked("XcodeGen is required to create the root generated project.", inputs) - actions = ["create one root XcodeGen project", "create Apps/, Packages/, and Services/ component roots", "create Packages/ local Swift package", "create root workspace wrapper"] - if service_first: - actions.append(f"create Services/{args.component_name or args.name + 'API'} with the {args.framework} workspace adapter") - elif library_first: - actions.append(f"create Packages/{args.component_name or args.name + 'Core'} as the first product component") - payload: dict[str, object] = {"status": "success", "path_type": "primary", "workspace_root": str(root), "workspace_path": str(root / f"{args.name}.xcworkspace"), "project_path": str(root / f"{args.name}.xcodeproj"), "normalized_inputs": inputs, "actions": actions} - if args.dry_run: - payload["validation_result"] = "skipped (--dry-run)" - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - root.mkdir(parents=True) - try: - install(root, args.name, args.file_prefix, platforms, args.org_identifier, args.development_team) - if library_first and args.component_name and args.component_name != f"{args.name}Core": - create_library_component(root, args.component_name) - if service_first: - adapter_command = [str(server_component_runner()), "--repo-root", str(root), "--name", args.component_name or f"{args.name}API", "--framework", args.framework] - if args.skip_validation: - adapter_command.append("--skip-validation") - adapter = subprocess.run(adapter_command, capture_output=True, text=True, check=False) - if adapter.returncode != 0: - raise RuntimeError(f"server component adapter failed:\n{adapter.stdout}\n{adapter.stderr}") - generated = subprocess.run([xcodegen, "generate", "--spec", "project.yml"], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"xcodegen generate failed:\n{generated.stderr}") - runner = maintain_project_repo_runner() - maintenance = subprocess.run([str(runner), "--repo-root", str(root), "--operation", "install", "--profile", "xcode-workspace"], capture_output=True, text=True, check=False) - if maintenance.returncode != 0: - raise RuntimeError(f"maintain-project-repo install failed:\n{maintenance.stdout}\n{maintenance.stderr}") - validation = "skipped (--skip-validation)" - if not args.skip_validation: - check = subprocess.run(["xcodebuild", "-list", "-workspace", f"{args.name}.xcworkspace"], cwd=root, capture_output=True, text=True, check=False) - if check.returncode != 0: - raise RuntimeError(f"xcodebuild -list failed:\n{check.stderr}") - validation = "passed (xcodebuild -list -workspace)" - payload["validation_result"] = validation - payload["next_step"] = "Open the root workspace; edit project.yml, included target specs, .xcconfig files, and Package.swift—not generated project data." - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - payload.update(status="failed", stderr=str(exc), next_step="Fix the reported bootstrap prerequisite or generated-spec error and rerun the workflow.") - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/SKILL.md b/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/SKILL.md index c66400d60..5bbc77d91 100644 --- a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/SKILL.md @@ -82,20 +82,13 @@ Guide AVFoundation camera, photo, depth, and computational-capture work while ke - Recommend `xcode-testing-workflow` for fixtures, capability probes, deterministic transforms, and device test plans. - Recommend `explore-apple-swift-docs` for current capture documentation. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/camera-discovery-controls-and-rotation.md` - `references/photo-computational-capture-and-lifecycle.md` - `references/depth-calibration-and-synchronized-capture.md` -- `references/customization-flow.md` - `../../shared/references/apple-camera-capability-contract.md` - `../../shared/references/apple-media-type-ownership.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization-flow.md deleted file mode 100644 index 1af867080..000000000 --- a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Camera Capture and Depth Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/scripts/customization_config.py deleted file mode 100755 index cb0466d4d..000000000 --- a/plugins/apple-dev-skills/skills/camera-capture-depth-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist Camera capture and depth workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "camera-capture-depth-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/SKILL.md b/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/SKILL.md index db8f26ae3..774f15bda 100644 --- a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/SKILL.md +++ b/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/SKILL.md @@ -63,10 +63,6 @@ Choose one boundary from evidence about fidelity, persistence, portability, host - Use `cybersecurity-skills:select-analysis-isolation` and `prepare-isolated-analysis-lab` for untrusted material. - Escalate to a physical Mac with the unresolved gap stated when VM fidelity is insufficient. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [Virtualization shape record](references/virtualization-shape-record.md) diff --git a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization-flow.md b/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization.template.yaml b/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/scripts/customization_config.py b/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/scripts/customization_config.py deleted file mode 100755 index 805ecef72..000000000 --- a/plugins/apple-dev-skills/skills/choose-macos-virtualization-shape/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "choose-macos-virtualization-shape" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/SKILL.md b/plugins/apple-dev-skills/skills/core-animation-layer-workflow/SKILL.md index cab56466b..fcaec307f 100644 --- a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/core-animation-layer-workflow/SKILL.md @@ -114,11 +114,7 @@ It is not the default path for ordinary SwiftUI motion, AppKit/UIKit control ani - Recommend `explore-apple-swift-docs` when the user primarily needs raw Apple documentation lookup. - Recommend `references/snippets/apple-xcode-project-core.md` when repo policy or Xcode project-integrity guidance is needed before applying layer-backed changes. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on layer classification, model/presentation repair, animation primitive choice, and validation handoffs. If future iterations add deterministic layer-tree diagnostics, document those helpers before relying on them. @@ -128,7 +124,6 @@ Keep the first release focused on layer classification, model/presentation repai - `references/layer-ownership-and-animation-rules.md` - `references/model-presentation-and-performance.md` -- `references/customization-flow.md` ### Support References @@ -137,5 +132,3 @@ Keep the first release focused on layer classification, model/presentation repai - Apple documentation anchors to verify include Core Animation Support, `UIView.layer`, `UIView.layerClass`, `NSView.layer`, `NSImage` layer contents, `CALayer`, `CAAnimation`, `CATransaction`, and Core Animation specialized layer types. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization-flow.md deleted file mode 100644 index 878ee6ff2..000000000 --- a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# SwiftUI App Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `swiftui-app-architecture-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first boundary and decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `swiftui-app-architecture-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/core-animation-layer-workflow/scripts/customization_config.py deleted file mode 100755 index 03f219a78..000000000 --- a/plugins/apple-dev-skills/skills/core-animation-layer-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-app-architecture-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/core-image-processing-workflow/SKILL.md b/plugins/apple-dev-skills/skills/core-image-processing-workflow/SKILL.md index 85d01f415..a5768b591 100644 --- a/plugins/apple-dev-skills/skills/core-image-processing-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/core-image-processing-workflow/SKILL.md @@ -89,18 +89,11 @@ Guide Core Image processing and rendering without turning `CIImage` into a bitma - Recommend `xcode-testing-workflow` for fixtures, image comparisons, performance baselines, or regression tests. - Recommend `explore-apple-swift-docs` when documentation lookup is the real need. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/core-image-processing-and-rendering.md` - `references/core-image-diagnostics-and-handoffs.md` -- `references/customization-flow.md` - `../../shared/references/apple-image-type-ownership.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization-flow.md deleted file mode 100644 index 83bdf622e..000000000 --- a/plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Core Image Processing Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/core-image-processing-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/core-image-processing-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/core-image-processing-workflow/scripts/customization_config.py deleted file mode 100755 index 1114fba7d..000000000 --- a/plugins/apple-dev-skills/skills/core-image-processing-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist Core Image workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "core-image-processing-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/SKILL.md b/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/SKILL.md index 4ec0d86b9..0b56c30ef 100644 --- a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/SKILL.md @@ -98,19 +98,12 @@ It is not the default audio recommendation when AVFAudio covers the job. - Recommend `xcode-testing-workflow` for repeatable regression tests around audio conversion, fixtures, or wrapper behavior. - Recommend `explore-apple-swift-docs` when more current or archive docs lookup is the next honest step. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but this workflow defines no runtime-enforced knobs. - ## References ### Workflow References - `references/coreaudio-modernization-and-repair.md` - `references/legacy-archive-boundary.md` -- `references/customization-flow.md` ### Support References @@ -118,5 +111,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project baseline policy for low-level audio apps. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/scripts/customization_config.py deleted file mode 100755 index 3a5602b1b..000000000 --- a/plugins/apple-dev-skills/skills/coreaudio-modernization-repair-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "coreaudio-modernization-repair-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/SKILL.md b/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/SKILL.md index 6e725486a..d547c8c53 100644 --- a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/SKILL.md @@ -102,19 +102,12 @@ It is not the AVFoundation pipeline owner, not the AVAudioEngine graph owner, an - Recommend `xcode-testing-workflow` for repeatable sample fixtures and timing regression tests. - Recommend `explore-apple-swift-docs` when more docs lookup is the next honest step. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but this workflow defines no runtime-enforced knobs. - ## References ### Workflow References - `references/time-samplebuffer-and-repair.md` - `references/diagnostics-and-handoffs.md` -- `references/customization-flow.md` ### Support References @@ -122,5 +115,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project baseline policy for sample-buffer apps. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/scripts/customization_config.py deleted file mode 100755 index 4ab6cbdf3..000000000 --- a/plugins/apple-dev-skills/skills/coremedia-timing-samplebuffer-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "coremedia-timing-samplebuffer-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/SKILL.md b/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/SKILL.md index ebfbaa261..17f460bde 100644 --- a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/SKILL.md @@ -124,11 +124,7 @@ The practical decision is whether the app needs DeviceCheck two-bit device state - Recommend the broader client auth and app-sync workflow when the task is Keychain storage, Sign in with Apple, `ASWebAuthenticationSession`, token refresh, logout, multi-account state, offline edits, or sync conflict handling. - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project policy for a repo that will own DeviceCheck capabilities, entitlements, signing, and target membership. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on DeviceCheck/App Attest classification, docs grounding, and handoffs. If future iterations add deterministic checks for entitlements, server validation fixtures, or rollout policy, document the knobs before runtime behavior depends on them. @@ -140,7 +136,6 @@ Keep the first release focused on DeviceCheck/App Attest classification, docs gr - `references/app-attest-client-flow.md` - `references/app-attest-server-validation.md` - `references/entitlements-app-id-and-validation.md` -- `references/customization-flow.md` ### Support References @@ -151,5 +146,3 @@ Keep the first release focused on DeviceCheck/App Attest classification, docs gr - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode project guidance for DeviceCheck and App Attest capability work. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization-flow.md deleted file mode 100644 index b975b2835..000000000 --- a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# DeviceCheck App Attest Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `devicecheck-app-attest-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first DeviceCheck and App Attest decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `devicecheck-app-attest-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/scripts/customization_config.py deleted file mode 100755 index 58ca0aeb5..000000000 --- a/plugins/apple-dev-skills/skills/devicecheck-app-attest-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "devicecheck-app-attest-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/SKILL.md b/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/SKILL.md index ba3f92cc2..37aa13636 100644 --- a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/SKILL.md +++ b/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/SKILL.md @@ -58,10 +58,6 @@ Trace one desired behavior through tracked project source, developer-account/pro - Use `macos-privacy-permissions-workflow` for user or managed privacy authorization. - Use `audit-apple-signing-and-containment` for forensic artifact audit and `research-macos-security-control` for private entitlement or exact-build enforcement research. -## Customization - -Use `references/customization-flow.md`. The workflow has no runtime knobs; comparison states and evidence levels may not be skipped. - ## References - `references/five-state-entitlement-comparison.md` diff --git a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization-flow.md b/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization-flow.md deleted file mode 100644 index 11717804d..000000000 --- a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Diagnose Apple Entitlements Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; the five-state comparison and final-artifact validation remain mandatory. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization.template.yaml b/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/scripts/customization_config.py b/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/scripts/customization_config.py deleted file mode 100755 index 158abb46b..000000000 --- a/plugins/apple-dev-skills/skills/diagnose-apple-entitlements/scripts/customization_config.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist entitlement-diagnosis customization state.""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "diagnose-apple-entitlements" -ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def read(path: Path, required: bool = False) -> dict: - if not path.exists(): - if required: - fail(f"Missing YAML file: {path}") - return {} - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return value - - -def validate(value: dict, partial: bool = False) -> None: - if set(value) - KEYS: - fail(f"Unknown top-level keys: {', '.join(sorted(set(value) - KEYS))}") - if not partial and set(value) != KEYS: - fail("State must define schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - for key, item in value["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(item, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def destination() -> Path: - root = Path(os.environ.get("APPLE_DEV_SKILLS_CONFIG_HOME", ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def current() -> dict: - template = read(Path(__file__).resolve().parents[1] / "references/customization.template.yaml", True) - saved = read(destination()) - validate(template) - if saved: - validate(saved) - return {"schemaVersion": 1, "isCustomized": saved.get("isCustomized", False), "settings": {**template["settings"], **saved.get("settings", {})}} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage entitlement-diagnosis customization") - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("path") - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - target = destination() - if args.command == "path": - print(target) - elif args.command == "effective": - print(yaml.safe_dump(current(), sort_keys=False), end="") - elif args.command == "reset": - if target.exists(): - target.unlink() - print(target) - else: - incoming = read(Path(args.input), True) - validate(incoming, partial=True) - updated = {"schemaVersion": 1, "isCustomized": True, "settings": {**current()["settings"], **incoming.get("settings", {})}} - validate(updated) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(yaml.safe_dump(updated, sort_keys=False), encoding="utf-8") - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/SKILL.md b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/SKILL.md index dbe5c8fa8..bc90469ec 100644 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/SKILL.md +++ b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/SKILL.md @@ -7,7 +7,7 @@ description: Explore Apple and Swift documentation across Xcode MCP docs, Dash, ## Purpose -Explore Apple and Swift documentation through one top-level entry point. Prefer direct docs access methods in this order: Xcode MCP `DocumentationSearch` first, Dash.app MCP second, Dash localhost HTTP only when the Dash.app MCP is unavailable or incomplete, then checked-out source, generated DocC, GitHub/source repositories, or release notes, and finally readable online documentation. `scripts/run_workflow.py` remains a maintainer helper for structured dry runs, fallback planning, and Dash follow-up automation, but it is not the primary way the agent should perform ordinary Apple or Swift docs lookup. +Explore Apple and Swift documentation through one top-level entry point. Prefer direct docs access methods in this order: Xcode MCP `DocumentationSearch` first, Dash.app MCP second, Dash localhost HTTP only when the Dash.app MCP is unavailable or incomplete, then checked-out source, generated DocC, GitHub/source repositories, or release notes, and finally readable online documentation. `scripts/run-workflow.fsx` remains a maintainer helper for structured dry runs, fallback planning, and Dash follow-up automation, but it is not the primary way the agent should perform ordinary Apple or Swift docs lookup. ## When To Use @@ -32,7 +32,7 @@ Explore Apple and Swift documentation through one top-level entry point. Prefer - `dash-http`: use the documented Dash localhost HTTP structure directly when Dash MCP is unavailable or incomplete - `source-repo`: use GitHub/source repositories, generated DocC, release notes, or checked-out source when the request is about open source Swift projects, tools, or packages - `official-web`: use official Apple or Swift web docs when the local-docs and source-repo paths are unavailable, the user explicitly prefers the web source, and the page content is actually readable through the available tool -4. Use `scripts/run_workflow.py` only when a structured non-interactive planning result is useful, or when the request is specifically about `dash-install` or `dash-generate` follow-up behavior. +4. Use `scripts/run-workflow.fsx` only when a structured non-interactive planning result is useful, or when the request is specifically about `dash-install` or `dash-generate` follow-up behavior. 5. If the selected mode cannot complete, hand off forward through one clear next step: - `explore -> dash-install` - `dash-install -> dash-generate` @@ -52,7 +52,7 @@ Explore Apple and Swift documentation through one top-level entry point. Prefer - Dash install source priority is `built-in,user-contributed,cheatsheet` - default search result limit is `20` - default search snippets setting is `true` - - maintainer helper entrypoint: executable `scripts/run_workflow.py` + - maintainer helper entrypoint: executable `scripts/run-workflow.fsx` ## Outputs @@ -75,7 +75,7 @@ Explore Apple and Swift documentation through one top-level entry point. Prefer - Do not run Dash install actions without explicit user approval. - Do not invent Apple or Swift doc sources, Dash identifiers, or catalog matches. -- Do not present `scripts/run_workflow.py` as the required first step for ordinary Apple or Swift docs lookup when direct Xcode MCP or Dash MCP/HTTP access is available. +- Do not present `scripts/run-workflow.fsx` as the required first step for ordinary Apple or Swift docs lookup when direct Xcode MCP or Dash MCP/HTTP access is available. - Do not treat generic no-JS web search or no-JS page extraction as a readable source for Apple Developer documentation. Apple Developer pages often require JavaScript-rendered payloads; if the content cannot be read through Xcode MCP, Dash, source repositories, generated docs, or a capable browser/source path, say that plainly instead of claiming the docs were checked. - Do not cite an Apple Developer URL as evidence unless the relevant documentation text was actually read through a usable source. A URL alone is only a citation target, not proof that the guidance was verified. - Stop with `blocked` when `explore` has no usable docs source after applying the documented fallback order. @@ -91,13 +91,7 @@ Explore Apple and Swift documentation through one top-level entry point. Prefer - Recommend `xcode-build-run-workflow` directly when the user’s task shifts from docs exploration to Apple or Swift build, run, diagnostics, toolchain, or mutation work. - Recommend `xcode-testing-workflow` directly when the user’s task shifts from docs exploration to Apple or Swift test work. - Recommend `bootstrap-xcode-workspace` directly when the user needs a new native product scaffold or existing-workspace alignment. -- `scripts/run_workflow.py` is the shared local helper for structured planning, install gating, and follow-up behavior; helper scripts remain implementation details behind it. - -## Customization - -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` loads and enforces the runtime-safe knobs documented in `references/customization-flow.md`. +- `scripts/run-workflow.fsx` is the shared local helper for structured planning, install gating, and follow-up behavior; helper scripts remain implementation details behind it. ## References @@ -117,7 +111,6 @@ Explore Apple and Swift documentation through one top-level entry point. Prefer - `references/stage-handoff-contract.md` - `references/automation-prompts.md` -- `references/customization-flow.md` ### Support References @@ -130,9 +123,5 @@ Explore Apple and Swift documentation through one top-level entry point. Prefer ### Script Inventory - These are maintainer helpers behind the public docs workflow, not the primary lookup path for ordinary Apple or Swift docs exploration. -- `scripts/run_workflow.py` -- `scripts/dash_api_probe.py` -- `scripts/dash_catalog_match.py` -- `scripts/dash_catalog_refresh.py` -- `scripts/dash_url_search.py` -- `scripts/dash_url_install.py` +- `scripts/run-workflow.fsx` +- `scripts/run-workflow.fsx`: fixed-source-order planning and confirmation-gated Dash follow-up. diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/automation-prompts.md b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/automation-prompts.md index a2487e9cf..df17e3b58 100644 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/automation-prompts.md +++ b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/automation-prompts.md @@ -58,7 +58,7 @@ Execution order: 2) If no mode is explicit, start with `explore`. 3) For `explore`, use the documented direct docs path first: Xcode MCP docs, then Dash MCP, then Dash localhost HTTP, then source repositories or generated docs when applicable, then readable official web docs. 4) Use the documented fallback order only if the primary source is unavailable. -5) Use `scripts/run_workflow.py` only when a structured helper result is useful or when the mode is `dash-install` or `dash-generate`. +5) Use `scripts/run-workflow.fsx` only when a structured helper result is useful or when the mode is `dash-install` or `dash-generate`. 6) When the next action belongs to the next mode, return a `handoff` output instead of mixing workflows. Behavior: diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization-flow.md b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization-flow.md deleted file mode 100644 index bfcbffd15..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization-flow.md +++ /dev/null @@ -1,34 +0,0 @@ -# Apple Swift Docs Customization Contract - -## Purpose - -Tune the runtime-supported defaults for Apple and Swift docs exploration, source fallback, and subordinate Dash follow-up behavior. - -## Knobs - -| Knob | Default | Status | Effect | -| --- | --- | --- | --- | -| `defaultSourceOrder` | `xcode-mcp-docs,dash,dash-http,source-repo,official-web` | `runtime-enforced` | Controls the default docs-source order used by `scripts/run_workflow.py` for `explore` mode. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the effective merged customization state at runtime. -- The public `explore` workflow should still use direct Xcode MCP, Dash MCP, Dash localhost HTTP, source-repository, and generated-docs access first; `scripts/run_workflow.py` remains a maintainer helper for structured planning and Dash follow-up automation. -- Match count, snippet shaping, Dash install source priority, install approval gating, Dash generation policy, and blocked-state troubleshooting posture now live as workflow defaults rather than ordinary durable user customization. -- Helper scripts remain implementation details behind `scripts/run_workflow.py`. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md`, `references/automation-prompts.md`, and the affected workflow references. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify the runtime output with `scripts/run_workflow.py --mode explore --query Swift --dry-run`. - -## Validation - -1. Run `scripts/dash_api_probe.py`. -2. Run `scripts/dash_catalog_match.py --query "swift"`. -3. Verify the docs still present one primary Apple and Swift docs workflow with subordinate Dash follow-up. -4. Verify `scripts/run_workflow.py` reflects the runtime-enforced knobs above. diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization.template.yaml b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization.template.yaml deleted file mode 100644 index f6a2bb2a1..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/customization.template.yaml +++ /dev/null @@ -1,4 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - defaultSourceOrder: "xcode-mcp-docs,dash,dash-http,source-repo,official-web" diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_http_api.md b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_http_api.md index 3c3b749e3..365c3cd13 100644 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_http_api.md +++ b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_http_api.md @@ -8,7 +8,7 @@ Dash exposes a localhost API server when integration is enabled. - The file contains JSON with a `port` key. - Build `base_url` as `http://127.0.0.1:{port}`. -Use `scripts/dash_api_probe.py` to produce: +Use `scripts/run-workflow.fsx` to produce: ```json { diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_url_and_service.md b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_url_and_service.md index 35b4fb72d..1d6bccf4e 100644 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_url_and_service.md +++ b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/references/dash_url_and_service.md @@ -53,4 +53,4 @@ Observed install query keys: - `entry_name` - `version` (optional) -Use `scripts/dash_url_install.py` with confirmation-first behavior. +Use `scripts/run-workflow.fsx --mode dash-install` with confirmation-first behavior. diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/customization_config.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/customization_config.py deleted file mode 100755 index ead8899e3..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "explore-apple-swift-docs" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_api_probe.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_api_probe.py deleted file mode 100755 index 737d098d3..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_api_probe.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Probe local Dash API availability and schema.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any -from urllib.error import URLError -from urllib.request import urlopen - - -STATUS_FILE = ( - Path.home() - / "Library" - / "Application Support" - / "Dash" - / ".dash_api_server" - / "status.json" -) - - -def _read_json_url(url: str, timeout: float = 2.0) -> tuple[bool, Any]: - try: - with urlopen(url, timeout=timeout) as response: - return True, json.loads(response.read().decode("utf-8")) - except (OSError, URLError, json.JSONDecodeError): - return False, None - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--status-file", - default=str(STATUS_FILE), - help="Path to Dash status.json file", - ) - parser.add_argument( - "--timeout", - type=float, - default=2.0, - help="HTTP timeout in seconds for Dash API probes", - ) - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - - status_file = Path(args.status_file).expanduser() - result: dict[str, Any] = { - "status_file_port": None, - "health_ok": False, - "schema_ok": False, - "base_url": None, - "schema_paths": [], - } - - try: - status_data = json.loads(status_file.read_text(encoding="utf-8")) - if isinstance(status_data, dict) and isinstance(status_data.get("health_ok"), bool): - result["health_ok"] = status_data["health_ok"] - if isinstance(status_data, dict) and isinstance(status_data.get("schema_ok"), bool): - result["schema_ok"] = status_data["schema_ok"] - if result["health_ok"] and result["schema_ok"]: - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - port = status_data.get("port") - if isinstance(port, int): - result["status_file_port"] = port - result["base_url"] = f"http://127.0.0.1:{port}" - except (FileNotFoundError, json.JSONDecodeError, OSError): - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - base_url = result["base_url"] - ok_health, health = _read_json_url(f"{base_url}/health", timeout=args.timeout) - if ok_health and isinstance(health, dict) and health.get("status") == "ok": - result["health_ok"] = True - - ok_schema, schema = _read_json_url(f"{base_url}/schema", timeout=args.timeout) - if ok_schema and isinstance(schema, dict): - result["schema_ok"] = True - paths = schema.get("paths", {}) - if isinstance(paths, dict): - result["schema_paths"] = sorted(paths.keys()) - - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_match.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_match.py deleted file mode 100755 index f384e15c6..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_match.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Match a query against Dash built-in, contributed, and cheatsheet catalogs.""" - -from __future__ import annotations - -import argparse -import json -from difflib import SequenceMatcher -from pathlib import Path -from typing import Any - - -def _references_dir() -> Path: - return Path(__file__).resolve().parents[1] / "references" - - -def _load_entries(filename: str) -> list[dict[str, Any]]: - path = _references_dir() / filename - if not path.exists(): - return [] - data = json.loads(path.read_text(encoding="utf-8")) - entries = data.get("entries", []) - return entries if isinstance(entries, list) else [] - - -def _score(query: str, name: str, slug: str) -> float: - q = query.lower().strip() - n = name.lower() - s = slug.lower() - if not q: - return 0.0 - if q == n or q == s: - return 1.0 - - score = 0.0 - if q in n: - score += 0.65 - if q in s: - score += 0.5 - - q_tokens = [token for token in q.split() if token] - if q_tokens: - overlap = sum(1 for token in q_tokens if token in n or token in s) - score += 0.2 * (overlap / len(q_tokens)) - - ratio = max(SequenceMatcher(None, q, n).ratio(), SequenceMatcher(None, q, s).ratio()) - score += 0.35 * ratio - return min(score, 1.0) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--query", required=True, help="Docset or cheatsheet query text") - parser.add_argument("--limit", type=int, default=20, help="Max matches to return") - args = parser.parse_args() - - entries: list[dict[str, Any]] = [] - for source, filename in ( - ("built_in", "catalog_built_in_docsets.json"), - ("user_contributed", "catalog_user_contrib_docsets.json"), - ("cheatsheet", "catalog_cheatsheets.json"), - ): - for item in _load_entries(filename): - entry = dict(item) - entry["source"] = source - entries.append(entry) - - ranked = [] - for item in entries: - name = str(item.get("name", "")) - slug = str(item.get("slug", "")) - sc = _score(args.query, name, slug) - if sc < 0.2: - continue - ranked.append( - { - "name": name, - "slug": slug, - "source": item.get("source"), - "score": round(sc, 4), - "hint": ( - "install from Dash Downloads" - if item.get("source") == "built_in" - else "install from User Contributed" - if item.get("source") == "user_contributed" - else "install from Cheat Sheets" - ), - } - ) - - ranked.sort(key=lambda row: row["score"], reverse=True) - result = { - "query": args.query, - "count": len(ranked[: args.limit]), - "matches": ranked[: args.limit], - } - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_refresh.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_refresh.py deleted file mode 100755 index c8869e99d..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_catalog_refresh.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Refresh Dash catalog snapshots from Kapeli sources.""" - -from __future__ import annotations - -import argparse -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from urllib.error import URLError -from urllib.request import Request, urlopen - - -GITHUB_API_FEEDS = "https://api.github.com/repos/Kapeli/feeds/contents?ref=master" -GITHUB_API_CONTRIB = ( - "https://api.github.com/repos/Kapeli/Dash-User-Contributions/contents/docsets?ref=master" -) -GITHUB_API_CHEATS = ( - "https://api.github.com/repos/Kapeli/cheatsheets/contents/cheatsheets?ref=master" -) - - -USER_AGENT = "dash-skill-catalog-refresh/1.0" - - -def _fetch_json(url: str) -> Any: - req = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) - with urlopen(req, timeout=20) as response: - return json.loads(response.read().decode("utf-8")) - - -def _humanize(name: str) -> str: - return name.replace("_", " ").strip() - - -def _references_dir() -> Path: - return Path(__file__).resolve().parents[1] / "references" - - -def _write_catalog(path: Path, entries: list[dict[str, Any]], source_url: str) -> None: - payload = { - "refreshed_at": datetime.now(timezone.utc).isoformat(), - "source_url": source_url, - "count": len(entries), - "entries": entries, - } - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def _refresh(force: bool) -> dict[str, Any]: - refs = _references_dir() - refs.mkdir(parents=True, exist_ok=True) - - built_in_path = refs / "catalog_built_in_docsets.json" - contrib_path = refs / "catalog_user_contrib_docsets.json" - cheats_path = refs / "catalog_cheatsheets.json" - - status: dict[str, Any] = { - "built_in": {"updated": False, "count": 0, "error": None}, - "user_contrib": {"updated": False, "count": 0, "error": None}, - "cheatsheets": {"updated": False, "count": 0, "error": None}, - } - - try: - feed_items = _fetch_json(GITHUB_API_FEEDS) - built_in_entries: list[dict[str, Any]] = [] - for item in feed_items: - name = item.get("name", "") - if item.get("type") != "file" or not name.endswith(".xml"): - continue - slug = name[:-4] - built_in_entries.append( - { - "name": _humanize(slug), - "slug": slug, - "feed_xml_url": item.get("download_url"), - "source": "built_in", - } - ) - built_in_entries.sort(key=lambda x: x["name"].lower()) - _write_catalog(built_in_path, built_in_entries, GITHUB_API_FEEDS) - status["built_in"]["updated"] = True - status["built_in"]["count"] = len(built_in_entries) - except (OSError, URLError, json.JSONDecodeError) as exc: - status["built_in"]["error"] = str(exc) - if force and not built_in_path.exists(): - raise - - try: - contrib_items = _fetch_json(GITHUB_API_CONTRIB) - contrib_entries: list[dict[str, Any]] = [] - for item in contrib_items: - if item.get("type") != "dir": - continue - slug = item.get("name", "").strip() - if not slug: - continue - contrib_entries.append( - { - "name": _humanize(slug), - "slug": slug, - "repo_path": item.get("path"), - "html_url": item.get("html_url"), - "source": "user_contributed", - } - ) - contrib_entries.sort(key=lambda x: x["name"].lower()) - _write_catalog(contrib_path, contrib_entries, GITHUB_API_CONTRIB) - status["user_contrib"]["updated"] = True - status["user_contrib"]["count"] = len(contrib_entries) - except (OSError, URLError, json.JSONDecodeError) as exc: - status["user_contrib"]["error"] = str(exc) - if force and not contrib_path.exists(): - raise - - try: - cheat_items = _fetch_json(GITHUB_API_CHEATS) - cheat_entries: list[dict[str, Any]] = [] - for item in cheat_items: - name = item.get("name", "") - if item.get("type") != "file" or not name.endswith(".rb"): - continue - slug = name[:-3] - cheat_entries.append( - { - "name": _humanize(slug), - "slug": slug, - "definition_url": item.get("html_url"), - "source": "cheatsheet", - } - ) - cheat_entries.sort(key=lambda x: x["name"].lower()) - _write_catalog(cheats_path, cheat_entries, GITHUB_API_CHEATS) - status["cheatsheets"]["updated"] = True - status["cheatsheets"]["count"] = len(cheat_entries) - except (OSError, URLError, json.JSONDecodeError) as exc: - status["cheatsheets"]["error"] = str(exc) - if force and not cheats_path.exists(): - raise - - return status - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--force", - action="store_true", - help="Fail if refresh cannot produce missing catalogs.", - ) - args = parser.parse_args() - - status = _refresh(force=args.force) - print(json.dumps(status, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_install.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_install.py deleted file mode 100755 index ab8d92fdd..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_install.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Launch Dash docset install URL after confirmation.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -from urllib.parse import urlencode - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-name", required=True, help="Dash repo name, e.g. Main Docsets") - parser.add_argument("--entry-name", required=True, help="Entry name, e.g. Rust") - parser.add_argument("--version", help="Optional version") - parser.add_argument("--yes", action="store_true", help="Skip interactive confirmation") - parser.add_argument("--dry-run", action="store_true", help="Print URL without opening it") - args = parser.parse_args() - - params = {"repo_name": args.repo_name, "entry_name": args.entry_name} - if args.version: - params["version"] = args.version - url = "dash-install://?" + urlencode(params) - - result = {"url": url, "launched": False, "returncode": None, "confirmed": args.yes} - if args.dry_run: - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - if not args.yes: - answer = input(f"Launch Dash install URL?\n{url}\n[y/N]: ").strip().lower() - result["confirmed"] = answer in {"y", "yes"} - if not result["confirmed"]: - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - proc = subprocess.run(["open", url], check=False) - result["launched"] = proc.returncode == 0 - result["returncode"] = proc.returncode - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_search.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_search.py deleted file mode 100755 index 240c96dcc..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/dash_url_search.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Launch a Dash search through the dash:// URL scheme.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -from urllib.parse import quote - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--query", required=True, help="Search query") - parser.add_argument("--keyword", help="Optional Dash keyword prefix, e.g. python") - parser.add_argument("--dry-run", action="store_true", help="Print URL without opening it") - args = parser.parse_args() - - query_text = f"{args.keyword}:{args.query}" if args.keyword else args.query - url = f"dash://?query={quote(query_text)}" - - result = {"url": url, "launched": False, "returncode": None} - if not args.dry_run: - proc = subprocess.run(["open", url], check=False) - result["launched"] = proc.returncode == 0 - result["returncode"] = proc.returncode - - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run-workflow.fsx new file mode 100644 index 000000000..d296c1730 --- /dev/null +++ b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run-workflow.fsx @@ -0,0 +1,41 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let has flag = args |> Array.contains flag +let value flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let mode = value "--mode" |> Option.defaultValue "explore" +let query = value "--query" +let request = value "--docset-request" +let preferred = value "--preferred-source" |> Option.defaultValue "auto" +let mcpFailed = value "--mcp-failure-reason" |> Option.isSome +let source = + if preferred <> "auto" then Some preferred + elif not mcpFailed then Some "xcode-mcp-docs" + else Some "dash" +let blocked = mode = "explore" && query.IsNone || mode <> "explore" && request.IsNone || mode = "dash-install" && not (has "--yes" || has "--dry-run") +let next = + if mode = "explore" && query.IsNone then "Provide --query." + elif mode <> "explore" && request.IsNone then "Provide --docset-request." + elif mode = "dash-install" && not (has "--yes" || has "--dry-run") then "Rerun with --yes to authorize the Dash install side effect." + elif mode = "dash-install" then "Install the selected catalog match in Dash, then return to explore mode." + elif mode = "dash-generate" then "Generate a deterministic Dash docset only after confirming no existing source is available." + elif source = Some "xcode-mcp-docs" then "Use Xcode MCP DocumentationSearch first." + elif source = Some "dash" then "Use Dash MCP, then its localhost HTTP API if MCP is unavailable." + elif source = Some "source-repo" then "Use checked-out source, generated DocC, or the canonical source repository." + else "Use readable official Apple or Swift documentation." +let payload = + {| status = if blocked then "blocked" else "success" + mode = mode + query = query + docset_request = request + source_used = source + source_order = [| "xcode-mcp-docs"; "dash"; "dash-http"; "source-repo"; "official-web" |] + policy = {| customization = "fixed"; install_requires_yes = true; snippets_are_not_evidence = true |} + dry_run = has "--dry-run" + next_step = next |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blocked then exit 1 diff --git a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run_workflow.py deleted file mode 100755 index a04a0a268..000000000 --- a/plugins/apple-dev-skills/skills/explore-apple-swift-docs/scripts/run_workflow.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Unified runtime entrypoint for explore-apple-swift-docs.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -import customization_config - - -VALID_MODES = {"explore", "dash-install", "dash-generate"} -DASH_INSTALL_REPO_NAME = { - "built_in": "Main Docsets", - "user_contributed": "User Contributed Docsets", - "cheatsheet": "Cheat Sheets", -} -VALID_SOURCES = {"xcode-mcp-docs", "dash", "dash-http", "source-repo", "official-web"} - - -def load_effective_config() -> dict: - return customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - -def split_csv(raw: str) -> list[str]: - return [item.strip() for item in raw.split(",") if item.strip()] - - -def normalize_source_order(raw: str) -> list[str]: - normalized = [item for item in split_csv(raw) if item in VALID_SOURCES] - return normalized or ["xcode-mcp-docs", "dash", "dash-http", "source-repo", "official-web"] - - -def run_json_script(script_name: str, args: list[str]) -> dict: - script_path = Path(__file__).with_name(script_name) - proc = subprocess.run( - [sys.executable, str(script_path), *args], - capture_output=True, - text=True, - check=False, - ) - try: - payload = json.loads(proc.stdout or "{}") - except json.JSONDecodeError: - payload = {} - payload["_returncode"] = proc.returncode - payload["_stderr"] = proc.stderr - return payload - - -def load_matches(query: str, limit: int) -> list[dict]: - payload = run_json_script("dash_catalog_match.py", ["--query", query, "--limit", str(limit)]) - return payload.get("matches", []) if isinstance(payload.get("matches"), list) else [] - - -def shape_matches(matches: list[dict], include_snippets: bool) -> list[dict]: - if include_snippets: - return matches - trimmed: list[dict] = [] - for match in matches: - trimmed.append( - { - "name": match.get("name"), - "slug": match.get("slug"), - "source": match.get("source"), - } - ) - return trimmed - - -def probe_dash(status_file: str | None) -> dict: - probe_args: list[str] = [] - if status_file: - probe_args.extend(["--status-file", status_file]) - return run_json_script("dash_api_probe.py", probe_args) - - -def dash_available(probe: dict) -> bool: - return bool(probe.get("health_ok")) and bool(probe.get("schema_ok")) - - -def source_repo_applicable(query: str | None) -> bool: - if not query: - return False - normalized = query.lower() - stripped = normalized.strip() - if stripped in {"swift", "swift language", "the swift programming language"}: - return True - source_backed_terms = { - "swift package", - "swift package manager", - "swiftpm", - "package.swift", - "packagedescription", - "swift-format", - "swiftlint", - "swift-nio", - "swiftnio", - "vapor", - "hummingbird", - "swift-configuration", - "swift-async-algorithms", - } - return any(term in normalized for term in source_backed_terms) - - -def select_source( - order: list[str], - preferred_source: str, - mcp_failure_reason: str | None, - dash_probe: dict, - query: str | None = None, -) -> tuple[str | None, list[str]]: - if preferred_source != "auto": - preferred = preferred_source - if preferred == "xcode-mcp-docs": - if not mcp_failure_reason: - return preferred, order - elif preferred == "dash": - if dash_available(dash_probe): - return preferred, order - elif preferred == "dash-http": - if dash_available(dash_probe): - return preferred, order - elif preferred == "source-repo": - return preferred, order - elif preferred == "official-web": - return preferred, order - - for source in order: - if source == "xcode-mcp-docs": - if not mcp_failure_reason: - return source, order - continue - if source in {"dash", "dash-http"}: - if dash_available(dash_probe): - return source, order - continue - if source == "source-repo": - if not source_repo_applicable(query): - continue - return source, order - if source == "official-web": - return source, order - return None, order - - -def choose_match(matches: list[dict], source_priority: list[str]) -> dict | None: - for source in source_priority: - for match in matches: - if match.get("source") == source: - return match - return matches[0] if matches else None - - -def explore_mode(args: argparse.Namespace, settings: dict) -> tuple[int, dict]: - if not args.query: - return 1, { - "status": "blocked", - "path_type": "primary", - "mode": "explore", - "source_used": None, - "configured_order": [], - "matches": [], - "next_step": "Provide --query for docs exploration.", - } - - order = normalize_source_order(str(settings.get("defaultSourceOrder", "xcode-mcp-docs,dash,dash-http,source-repo,official-web"))) - preferred_source = args.preferred_source or "auto" - include_snippets = True - raw_matches = load_matches(args.query, 20) - matches = shape_matches(raw_matches, include_snippets) - dash_probe = probe_dash(args.status_file) - selected_source, configured_order = select_source( - order, - preferred_source, - args.mcp_failure_reason, - dash_probe, - args.query, - ) - - if not selected_source: - troubleshooting_preference = "xcode-mcp-first" - if troubleshooting_preference == "dash-first": - next_step = "No usable Apple or Swift docs source is available. Recover Dash access first, then fall back through source repositories and readable official web docs." - else: - next_step = "No usable Apple or Swift docs source is available. Recover Xcode MCP docs first, then fall back through Dash, source repositories, and readable official web docs." - return 1, { - "status": "blocked", - "path_type": "fallback", - "mode": "explore", - "source_used": None, - "configured_order": configured_order, - "matches": matches, - "dash_probe": dash_probe, - "search_snippets_enabled": include_snippets, - "troubleshooting_preference": troubleshooting_preference, - "next_step": next_step, - } - - path_type = "primary" if selected_source == configured_order[0] and preferred_source in {"", "auto"} else "fallback" - if preferred_source not in {"", "auto"} and selected_source == preferred_source: - path_type = "primary" - - if selected_source == "xcode-mcp-docs": - next_step = "Use Xcode MCP docs tools first for the Apple or Swift lookup." - elif selected_source == "dash": - next_step = ( - "Use Dash for the Apple or Swift lookup. If the needed docset is missing, rerun with --mode dash-install." - ) - elif selected_source == "dash-http": - next_step = "Use the Dash localhost HTTP API for the Apple or Swift lookup." - elif selected_source == "source-repo": - next_step = "Use the relevant GitHub/source repository, generated DocC, release notes, or checked-out source for the Apple or Swift lookup." - else: - next_step = "Use readable official Apple or Swift web docs for the lookup; do not rely on no-JS search snippets or bare URLs as evidence." - - return 0, { - "status": "success", - "path_type": path_type, - "mode": "explore", - "source_used": selected_source, - "configured_order": configured_order, - "preferred_source": preferred_source, - "docs_kind": args.docs_kind or "search", - "matches": matches, - "dash_probe": dash_probe, - "search_snippets_enabled": include_snippets, - "next_step": next_step, - } - - -def dash_install_mode(args: argparse.Namespace, settings: dict) -> tuple[int, dict]: - if not args.docset_request: - return 1, { - "status": "blocked", - "path_type": "primary", - "mode": "dash-install", - "source_used": "dash", - "source_path": None, - "matches": [], - "next_step": "Provide --docset-request for the Dash install follow-up.", - } - - matches = load_matches(args.docset_request, 20) - source_priority = split_csv("built-in,user-contributed,cheatsheet") - normalized_priority = [item.replace("-", "_") for item in source_priority] - selected = choose_match(matches, normalized_priority) - if not selected: - return 0, { - "status": "handoff", - "path_type": "primary", - "mode": "dash-install", - "source_used": "dash", - "source_path": None, - "matches": matches, - "next_step": "No installable Dash catalog match was found. Hand off to dash-generate.", - } - - approval_required = True - approved = bool(args.yes) or not approval_required or args.dry_run - source = str(selected.get("source", "built_in")) - repo_name = DASH_INSTALL_REPO_NAME.get(source, "Main Docsets") - install_result = run_json_script( - "dash_url_install.py", - [ - "--repo-name", - repo_name, - "--entry-name", - str(selected.get("name", args.docset_request)), - *(["--yes"] if approved and not args.dry_run else []), - *(["--dry-run"] if args.dry_run else []), - ], - ) - - if approval_required and not args.dry_run and not args.yes: - return 1, { - "status": "blocked", - "path_type": "primary", - "mode": "dash-install", - "source_used": "dash", - "source_path": source, - "matches": matches, - "selected_match": selected, - "next_step": "Rerun with --yes to allow Dash install side effects.", - } - - return 0, { - "status": "success", - "path_type": "primary", - "mode": "dash-install", - "source_used": "dash", - "source_path": source, - "matches": matches, - "selected_match": selected, - "install_result": install_result, - "next_step": "Return to explore mode after installation if you still need docs lookup results.", - } - - -def dash_generate_mode(args: argparse.Namespace, settings: dict) -> tuple[int, dict]: - if not args.docset_request: - return 1, { - "status": "blocked", - "path_type": "primary", - "mode": "dash-generate", - "source_used": "dash", - "source_path": None, - "matches": [], - "next_step": "Provide --docset-request for the Dash generation follow-up.", - } - - matches = load_matches(args.docset_request, 20) - generation_policy = "automate-stable" - guidance = { - "policy": generation_policy, - "automation_first": generation_policy == "automate-stable", - "steps": [ - "Confirm the missing Apple or Swift docs surface is not already available through Xcode MCP docs, source repositories, generated docs, or readable official web docs.", - "Check whether an existing Dash-compatible docset source already exists before generating anything new.", - "Prefer stable automated generation only when the docs source is durable and repeatable.", - "Fall back to deterministic manual docset guidance when stable automation is unavailable.", - ], - } - return 0, { - "status": "success", - "path_type": "primary" if generation_policy == "automate-stable" else "fallback", - "mode": "dash-generate", - "source_used": "dash", - "source_path": "automation-guidance", - "matches": matches, - "guidance": guidance, - "next_step": "Use this guidance only if the user explicitly wants Dash coverage for the missing docs source.", - } - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--mode", default="explore", choices=sorted(VALID_MODES)) - parser.add_argument("--query") - parser.add_argument("--docs-kind") - parser.add_argument( - "--preferred-source", - choices=["auto", "xcode-mcp-docs", "dash", "dash-http", "source-repo", "official-web"], - default="auto", - ) - parser.add_argument("--docset-request") - parser.add_argument("--mcp-failure-reason") - parser.add_argument("--status-file") - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--yes", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - config = load_effective_config() - settings = config["settings"] - - if args.mode == "explore": - code, payload = explore_mode(args, settings) - elif args.mode == "dash-install": - code, payload = dash_install_mode(args, settings) - else: - code, payload = dash_generate_mode(args, settings) - - payload["dry_run"] = args.dry_run - print(json.dumps(payload, indent=2, sort_keys=True)) - return code - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/SKILL.md b/plugins/apple-dev-skills/skills/feedback-assistant-workflow/SKILL.md index 72e8a9890..f09bd4d26 100644 --- a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/feedback-assistant-workflow/SKILL.md @@ -50,4 +50,3 @@ Use `LanguageModelSession.logFeedbackAttachment` only for Foundation Models beha - `references/report-quality-and-evidence.md` - `references/live-app-and-api-boundaries.md` - `references/foundation-models-feedback-attachments.md` -- `references/customization-flow.md` diff --git a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization-flow.md deleted file mode 100644 index 47b6ed7fe..000000000 --- a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Feedback Assistant Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/feedback-assistant-workflow/scripts/customization_config.py deleted file mode 100644 index 9b871cf8c..000000000 --- a/plugins/apple-dev-skills/skills/feedback-assistant-workflow/scripts/customization_config.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Manage Feedback Assistant Workflow customization state.""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path - -import yaml - -SKILL_NAME = "feedback-assistant-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -DEFAULT = {"schemaVersion": 1, "isCustomized": False, "settings": {}} - - -def config_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load(path: Path, *, partial: bool = False) -> dict: - if not path.exists(): - return {} if partial else DEFAULT.copy() - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - raise ValueError(f"Customization must be a YAML mapping: {path}") - unknown = set(data) - set(DEFAULT) - if unknown: - raise ValueError(f"Unsupported customization keys: {', '.join(sorted(unknown))}") - if not partial and set(data) != set(DEFAULT): - raise ValueError(f"Customization must contain schemaVersion, isCustomized, and settings: {path}") - if "schemaVersion" in data and data["schemaVersion"] != 1: - raise ValueError("schemaVersion must be 1") - if "isCustomized" in data and not isinstance(data["isCustomized"], bool): - raise ValueError("isCustomized must be boolean") - if "settings" in data and not isinstance(data["settings"], dict): - raise ValueError("settings must be a mapping") - return data - - -def merged() -> dict: - result = {"schemaVersion": 1, "isCustomized": False, "settings": {}} - result.update(load(config_path(), partial=True)) - result["settings"] = dict(result.get("settings", {})) - return result - - -def write(value: dict) -> None: - target = config_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(yaml.safe_dump(value, sort_keys=False), encoding="utf-8") - print(target) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage Feedback Assistant Workflow customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply = commands.add_parser("apply") - apply.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - if args.command == "path": - print(config_path()) - elif args.command == "effective": - print(yaml.safe_dump(merged(), sort_keys=False), end="") - elif args.command == "apply": - update = load(Path(args.input), partial=True) - value = merged() - value.update(update) - value["settings"] = {**merged()["settings"], **update.get("settings", {})} - value["schemaVersion"] = 1 - value["isCustomized"] = True - write(value) - else: - target = config_path() - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/SKILL.md b/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/SKILL.md index 8cff50204..d85255e71 100644 --- a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/SKILL.md @@ -93,12 +93,6 @@ This skill owns that decision, File Provider synchronization mechanics, and Find - Recommend `explore-apple-swift-docs` for current File Provider or Finder Sync API confirmation. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable File Provider/Finder Sync target structure guidance. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the common customization-file contract. Synchronization ownership, conflict policy, and monitored-directory scope remain product evidence, not opaque defaults. - ## References ### Workflow References @@ -106,7 +100,6 @@ Use `references/customization-flow.md`. - `references/file-provider-synchronization.md` - `references/finder-sync-boundaries.md` - `references/privacy-validation-and-recovery.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -119,5 +112,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project guidance for File Provider and Finder Sync targets. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md deleted file mode 100644 index a71d6f199..000000000 --- a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# File Provider and Finder Sync Workflow Customization Contract - -## Purpose - -Preserve the common customization-file contract without hiding synchronization authority, conflict policy, or monitored-folder scope in unmanaged defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` provides the shared configuration shape. -- The workflow ignores persisted settings because remote identity, destructive behavior, and Finder scope require product-specific validation. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after documenting its synchronization and privacy effects. -3. Validate YAML before applying it. diff --git a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py deleted file mode 120000 index fe515f2db..000000000 --- a/plugins/apple-dev-skills/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py +++ /dev/null @@ -1 +0,0 @@ -../../safari-extension-control-workflow/scripts/customization_config.py \ No newline at end of file diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/SKILL.md b/plugins/apple-dev-skills/skills/format-swift-sources/SKILL.md index 3be28c8a0..382c96579 100644 --- a/plugins/apple-dev-skills/skills/format-swift-sources/SKILL.md +++ b/plugins/apple-dev-skills/skills/format-swift-sources/SKILL.md @@ -54,7 +54,7 @@ Use this skill as the top-level workflow for integrating and maintaining SwiftLi 4. Choose one documented path: - when the user wants the default baseline for a shared Swift repo, prefer a checked-in root `.swiftformat` and a Git pre-commit hook that formats staged Swift files and then verifies them with `swiftformat --lint` - for SwiftFormat settings export, prefer the host app export flow in `references/swiftformat-xcode-config-export.md` - - use `scripts/export_swiftformat_xcode_config.py` only when a deterministic shared-defaults export is needed + - use `scripts/export-swiftformat-xcode-config.fsx` only when a deterministic shared-defaults export is needed - when `defaults export` from the suite domain is empty, stale, or incomplete, point the script at the real shared plist inside the SwiftFormat group container with `--input-plist` - after script export, review the generated file before checking it in because extension state may still need light curation - for all other surfaces, use the tool-specific references instead of inventing a hybrid path @@ -103,7 +103,7 @@ Use this skill as the top-level workflow for integrating and maintaining SwiftLi ## Fallbacks and Handoffs -- SwiftFormat config export falls back from host-app export to `scripts/export_swiftformat_xcode_config.py`, preferably with `--input-plist` pointed at the real shared plist when the suite-domain export is not trustworthy on the current machine. +- SwiftFormat config export falls back from host-app export to `scripts/export-swiftformat-xcode-config.fsx`, preferably with `--input-plist` pointed at the real shared plist when the suite-domain export is not trustworthy on the current machine. - SwiftLint plugin adoption falls back to an Xcode Run Script Build Phase when plugin constraints conflict with config placement or project layout. - SwiftFormat build-phase adoption falls back from package-managed or pinned local binaries to the locally installed CLI path only when shared-version drift is acceptable. - For shared repos, prefer the Git pre-commit path over build-phase-only enforcement when the goal is to keep commits formatted before review and CI. @@ -117,11 +117,9 @@ Use this skill as the top-level workflow for integrating and maintaining SwiftLi - Recommend `author-swift-docc-docs` directly when the task becomes symbol documentation, DocC article work, landing-page structure, topic groups, or DocC-oriented review. - Recommend `bootstrap-xcode-workspace --operation align` for product guidance alignment. -## Customization +## Fixed Policy -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` reads, writes, resets, and reports per-skill customization metadata. -- The current customization surface is one policy-only guidance default for tool selection. This skill has no `run_workflow.py` runtime entrypoint at present. +- Tool selection follows the fixed workflow policy. This skill has no separate runtime planner. ## References @@ -135,7 +133,6 @@ Use this skill as the top-level workflow for integrating and maintaining SwiftLi ### Contract References - `references/automation-prompts.md` -- `references/customization-flow.md` ### Support References @@ -144,5 +141,4 @@ Use this skill as the top-level workflow for integrating and maintaining SwiftLi ### Script Inventory -- `scripts/customization_config.py` -- `scripts/export_swiftformat_xcode_config.py` +- `scripts/export-swiftformat-xcode-config.fsx` diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/references/automation-prompts.md b/plugins/apple-dev-skills/skills/format-swift-sources/references/automation-prompts.md index 1495b3943..8eb5cec1a 100644 --- a/plugins/apple-dev-skills/skills/format-swift-sources/references/automation-prompts.md +++ b/plugins/apple-dev-skills/skills/format-swift-sources/references/automation-prompts.md @@ -53,7 +53,7 @@ Execution requirements: 1) Check `references/integration-matrix.md` before proposing steps. 2) Use only the documented tool-specific surface guidance from `references/swiftformat-surfaces.md`, `references/swiftlint-surfaces.md`, and `references/swiftformat-xcode-config-export.md`. 3) If the request is for SwiftFormat for Xcode settings export, prefer the host app export path unless the script path is explicitly needed. -4) If the request is for the scriptable export path, use `scripts/export_swiftformat_xcode_config.py`, prefer `--input-plist` when the suite-domain export is incomplete, and call out that the generated file should be reviewed before commit. +4) If the request is for the scriptable export path, use `scripts/export-swiftformat-xcode-config.fsx`, prefer `--input-plist` when the suite-domain export is incomplete, and call out that the generated file should be reviewed before commit. 5) Stop and return `blocked` if the requested tool and surface combination is unsupported. Return the documented contract only: diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/references/customization-flow.md b/plugins/apple-dev-skills/skills/format-swift-sources/references/customization-flow.md deleted file mode 100644 index fa2e31a52..000000000 --- a/plugins/apple-dev-skills/skills/format-swift-sources/references/customization-flow.md +++ /dev/null @@ -1,31 +0,0 @@ -# Swift Style Tooling Customization Contract - -## Purpose - -Tune the documented default preferences for selecting SwiftLint and SwiftFormat integration paths. - -## Knobs - -| Knob | Default | Status | Effect | -| --- | --- | --- | --- | -| `defaultToolSelection` | `both` | `policy-only` | Sets the default planning posture when the user wants “style tooling” without naming one tool. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/export_swiftformat_xcode_config.py` is deterministic, but it does not currently read these customization knobs. -- Surface selection, plugin preference, config-file placement, and host-app export preference now live as workflow defaults rather than durable user customization. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected references to reflect the approved default-policy change. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify the references and automation prompts still describe the same defaults. - -## Validation - -1. Verify the support matrix in `references/integration-matrix.md` still matches `SKILL.md`. -2. Verify every customization knob is described consistently across `SKILL.md`, this file, and `references/automation-prompts.md`. -3. Verify the customization template remains under `references/customization.template.yaml`. diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/references/customization.template.yaml b/plugins/apple-dev-skills/skills/format-swift-sources/references/customization.template.yaml deleted file mode 100644 index ba856a3c3..000000000 --- a/plugins/apple-dev-skills/skills/format-swift-sources/references/customization.template.yaml +++ /dev/null @@ -1,4 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - defaultToolSelection: "both" diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/references/swiftformat-xcode-config-export.md b/plugins/apple-dev-skills/skills/format-swift-sources/references/swiftformat-xcode-config-export.md index 4d77a4cc7..4330e356b 100644 --- a/plugins/apple-dev-skills/skills/format-swift-sources/references/swiftformat-xcode-config-export.md +++ b/plugins/apple-dev-skills/skills/format-swift-sources/references/swiftformat-xcode-config-export.md @@ -19,18 +19,18 @@ Why this is the preferred path: ## Deterministic Fallback Path -Use `scripts/export_swiftformat_xcode_config.py` when you need a scriptable export from the SwiftFormat shared defaults state. +Use `scripts/export-swiftformat-xcode-config.fsx` when you need a scriptable export from the SwiftFormat shared defaults state. Example: ```bash -skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py --output .swiftformat +skills/format-swift-sources/scripts/export-swiftformat-xcode-config.fsx --output .swiftformat ``` If `defaults export com.charcoaldesign.SwiftFormat -` does not produce a useful payload on the current machine, point the script at the actual shared plist inside the SwiftFormat group container instead: ```bash -skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py \ +skills/format-swift-sources/scripts/export-swiftformat-xcode-config.fsx \ --input-plist "/path/to/SwiftFormat-group-container/.../com.charcoaldesign.SwiftFormat.plist" \ --output .swiftformat ``` diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/scripts/customization_config.py b/plugins/apple-dev-skills/skills/format-swift-sources/scripts/customization_config.py deleted file mode 100644 index 39a1a67d9..000000000 --- a/plugins/apple-dev-skills/skills/format-swift-sources/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "format-swift-sources" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/scripts/export-swiftformat-xcode-config.fsx b/plugins/apple-dev-skills/skills/format-swift-sources/scripts/export-swiftformat-xcode-config.fsx new file mode 100644 index 000000000..064e0bc33 --- /dev/null +++ b/plugins/apple-dev-skills/skills/format-swift-sources/scripts/export-swiftformat-xcode-config.fsx @@ -0,0 +1,51 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let value flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let run executable arguments = + let info = ProcessStartInfo(executable) + info.UseShellExecute <- false; info.RedirectStandardOutput <- true; info.RedirectStandardError <- true + arguments |> List.iter info.ArgumentList.Add + use child = Process.Start info + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + if child.ExitCode <> 0 then failwith (if String.IsNullOrWhiteSpace stderr then stdout else stderr) + stdout +let suite = value "--suite-domain" |> Option.defaultValue "com.charcoaldesign.SwiftFormat" +let json = + match value "--input-plist" with + | Some path -> run "plutil" [ "-convert"; "json"; "-o"; "-"; path ] + | None -> + let plist = run "defaults" [ "export"; suite; "-" ] + let temporary = Path.GetTempFileName() + try + File.WriteAllText(temporary, plist) + run "plutil" [ "-convert"; "json"; "-o"; "-"; temporary ] + finally File.Delete temporary +use document = JsonDocument.Parse json +let root = document.RootElement +let mapping (name: string) = let mutable item = Unchecked.defaultof<JsonElement> in if root.TryGetProperty(name, &item) && item.ValueKind = JsonValueKind.Object then item.EnumerateObject() |> Seq.toArray else [||] +let enabled = mapping "rules" |> Array.filter (fun item -> item.Value.ValueKind = JsonValueKind.True) |> Array.map _.Name |> Array.sort +let mutable infer = Unchecked.defaultof<JsonElement> +let inferOptions = not (root.TryGetProperty("infer-options", &infer)) || infer.ValueKind = JsonValueKind.True +let versions = Set.ofList [ "swiftversion"; "swift-version"; "languagemode"; "language-mode" ] +let options = + mapping "format-options" + |> Array.filter (fun item -> not inferOptions || versions.Contains item.Name) + |> Array.map (fun item -> item.Name, item.Value.ToString()) + |> Array.filter (fun (name, value) -> not (String.IsNullOrWhiteSpace value) && not (versions.Contains name && Set.ofList [ "0"; "auto"; "undefined" ] |> Set.contains (value.ToLowerInvariant()))) + |> Array.sortBy fst +let lines = ResizeArray [ "# Generated from SwiftFormat for Xcode shared defaults."; $"# Source suite: {suite}"; "" ] +if enabled.Length > 0 then lines.Add("--rules " + String.concat "," enabled) +for name, raw in options do + let value = if raw.IndexOfAny([|' '; '"'|]) >= 0 then "\"" + raw.Replace("\"", "\\\"") + "\"" else raw + lines.Add($"--{name} {value}") +if enabled.Length = 0 && options.Length = 0 then lines.Add("# No explicit rules or exportable options were found.") +let rendered = String.concat "\n" lines + "\n" +match value "--output" with Some path -> File.WriteAllText(path, rendered) | None -> printf "%s" rendered diff --git a/plugins/apple-dev-skills/skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py b/plugins/apple-dev-skills/skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py deleted file mode 100644 index 09a46bb3c..000000000 --- a/plugins/apple-dev-skills/skills/format-swift-sources/scripts/export_swiftformat_xcode_config.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Export SwiftFormat for Xcode shared defaults to a SwiftFormat config file.""" - -from __future__ import annotations - -import argparse -import plistlib -import re -import subprocess -import sys -from pathlib import Path -from typing import Any - - -DEFAULT_SUITE_DOMAIN = "com.charcoaldesign.SwiftFormat" -MEANINGLESS_VERSION_VALUES = {"", "0", "auto", "undefined"} -VERSION_OPTION_KEYS = {"swiftversion", "swift-version", "languagemode", "language-mode"} -SAFE_LITERAL_RE = re.compile(r"^[A-Za-z0-9_.,:/+-]+$") - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def load_defaults_domain(domain: str) -> dict[str, Any]: - proc = subprocess.run( - ["defaults", "export", domain, "-"], - capture_output=True, - check=False, - ) - if proc.returncode != 0: - stderr = proc.stderr.decode("utf-8", errors="replace").strip() - fail( - "Unable to read the SwiftFormat shared defaults domain " - f"`{domain}` via `defaults export`. {stderr or 'The domain may not exist on this machine.'}" - ) - - try: - loaded = plistlib.loads(proc.stdout) - except Exception as exc: # pragma: no cover - defensive parse guard - fail(f"Unable to parse plist data returned by `defaults export`: {exc}") - - if not isinstance(loaded, dict): - fail("The exported SwiftFormat defaults payload was not a dictionary.") - return loaded - - -def load_plist(path: Path) -> dict[str, Any]: - try: - with path.open("rb") as handle: - loaded = plistlib.load(handle) - except FileNotFoundError: - fail(f"Missing plist input file: {path}") - except Exception as exc: - fail(f"Unable to read plist input file {path}: {exc}") - - if not isinstance(loaded, dict): - fail(f"Expected plist root to be a dictionary in {path}") - return loaded - - -def expect_mapping(payload: dict[str, Any], key: str) -> dict[str, Any]: - value = payload.get(key, {}) - if value is None: - return {} - if not isinstance(value, dict): - fail(f"Expected `{key}` in the SwiftFormat defaults payload to be a dictionary.") - return value - - -def normalize_scalar(value: Any) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if value is None: - return "" - return str(value) - - -def should_skip_option_value(key: str, value: str) -> bool: - stripped = value.strip() - if stripped == "": - return True - if key in VERSION_OPTION_KEYS and stripped.lower() in MEANINGLESS_VERSION_VALUES: - return True - return False - - -def encode_argument(value: str) -> str: - if SAFE_LITERAL_RE.fullmatch(value): - return value - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def serialize_lines(payload: dict[str, Any]) -> list[str]: - rules = expect_mapping(payload, "rules") - options = expect_mapping(payload, "format-options") - infer_options = bool(payload.get("infer-options", True)) - - enabled_rules = sorted(name for name, enabled in rules.items() if bool(enabled)) - rendered_options: list[tuple[str, str]] = [] - - if infer_options: - for key in sorted(options): - if key not in VERSION_OPTION_KEYS: - continue - value = normalize_scalar(options[key]).strip() - if should_skip_option_value(key, value): - continue - rendered_options.append((key, value)) - else: - for key in sorted(options): - value = normalize_scalar(options[key]).strip() - if should_skip_option_value(key, value): - continue - rendered_options.append((key, value)) - - lines = [ - "# Generated from SwiftFormat for Xcode shared defaults.", - f"# Source suite: {DEFAULT_SUITE_DOMAIN}", - ] - if infer_options: - lines.append("# infer-options is enabled, so only explicit Swift version or language mode values are exported.") - lines.append("") - - if enabled_rules: - lines.append(f"--rules {','.join(enabled_rules)}") - - for key, value in rendered_options: - lines.append(f"--{key} {encode_argument(value)}") - - if not enabled_rules and not rendered_options: - lines.append("# No explicit rules or exportable options were found in the shared defaults payload.") - - return lines - - -def write_output(lines: list[str], output: Path | None) -> None: - text = "\n".join(lines).rstrip() + "\n" - if output is None: - sys.stdout.write(text) - return - output.write_text(text, encoding="utf-8") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--suite-domain", - default=DEFAULT_SUITE_DOMAIN, - help="UserDefaults suite domain to export. Defaults to SwiftFormat's shared suite.", - ) - parser.add_argument( - "--input-plist", - type=Path, - help="Read a plist file instead of calling `defaults export`. Useful for testing, offline export, or using the real shared plist from the SwiftFormat group container.", - ) - parser.add_argument( - "--output", - type=Path, - help="Write the generated config to this path. Defaults to stdout.", - ) - return parser - - -def main() -> None: - args = build_parser().parse_args() - payload = load_plist(args.input_plist) if args.input_plist else load_defaults_domain(args.suite_domain) - lines = serialize_lines(payload) - write_output(lines, args.output) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/icon-composer-app-icon-workflow/SKILL.md b/plugins/apple-dev-skills/skills/icon-composer-app-icon-workflow/SKILL.md index d5f5f5e21..c1d8af8a6 100644 --- a/plugins/apple-dev-skills/skills/icon-composer-app-icon-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/icon-composer-app-icon-workflow/SKILL.md @@ -243,12 +243,12 @@ Product constraints to preserve: For Socket implementation work involving this skill: ```bash -uv run scripts/validate_socket_metadata.py -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh +just repo-validate +just repo-validate ``` From the Socket root, run the Apple Dev tests through -`uv run scripts/validate_socket.py --profile full` when tests, validation +`just repo-validate` and `just test` when tests, validation helpers, or scripts changed. ## Handoffs diff --git a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/SKILL.md b/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/SKILL.md index 6df78cfe0..1693f93b4 100644 --- a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/SKILL.md @@ -51,13 +51,8 @@ Provide two explicit evidence modes for a reproducible iOS Simulator issue: `per - Recommend `xcode-testing-workflow` for Instruments, `xctrace`, test plans, and broader Xcode performance work. - Recommend `swiftui-performance-audit` when the user first needs a code-first SwiftUI hypothesis. -## Customization - -Use `references/customization-flow.md`. Evidence mode and reproducibility requirements are fixed; local customization cannot bypass same-flow comparison or artifact-scope reporting. - ## References - `references/performance-trace-evidence.md` - `references/memory-graph-evidence.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the app needs reusable Xcode-project policy alongside simulator evidence work. diff --git a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization-flow.md deleted file mode 100644 index 2e9f11c41..000000000 --- a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# TipKit Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/scripts/customization_config.py deleted file mode 100755 index 6de073b66..000000000 --- a/plugins/apple-dev-skills/skills/ios-runtime-forensics-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "ios-runtime-forensics-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/SKILL.md b/plugins/apple-dev-skills/skills/linux-development-vm-workflow/SKILL.md index 2ef428959..bbfc7e173 100644 --- a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/linux-development-vm-workflow/SKILL.md @@ -62,10 +62,6 @@ Prepare one persistent Linux development environment whose lifecycle, host integ - Use `xcode-build-run-workflow`, `swift-package-build-run-workflow`, or stack-specific skills after the guest is ready. - Use `prepare-isolated-analysis-lab` for disposable hostile-workload controls. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [Linux development guest matrix](references/linux-development-guest-matrix.md) diff --git a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/linux-development-vm-workflow/scripts/customization_config.py deleted file mode 100755 index e814a9867..000000000 --- a/plugins/apple-dev-skills/skills/linux-development-vm-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "linux-development-vm-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/SKILL.md b/plugins/apple-dev-skills/skills/macos-development-vm-workflow/SKILL.md index 7c2386dcd..b7809efa9 100644 --- a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/macos-development-vm-workflow/SKILL.md @@ -58,10 +58,6 @@ Prepare a reproducible macOS guest while keeping restore images, identity, disks - Use `prepare-isolated-analysis-lab` before executing untrusted content. - Use a spare physical Mac when hardware, recoveryOS, Secure Enclave, device, performance, or anti-VM fidelity is required. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [macOS VM artifact lifecycle](references/macos-vm-artifact-lifecycle.md) diff --git a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/macos-development-vm-workflow/scripts/customization_config.py deleted file mode 100755 index 163086d49..000000000 --- a/plugins/apple-dev-skills/skills/macos-development-vm-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "macos-development-vm-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/macos-distribution-workflow/SKILL.md b/plugins/apple-dev-skills/skills/macos-distribution-workflow/SKILL.md index 902fadf4d..05211ec37 100644 --- a/plugins/apple-dev-skills/skills/macos-distribution-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/macos-distribution-workflow/SKILL.md @@ -52,13 +52,8 @@ Treat a signed exported artifact as the source of truth for macOS distribution d - Recommend `xcode-build-run-workflow` for entitlement/project-signing changes, archive/export, and build validation. - Recommend `explore-apple-swift-docs` for current signing, notarization, or distribution policy. -## Customization - -Use `references/customization-flow.md`. Distribution validation is artifact- and channel-specific, so this workflow provides no shortcut that can skip signature, Gatekeeper, or notarization evidence. - ## References - `references/artifact-inspection-and-classification.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the app needs reusable Xcode-project policy alongside distribution work. - [Packaging Mac software for distribution](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution) documents distribution packaging and notarization context. diff --git a/plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization-flow.md deleted file mode 100644 index 2e9f11c41..000000000 --- a/plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# TipKit Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/macos-distribution-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/macos-distribution-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/macos-distribution-workflow/scripts/customization_config.py deleted file mode 100755 index 06b482df4..000000000 --- a/plugins/apple-dev-skills/skills/macos-distribution-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "macos-distribution-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/SKILL.md b/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/SKILL.md index c54cd9ec2..ea1ae4625 100644 --- a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/SKILL.md @@ -59,10 +59,6 @@ Identify the protected operation, responsible executable, and current public aut - Use `research-macos-security-control` for private TCC symbols, database schemas, daemon behavior, or exact-build implementation research. - Use Cybersecurity Skills for suspicious prompts, unexplained grants, Gatekeeper/XProtect alerts, or host compromise questions. -## Customization - -Use `references/customization-flow.md`. The workflow has no runtime knobs; permission behavior must remain tied to the recorded identity, OS build, public API, and user or managed decision. - ## References - `references/permission-class-matrix.md` diff --git a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization-flow.md deleted file mode 100644 index b05416944..000000000 --- a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# macOS Privacy Permissions Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; permission conclusions must remain derived from current documentation, stable code identity, exact host state, and the recorded user or managed decision. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/scripts/customization_config.py deleted file mode 100755 index 8474f269a..000000000 --- a/plugins/apple-dev-skills/skills/macos-privacy-permissions-workflow/scripts/customization_config.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist macOS privacy workflow customization state.""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "macos-privacy-permissions-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def load(path: Path, *, required: bool) -> dict: - if not path.exists(): - if required: - fail(f"Missing YAML file: {path}") - return {} - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if not isinstance(data, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return data - - -def validate(data: dict, *, partial: bool) -> None: - unknown = set(data) - ALLOWED_KEYS - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial and set(data) != ALLOWED_KEYS: - fail("Customization state must define schemaVersion, isCustomized, and settings") - if "schemaVersion" in data and data["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in data and not isinstance(data["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in data: - if not isinstance(data["settings"], dict): - fail("settings must be a mapping") - for key, value in data["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def effective() -> dict: - base = load(template_path(), required=True) - saved = load(durable_path(), required=False) - validate(base, partial=False) - validate(saved, partial=False) if saved else None - merged = { - "schemaVersion": saved.get("schemaVersion", base["schemaVersion"]), - "isCustomized": saved.get("isCustomized", base["isCustomized"]), - "settings": {**base["settings"], **saved.get("settings", {})}, - } - validate(merged, partial=False) - return merged - - -def render(data: dict) -> str: - return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) - - -def command_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def command_effective(_: argparse.Namespace) -> None: - print(render(effective()), end="") - - -def command_apply(args: argparse.Namespace) -> None: - incoming = load(Path(args.input), required=True) - validate(incoming, partial=True) - current = effective() - updated = { - "schemaVersion": 1, - "isCustomized": True, - "settings": {**current["settings"], **incoming.get("settings", {})}, - } - validate(updated, partial=False) - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(render(updated), encoding="utf-8") - print(target) - - -def command_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage macOS privacy workflow customization") - commands = parser.add_subparsers(dest="command", required=True) - path_parser = commands.add_parser("path") - path_parser.set_defaults(func=command_path) - effective_parser = commands.add_parser("effective") - effective_parser.set_defaults(func=command_effective) - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - apply_parser.set_defaults(func=command_apply) - reset_parser = commands.add_parser("reset") - reset_parser.set_defaults(func=command_reset) - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/SKILL.md b/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/SKILL.md index 0e91e6bb0..f96362102 100644 --- a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/SKILL.md @@ -59,10 +59,6 @@ Preserve user intent and access lifetime while selecting the smallest supported - Use `app-extension-architecture-workflow` for extension lifecycle/IPC design and Xcode workflows for project edits. - Use `research-macos-security-control` for private sandbox profiles, extensions, or exact-build Seatbelt behavior. -## Customization - -Use `references/customization-flow.md`. The workflow has no runtime knobs; access scope must follow the recorded feature, process, resource, and lifetime. - ## References - `references/sandbox-and-filesystem-control-map.md` diff --git a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization-flow.md deleted file mode 100644 index 8f5781c8b..000000000 --- a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# macOS Sandbox File Access Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; access scope must be selected from the concrete resource, process, operation, distribution, and persistence need. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py deleted file mode 100755 index 7c8b797e1..000000000 --- a/plugins/apple-dev-skills/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist sandbox file-access workflow customization state.""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "macos-sandbox-file-access-workflow" -ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def read(path: Path, required: bool = False) -> dict: - if not path.exists(): - if required: - fail(f"Missing YAML file: {path}") - return {} - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return value - - -def validate(value: dict, partial: bool = False) -> None: - if set(value) - KEYS: - fail(f"Unknown top-level keys: {', '.join(sorted(set(value) - KEYS))}") - if not partial and set(value) != KEYS: - fail("State must define schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - for key, item in value["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(item, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def destination() -> Path: - root = Path(os.environ.get("APPLE_DEV_SKILLS_CONFIG_HOME", ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def current() -> dict: - template = read(Path(__file__).resolve().parents[1] / "references/customization.template.yaml", True) - saved = read(destination()) - validate(template) - if saved: - validate(saved) - return {"schemaVersion": 1, "isCustomized": saved.get("isCustomized", False), "settings": {**template["settings"], **saved.get("settings", {})}} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage sandbox file-access customization") - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("path") - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - target = destination() - if args.command == "path": - print(target) - elif args.command == "effective": - print(yaml.safe_dump(current(), sort_keys=False), end="") - elif args.command == "reset": - if target.exists(): - target.unlink() - print(target) - else: - incoming = read(Path(args.input), True) - validate(incoming, partial=True) - updated = {"schemaVersion": 1, "isCustomized": True, "settings": {**current()["settings"], **incoming.get("settings", {})}} - validate(updated) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(yaml.safe_dump(updated, sort_keys=False), encoding="utf-8") - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/mailkit-workflow/SKILL.md b/plugins/apple-dev-skills/skills/mailkit-workflow/SKILL.md index b55e3a75b..33927619d 100644 --- a/plugins/apple-dev-skills/skills/mailkit-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/mailkit-workflow/SKILL.md @@ -97,19 +97,12 @@ It does not own a mail server, IMAP/SMTP transport, account provisioning, genera - Recommend `explore-apple-swift-docs` when current MailKit symbols or capability behavior need source-specific confirmation. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable containing-app and extension-target project guidance. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the common customization-file contract. Mail handler choice, message policy, and security decisions remain project-specific and must not be converted into opaque persistent defaults. - ## References ### Workflow References - `references/mailkit-capabilities-and-handler-boundaries.md` - `references/privacy-security-and-validation.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -122,5 +115,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project guidance for MailKit app and extension targets. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/mailkit-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/mailkit-workflow/references/customization-flow.md deleted file mode 100644 index 7c9fa5c04..000000000 --- a/plugins/apple-dev-skills/skills/mailkit-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# MailKit Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without persisting mail-access, action, header, or message-security policy as hidden defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` provides the shared configuration shape. -- The workflow ignores persisted settings because handler declarations and mail-data policy must be explicit for each product. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after documenting its MailKit capability, user impact, and privacy boundary. -3. Validate YAML before applying it. diff --git a/plugins/apple-dev-skills/skills/mailkit-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/mailkit-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/mailkit-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/mailkit-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/mailkit-workflow/scripts/customization_config.py deleted file mode 120000 index fe515f2db..000000000 --- a/plugins/apple-dev-skills/skills/mailkit-workflow/scripts/customization_config.py +++ /dev/null @@ -1 +0,0 @@ -../../safari-extension-control-workflow/scripts/customization_config.py \ No newline at end of file diff --git a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/SKILL.md b/plugins/apple-dev-skills/skills/photos-library-editing-workflow/SKILL.md index 2e5659536..3c03444f8 100644 --- a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/photos-library-editing-workflow/SKILL.md @@ -83,18 +83,11 @@ Guide PhotosUI selection and PhotoKit library work while requesting the narrowes - Recommend `xcode-testing-workflow` for authorization matrices, picker/load fakes, resource fixtures, change-detail tests, and edit round trips. - Recommend `explore-apple-swift-docs` for current PhotosUI or PhotoKit research. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/photosui-selection-and-authorization.md` - `references/assets-fetches-requests-resources-and-changes.md` - `references/creation-collections-and-nondestructive-editing.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization-flow.md deleted file mode 100644 index 53360d96a..000000000 --- a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Photos Library and Editing Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/photos-library-editing-workflow/scripts/customization_config.py deleted file mode 100755 index 1f4ed6eac..000000000 --- a/plugins/apple-dev-skills/skills/photos-library-editing-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist Photos library and editing workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "photos-library-editing-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/SKILL.md b/plugins/apple-dev-skills/skills/safari-extension-control-workflow/SKILL.md index 59c3aaf12..377491336 100644 --- a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/safari-extension-control-workflow/SKILL.md @@ -117,11 +117,7 @@ This skill owns the decision between Safari Web Extensions, Safari Web Inspector - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode-project policy for a repo that will own the containing app and extension targets. - Treat external automation as a conscious fallback, not the default. When it is requested, state the user-visible permissions, fragility, and Safari-version sensitivity before implementation. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on the documented Safari surface decision. If future iterations add deterministic checks for manifests, entitlements, or app-group configuration, document the knobs before runtime behavior depends on them. @@ -134,7 +130,6 @@ Keep the first release focused on the documented Safari surface decision. If fut - `references/safari-services-control-surfaces.md` - `references/messaging-shared-data-and-permissions.md` - `references/testing-debugging-and-distribution.md` -- `references/customization-flow.md` ### Support References @@ -144,5 +139,3 @@ Keep the first release focused on the documented Safari surface decision. If fut - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode project guidance for containing-app and extension-target work. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/safari-extension-control-workflow/scripts/customization_config.py deleted file mode 100755 index 27ef917b5..000000000 --- a/plugins/apple-dev-skills/skills/safari-extension-control-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-extension-control-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/safari-mcp-workflow/SKILL.md b/plugins/apple-dev-skills/skills/safari-mcp-workflow/SKILL.md index e7a409676..1463bf987 100644 --- a/plugins/apple-dev-skills/skills/safari-mcp-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/safari-mcp-workflow/SKILL.md @@ -64,17 +64,12 @@ Use Safari MCP for evidence from a live Safari Technology Preview tab. It owns b - Recommend `apple-ui-accessibility-workflow` for Apple native UI accessibility work. - Recommend `explore-apple-swift-docs` for current Apple or WebKit documentation. -## Customization - -Use `references/customization-flow.md`. The first version has no runtime-enforced settings: origin, interaction, and privacy boundaries must be chosen for each live session. - ## References ### Workflow References - `references/setup-and-privacy.md` - `references/evidence-and-validation.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -82,5 +77,3 @@ Use `references/customization-flow.md`. The first version has no runtime-enforce - [Safari Technology Preview](https://developer.apple.com/safari/technology-preview/) ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization-flow.md deleted file mode 100644 index 1abfb2633..000000000 --- a/plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# Safari MCP Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without making origin, interaction, or privacy decisions persistent defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` maintains the common configuration shape. -- The workflow ignores persisted settings because each browser session requires its own approved target and interaction boundary. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after its stable behavior and safety boundary are documented. -3. Validate YAML before persisting it. diff --git a/plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/safari-mcp-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/safari-mcp-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/safari-mcp-workflow/scripts/customization_config.py deleted file mode 100755 index c03565916..000000000 --- a/plugins/apple-dev-skills/skills/safari-mcp-workflow/scripts/customization_config.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist per-skill Safari MCP customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-mcp-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value: object) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - return quote_string("" if value is None else str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not allow_partial and set(config) != ALLOWED_TOP_LEVEL: - fail("Missing required customization keys: schemaVersion, isCustomized, settings") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - merged[key] = overlay[key] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {config['schemaVersion']}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - lines.extend(f" {key}: {encode_scalar(value)}" for key, value in sorted(config["settings"].items())) - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - config = parse_yaml(template_path()) - validate_config(config, allow_partial=False) - return config - - -def load_durable() -> dict: - return parse_yaml(durable_path()) if durable_path().exists() else {} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage Safari MCP workflow customization.") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply = commands.add_parser("apply") - apply.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - if args.command == "path": - print(durable_path()) - return - if args.command == "effective": - print(dump_yaml(merge_configs(load_template(), load_durable())), end="") - return - if args.command == "reset": - durable_path().unlink(missing_ok=True) - print(durable_path()) - return - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - updated = merge_configs(merge_configs(load_template(), load_durable()), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - durable_path().parent.mkdir(parents=True, exist_ok=True) - durable_path().write_text(dump_yaml(updated), encoding="utf-8") - print(durable_path()) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/sf-symbols-workflow/SKILL.md b/plugins/apple-dev-skills/skills/sf-symbols-workflow/SKILL.md index 585e849b5..c23267983 100644 --- a/plugins/apple-dev-skills/skills/sf-symbols-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/sf-symbols-workflow/SKILL.md @@ -123,11 +123,7 @@ Current local evidence from 2026-06-28: SF Symbols 7.2 build 119 exposes an acce - Recommend `explore-apple-swift-docs` when the user primarily needs raw Apple documentation lookup. - Recommend `references/snippets/apple-xcode-project-core.md` when repo policy or Xcode project-integrity guidance is needed before applying symbol resources. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on symbol selection, app inspection, rendering behavior, accessibility semantics, and Xcode handoffs. If future iterations add deterministic export or validation helpers, document those helpers before relying on them. @@ -137,7 +133,6 @@ Keep the first release focused on symbol selection, app inspection, rendering be - `references/symbol-selection-and-rendering.md` - `references/custom-symbols-and-app-inspection.md` -- `references/customization-flow.md` ### Support References @@ -146,5 +141,3 @@ Keep the first release focused on symbol selection, app inspection, rendering be - Apple documentation anchors to verify include SF Symbols in the Human Interface Guidelines, Configuring and displaying symbol images in your UI, Creating custom symbol images for your app, SwiftUI Images symbol rendering, and SwiftUI symbol effects. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization-flow.md deleted file mode 100644 index 878ee6ff2..000000000 --- a/plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# SwiftUI App Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `swiftui-app-architecture-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first boundary and decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `swiftui-app-architecture-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/sf-symbols-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/sf-symbols-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/sf-symbols-workflow/scripts/customization_config.py deleted file mode 100755 index 03f219a78..000000000 --- a/plugins/apple-dev-skills/skills/sf-symbols-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-app-architecture-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/SKILL.md b/plugins/apple-dev-skills/skills/structure-swift-sources/SKILL.md index ff1c95fdc..d0fd7a57d 100644 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/SKILL.md +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/SKILL.md @@ -7,7 +7,7 @@ description: Organize Swift source trees and oversized Swift files by feature, l ## Purpose -Use this skill as the top-level workflow for structural cleanup inside existing Swift components. It governs file splitting, file moves, section grouping, plain-language file headers, and TODO or FIXME ledger extraction. `scripts/run_workflow.py` classifies the cleanup, loads policy, and hands off only DocC content or Xcode-owned membership operations. It is not the formatter or linter integration authority, and it is not the DocC authoring authority. +Use this skill as the top-level workflow for structural cleanup inside existing Swift components. It governs file splitting, file moves, section grouping, plain-language file headers, and TODO or FIXME ledger extraction. `scripts/run-workflow.fsx` classifies the cleanup, loads policy, and hands off only DocC content or Xcode-owned membership operations. It is not the formatter or linter integration authority, and it is not the DocC authoring authority. ## When To Use @@ -26,52 +26,6 @@ Use this skill as the top-level workflow for structural cleanup inside existing ## Single-Path Workflow -1. Classify the request: - - repo layout cleanup - - large-file split - - section and MARK normalization - - file-header normalization - - TODO or FIXME ledger extraction - - combined source-hygiene pass -2. Run or confirm `format-swift-sources` first: - - use it to establish a clean baseline before file moves or file splits - - if the repo does not have a clear formatter or linter path yet, stop and set that up first -3. Resolve the requested Swift component and read the relevant references: - - `references/glossary.md` - - `references/layout-rules.md` - - `references/source-organization-rules.md` - - `references/file-headers.md` - - `references/todo-fixme-ledgers.md` - - `references/automation-prompts.md` - - `references/customization-flow.md` -4. Apply the structure rules: - - strongly consider splitting a file once it exceeds the configured soft split threshold and clearly holds `2` or more separate concerns - - always split a file once it exceeds the configured hard split threshold - - require one explicit three-letter uppercase prefix for every project-owned Swift source file and primary declaration - - never infer a different prefix after project setup; ask the user or agent to choose explicitly from reasonable initials-based suggestions - - when a coherent type needs an extracted concern, concatenate the concern after the owning type, such as `GEAWhateverServiceAdapter.swift`; do not use `+` filenames - - add `// MARK:` groups only when a file is large enough or varied enough that the grouping materially improves navigation, concern ownership, or declaration discovery - - skip `// MARK:` groups entirely when a short file or an already-obvious declaration run does not present meaningful navigation ambiguity - - when groups are warranted, use explicit `// MARK: - <Heading>` sections that name a real responsibility boundary instead of restating declaration kinds or symbol names in slightly different words - - add a secondary `// MARK: <Comment>` line only when it answers a useful navigation question such as why this section exists, what job it serves, or how it differs from nearby sections - - never use headings or secondary comments that just restate an obvious type, symbol, or method name, narrate intuitive code in a small file, or pad the file with redundant structure - - require or recommend the documented project-and-file banner header according to the effective header policy - - keep `Concern` and `Purpose` text in plain terms that explain what the file owns and what job it does, instead of repeating the filename or symbol names as jargon - - treat `Key Types` and `See Also` as optional high-signal fields rather than mandatory filler - - move TODO and FIXME text into `TODO.md` and `FIXME.md`, keeping only ticket IDs in source comments - - when the task is TODO or FIXME normalization, use `scripts/normalize_todo_fixme_ledgers.py` for the deterministic ledger rewrite pass across supported Swift and Objective-C source forms - - when the task is file-header normalization or a full cleanup pass that includes headers, use `scripts/normalize_swift_file_headers.py` to audit or apply the documented header shape -5. Apply component layout rules: - - for Swift packages, prefer directories grouped by layer and feature, such as `API/<Feature>/<Concern>.swift` and `Features/<Feature>/<Concern>.swift` - - for Xcode app projects, ensure important app-facing source directories such as `Views/`, `Models/`, and `Services/`, and do not preserve a root `Controllers/` directory - - for SwiftUI views, keep view files in `Views/Shared`, `Views/macOS`, or `Views/iOS`, require exactly one SwiftUI `View` component per file, and keep that component's Xcode SwiftUI preview in the same file - - hand SwiftUI component, feature-service, and modifier composition decisions to `swiftui-app-architecture-workflow` - - hand SwiftData persistence naming and integration decisions to `swiftdata-workflow` - - for services, use `Services/Consumed`, `Services/Internal`, and `Services/Provided`; name each direct concrete capability such as `GEAWhateverService.swift` without introducing an umbrella `GEAAppService.swift` - - reserve `Model` for persistence representations; use `Record` and `DTO` only when additional representations are genuinely needed - - treat `Package.swift`, externally generated Swift, and vendored third-party Swift as the only default filename-prefix exceptions -7. Finish with `format-swift-sources` again so the moved or split files end in a normalized state. - ## Inputs - `cleanup_kind`: one of the request classes above @@ -125,14 +79,12 @@ Use this skill as the top-level workflow for structural cleanup inside existing - If a broad repo-wide cleanup is too risky, fall back to one feature directory or one oversized file at a time. - If the request becomes symbol-doc or DocC-content work, hand off to `author-swift-docc-docs`. - If Xcode project integrity must be revalidated after file moves, hand off to `xcode-build-run-workflow`. -- `scripts/run_workflow.py` is the top-level runtime entrypoint and converts component inspection plus request inference into the documented JSON contract. +- `scripts/run-workflow.fsx` is the top-level runtime entrypoint and converts component inspection plus request inference into the documented JSON contract. - Recommend `bootstrap-xcode-workspace --operation align` when the request is really about durable product rules. -## Customization +## Fixed Policy -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` loads the runtime-enforced header policy and split thresholds before shaping the final workflow contract. +- `scripts/run-workflow.fsx` uses the managed header policy and fixed split thresholds. ## References @@ -147,7 +99,6 @@ Use this skill as the top-level workflow for structural cleanup inside existing ### Contract References - `references/automation-prompts.md` -- `references/customization-flow.md` ### Support References @@ -156,8 +107,7 @@ Use this skill as the top-level workflow for structural cleanup inside existing ### Script Inventory -- `scripts/customization_config.py` -- `scripts/run_workflow.py` -- `scripts/normalize_todo_fixme_ledgers.py` -- `scripts/normalize_swift_file_headers.py` +- `scripts/run-workflow.fsx` +- `scripts/normalize-swift-structure.fsx` +- `scripts/normalize-swift-structure.fsx` - `references/file-header-inventory.template.yaml` diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/agents/openai.yaml b/plugins/apple-dev-skills/skills/structure-swift-sources/agents/openai.yaml index 4cd2aa4fb..738578682 100644 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/agents/openai.yaml +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Structure Swift Sources" short_description: "Split, move, group, and normalize Swift source files" - default_prompt: "Use $format-swift-sources first, then use $structure-swift-sources to split oversized Swift files, normalize directory layout, MARK sections, and structured project-and-file banner headers, and move TODO and FIXME text into ledger files. Start with `scripts/run_workflow.py` so the cleanup kind, header policy, and handoff surface resolve consistently. If the request becomes DocC authoring or review work, hand off to $author-swift-docc-docs. Finish by returning to $format-swift-sources for cleanup." + default_prompt: "Use $format-swift-sources first, then use $structure-swift-sources to split oversized Swift files, normalize directory layout, MARK sections, and structured project-and-file banner headers, and move TODO and FIXME text into ledger files. Start with `scripts/run-workflow.fsx` so the cleanup kind, header policy, and handoff surface resolve consistently. If the request becomes DocC authoring or review work, hand off to $author-swift-docc-docs. Finish by returning to $format-swift-sources for cleanup." diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/references/automation-prompts.md b/plugins/apple-dev-skills/skills/structure-swift-sources/references/automation-prompts.md index 5c6ac3c6d..db65a9dea 100644 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/references/automation-prompts.md +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/references/automation-prompts.md @@ -17,8 +17,8 @@ This should stay sequential. The formatting passes and the structural pass all m - Use a Codex GUI automation or `codex exec` wrapper for the sequence above when the task is large and repeatable. - Keep file splitting itself agent-driven because concern detection and access-control-safe extraction still require reasoning. - Keep deterministic follow-up work, such as running the formatting skill before and after, inside automation. -- Use `scripts/normalize_todo_fixme_ledgers.py --apply` as the deterministic helper when the structure pass includes TODO/FIXME ledger normalization. -- Use `references/file-header-inventory.template.yaml` as the starting point when the structure pass includes deterministic file-header application through `scripts/normalize_swift_file_headers.py --apply --inventory ...`. +- Use `scripts/normalize-swift-structure.fsx --apply` as the deterministic helper when the structure pass includes TODO/FIXME ledger normalization. +- Use `references/file-header-inventory.template.yaml` as the starting point when the structure pass includes deterministic file-header application through `scripts/normalize-swift-structure.fsx --apply --inventory ...`. ## Codex CLI Prompt Template @@ -36,7 +36,7 @@ Then use $structure-swift-sources for: Execution requirements: 1) Establish or confirm the formatting baseline first. -2) Run `scripts/run_workflow.py` first so the cleanup kind, header policy, split thresholds, and handoff surface resolve into one contract. +2) Run `scripts/run-workflow.fsx` first so the cleanup kind, header policy, split thresholds, and handoff surface resolve into one contract. 3) Apply the structure rules from the skill references. 4) If the request becomes symbol-doc or DocC-content work, stop and hand off to $author-swift-docc-docs. 5) If splitting or moving files touches Xcode-managed membership, stop and hand off to $xcode-build-run-workflow. diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/references/customization-flow.md b/plugins/apple-dev-skills/skills/structure-swift-sources/references/customization-flow.md deleted file mode 100644 index 6396a9635..000000000 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/references/customization-flow.md +++ /dev/null @@ -1,39 +0,0 @@ -# Structure Swift Sources Customization Contract - -## Purpose - -Tune the runtime-enforced header policy and split-threshold defaults for the structural-cleanup workflow without turning the skill into a repo-specific one-off. - -## Knobs - -| Knob | Default | Status | Effect | -| --- | --- | --- | --- | -| `fileHeaderMode` | `advisory` | `runtime-enforced` | Controls whether file-header work is recommended or required inside the workflow output. | -| `fileHeaderStyle` | `project-banner` | `runtime-enforced` | Controls the documented header shape. The current runtime supports only the project-and-file banner block-comment form. | -| `fileHeaderCopyrightOwner` | `Gale Williams` | `runtime-enforced` | Controls the copyright owner string rendered in normalized headers. | -| `splitSoftLimit` | `400` | `runtime-enforced` | Controls when the workflow starts strongly recommending a split. | -| `splitHardLimit` | `800` | `runtime-enforced` | Controls when the workflow treats a split as required. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the effective merged customization state at runtime. -- `fileHeaderMode=advisory` keeps file headers as a strong recommendation in the output contract. -- `fileHeaderMode=required` makes missing or malformed file headers part of the required cleanup surface in the output contract. -- `fileHeaderStyle=project-banner` keeps the skill aligned with `references/file-headers.md`. -- `fileHeaderCopyrightOwner` changes the owner string rendered by `scripts/normalize_swift_file_headers.py`. -- `splitSoftLimit` and `splitHardLimit` change the thresholds reported by `scripts/run_workflow.py`, but do not turn file splitting into a deterministic script. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected references so they still describe the same runtime-enforced header policy and split-threshold boundary. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify `scripts/run_workflow.py --request "normalize file headers and split this large view file" --repo-path .` reflects the configured header policy and split thresholds. - -## Validation - -1. Verify file-header policy remains a shape-and-presence rule rather than a promise to auto-author good descriptions from code. -2. Verify DocC-shaped requests still hand off to `author-swift-docc-docs`. -3. Verify Xcode-membership-sensitive requests still hand off to `xcode-build-run-workflow`. diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/references/customization.template.yaml b/plugins/apple-dev-skills/skills/structure-swift-sources/references/customization.template.yaml deleted file mode 100644 index 8db04a8c3..000000000 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/references/customization.template.yaml +++ /dev/null @@ -1,8 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - fileHeaderMode: "advisory" - fileHeaderStyle: "project-banner" - fileHeaderCopyrightOwner: "Gale Williams" - splitSoftLimit: 400 - splitHardLimit: 800 diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/references/file-headers.md b/plugins/apple-dev-skills/skills/structure-swift-sources/references/file-headers.md index 26fc2cb1e..f652107a5 100644 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/references/file-headers.md +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/references/file-headers.md @@ -47,9 +47,9 @@ See Also: Optional related files, symbols, or flows. ## Automation Boundary -- Use `scripts/normalize_swift_file_headers.py` to audit header presence and shape across `.swift` files. +- Use `scripts/normalize-swift-structure.fsx` to audit header presence and shape across `.swift` files. - Start from `references/file-header-inventory.template.yaml` when you want a user-editable inventory file for `--apply` mode. -- Use `scripts/normalize_swift_file_headers.py --apply --inventory <yaml>` when you already have explicit `Purpose` and `Concern` text for each file and want the script to normalize placement and formatting deterministically. +- Use `scripts/normalize-swift-structure.fsx --apply --inventory <yaml>` when you already have explicit `Purpose` and `Concern` text for each file and want the script to normalize placement and formatting deterministically. - The script fills `<Project Name>`, `<File Name>`, and `<YEAR>` deterministically. - The inventory may also provide optional `key_types` and `see_also` entries. - Do not use the script to invent `Purpose` or `Concern` text. The script normalizes shape and placement; the meaning-bearing content still needs to come from actual code understanding or an explicit inventory. @@ -63,7 +63,7 @@ Start from this checked-in template: It is meant to be copied and edited by maintainers or end users before running: ```bash -scripts/normalize_swift_file_headers.py --apply --inventory path/to/headers.yaml +scripts/normalize-swift-structure.fsx --apply --inventory path/to/headers.yaml ``` The expected shape is: diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/references/todo-fixme-ledgers.md b/plugins/apple-dev-skills/skills/structure-swift-sources/references/todo-fixme-ledgers.md index 87c397075..077b89f14 100644 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/references/todo-fixme-ledgers.md +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/references/todo-fixme-ledgers.md @@ -60,6 +60,6 @@ Each ledger entry should include: ## Deterministic Helper -- Use `scripts/normalize_todo_fixme_ledgers.py --apply` when the task is specifically about normalizing supported Swift and Objective-C TODO/FIXME comments into the ledger format above. +- Use `scripts/normalize-swift-structure.fsx --apply` when the task is specifically about normalizing supported Swift and Objective-C TODO/FIXME comments into the ledger format above. - Use the script without `--apply` for a report-only preview. - Use report mode first when you want to audit unresolved roadmap or plan-doc references before mutating source. diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/customization_config.py b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/customization_config.py deleted file mode 100755 index 458b2409b..000000000 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "structure-swift-sources" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize-swift-structure.fsx b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize-swift-structure.fsx new file mode 100644 index 000000000..7df87cfab --- /dev/null +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize-swift-structure.fsx @@ -0,0 +1,47 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json +open System.Text.RegularExpressions + +let args = fsi.CommandLineArgs |> Array.skip 1 +let has flag = args |> Array.contains flag +let value flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let positional = args |> Array.tryFind (fun item -> not (item.StartsWith("--"))) +let root = value "--repo-path" |> Option.orElse positional |> Option.defaultValue "." |> Path.GetFullPath +if not (Directory.Exists root) then eprintfn "Swift source root does not exist: %s" root; exit 2 +let ignored path = path.Split(Path.DirectorySeparatorChar) |> Array.exists (Set.ofList [ ".git"; ".build"; "DerivedData" ] |> Set.contains) +let files = Directory.GetFiles(root, "*.swift", SearchOption.AllDirectories) |> Array.filter (ignored >> not) |> Array.sort +let findings = ResizeArray<string>() +let ledger = ResizeArray<string>() +let todoPattern = Regex(@"^\s*//\s*(TODO|FIXME)\s*:?\s*(.+)$", RegexOptions.IgnoreCase) +for file in files do + let relative = Path.GetRelativePath(root, file) + let original = File.ReadAllText file + let lines = original.Replace("\r\n", "\n").Split('\n') + let expectedPrefix = $"//\n// {Path.GetFileName file}\n//\n// Purpose: Owns the {Path.GetFileNameWithoutExtension file} implementation.\n// Concern: Keep this file focused on one source-level responsibility.\n//\n" + let hasManagedHeader = original.StartsWith("//\n// " + Path.GetFileName file + "\n//\n// Purpose:", StringComparison.Ordinal) + if not hasManagedHeader then findings.Add($"{relative}: missing managed file header") + let rewritten = + lines + |> Array.mapi (fun index line -> + let matchValue = todoPattern.Match line + if matchValue.Success then + let kind = matchValue.Groups[1].Value.ToUpperInvariant() + let message = matchValue.Groups[2].Value.Trim() + ledger.Add($"- [ ] **{kind}** `{relative}:{index + 1}` — {message}") + findings.Add($"{relative}:{index + 1}: inline {kind} requires ledger normalization") + if has "--apply" then "// TODO(ledger): see TODO.md" else line + else line) + |> String.concat "\n" + if has "--apply" then + let withHeader = if hasManagedHeader then rewritten else expectedPrefix + rewritten.TrimStart('\n') + File.WriteAllText(file, withHeader.TrimEnd() + "\n") +if has "--apply" && ledger.Count > 0 then + let ledgerPath = Path.Combine(root, "TODO.md") + let content = "# Source Work Ledger\n\n" + (ledger |> Seq.distinct |> Seq.sort |> String.concat "\n") + "\n" + File.WriteAllText(ledgerPath, content) +let payload = {| status = if findings.Count = 0 || has "--apply" then "success" else "changes-required"; root = root; apply = has "--apply"; files = files.Length; findings = findings.ToArray() |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if findings.Count > 0 && not (has "--apply") then exit 1 diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_swift_file_headers.py b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_swift_file_headers.py deleted file mode 100755 index 465347d10..000000000 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_swift_file_headers.py +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Audit and normalize structured block-comment headers in Swift source files.""" - -from __future__ import annotations - -import argparse -from datetime import date -import json -import re -import sys -from pathlib import Path -from typing import NamedTuple - -import yaml - - -SKIP_DIR_NAMES = {".build", ".swiftpm", "DerivedData"} -STRUCTURED_HEADER_RE = re.compile(r"\A/\*(?P<body>.*?)\*/", re.DOTALL) -COPYRIGHT_RE = re.compile(r"^© (?P<owner>.+) (?P<year>\d{4})$") - - -class HeaderFields(NamedTuple): - project_name: str - file_name: str - copyright_owner: str - copyright_year: int - purpose: str - concern: str - key_types: tuple[str, ...] - see_also: tuple[str, ...] - - -def fail(message: str) -> None: - print(json.dumps({"status": "blocked", "message": message}, indent=2, sort_keys=True), file=sys.stderr) - raise SystemExit(1) - - -def managed_swift_files(root: Path) -> list[Path]: - files: list[Path] = [] - for path in root.rglob("*.swift"): - if path.name == "Package.swift": - continue - if any(part in SKIP_DIR_NAMES for part in path.parts): - continue - files.append(path) - return sorted(files) - - -def parse_structured_header(block_text: str) -> HeaderFields | None: - body = block_text.removeprefix("/*").removesuffix("*/") - project_name = "" - file_name = "" - copyright_owner = "" - copyright_year = 0 - purpose = "" - concern = "" - key_types: tuple[str, ...] = () - see_also: tuple[str, ...] = () - content_lines: list[str] = [] - for raw_line in body.splitlines(): - line = raw_line.strip() - if line.startswith("*"): - line = line.removeprefix("*").strip() - if line: - content_lines.append(line) - - if len(content_lines) < 5: - return None - - project_name = content_lines[0] - file_name = content_lines[1] - copyright_match = COPYRIGHT_RE.match(content_lines[2]) - if not copyright_match: - return None - copyright_owner = copyright_match.group("owner").strip() - copyright_year = int(copyright_match.group("year")) - - for line in content_lines[3:]: - if line.startswith("Concern:"): - concern = line.partition(":")[2].strip() - elif line.startswith("Purpose:"): - purpose = line.partition(":")[2].strip() - elif line.startswith("Key Types:"): - value = line.partition(":")[2].strip() - key_types = tuple(part.strip() for part in value.split(",") if part.strip()) - elif line.startswith("See Also:"): - value = line.partition(":")[2].strip() - see_also = tuple(part.strip() for part in value.split(",") if part.strip()) - if project_name and file_name and copyright_owner and copyright_year and purpose and concern: - return HeaderFields( - project_name=project_name, - file_name=file_name, - copyright_owner=copyright_owner, - copyright_year=copyright_year, - purpose=purpose, - concern=concern, - key_types=key_types, - see_also=see_also, - ) - return None - - -def first_block_comment(content: str) -> tuple[str | None, str]: - stripped = content.lstrip() - leading_gap = content[: len(content) - len(stripped)] - if not stripped.startswith("/*"): - return None, content - match = STRUCTURED_HEADER_RE.match(stripped) - if not match: - return None, content - block = stripped[: match.end()] - remainder = stripped[match.end() :] - return block, leading_gap + remainder - - -def first_preamble_segments(content: str) -> tuple[list[str], str]: - lines = content.splitlines(keepends=True) - index = 0 - segments: list[str] = [] - - while index < len(lines): - line = lines[index] - stripped = line.strip() - if stripped == "": - index += 1 - continue - if stripped.startswith("//"): - start = index - index += 1 - while index < len(lines) and lines[index].strip().startswith("//"): - index += 1 - segments.append("".join(lines[start:index])) - continue - if stripped.startswith("/*"): - start = index - index += 1 - while index < len(lines) and "*/" not in lines[index - 1]: - index += 1 - segments.append("".join(lines[start:index])) - continue - break - - remainder = "".join(lines[index:]) - return segments, remainder - - -def first_structured_header_segment(content: str) -> HeaderFields | None: - segments, _ = first_preamble_segments(content) - for segment in segments: - fields = parse_structured_header(segment.lstrip()) - if fields is not None: - return fields - return None - - -def header_issue_for_content(content: str) -> tuple[str, HeaderFields | None]: - segments, _ = first_preamble_segments(content) - saw_candidate = False - for segment in segments: - stripped = segment.lstrip() - if not stripped.startswith("/*"): - continue - if "Concern:" in stripped or "Purpose:" in stripped or "© " in stripped: - saw_candidate = True - fields = parse_structured_header(stripped) - if fields is not None: - return "compliant", fields - if saw_candidate: - return "malformed-header", None - return "missing-header", None - - -def report_headers(root: Path) -> dict: - files = managed_swift_files(root) - results = [] - counts = {"compliant": 0, "missing-header": 0, "malformed-header": 0} - for path in files: - content = path.read_text(encoding="utf-8") - issue, fields = header_issue_for_content(content) - counts[issue] += 1 - result = {"path": str(path.relative_to(root)), "status": issue} - if fields is not None: - result["project_name"] = fields.project_name - result["file_name"] = fields.file_name - result["copyright_owner"] = fields.copyright_owner - result["copyright_year"] = fields.copyright_year - result["purpose"] = fields.purpose - result["concern"] = fields.concern - if fields.key_types: - result["key_types"] = list(fields.key_types) - if fields.see_also: - result["see_also"] = list(fields.see_also) - results.append(result) - return { - "status": "success", - "files_scanned": len(files), - "counts": counts, - "results": results, - } - - -def load_inventory(path: Path) -> list[dict]: - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in inventory {path}: {exc}") - if not isinstance(loaded, dict) or not isinstance(loaded.get("entries"), list): - fail(f"Inventory {path} must be a mapping with an entries list.") - entries = loaded["entries"] - normalized: list[dict] = [] - for entry in entries: - if not isinstance(entry, dict): - fail("Each inventory entry must be a mapping.") - path_value = str(entry.get("path", "")).strip() - purpose = str(entry.get("purpose", "")).strip() - concern = str(entry.get("concern", "")).strip() - key_types_value = entry.get("key_types", []) - see_also_value = entry.get("see_also", []) - if not path_value or not purpose or not concern: - fail("Each inventory entry must include non-empty path, purpose, and concern values.") - if isinstance(key_types_value, str): - key_types = [item.strip() for item in key_types_value.split(",") if item.strip()] - elif isinstance(key_types_value, list): - key_types = [str(item).strip() for item in key_types_value if str(item).strip()] - else: - fail("Inventory key_types must be a string or list of strings.") - if isinstance(see_also_value, str): - see_also = [item.strip() for item in see_also_value.split(",") if item.strip()] - elif isinstance(see_also_value, list): - see_also = [str(item).strip() for item in see_also_value if str(item).strip()] - else: - fail("Inventory see_also must be a string or list of strings.") - normalized.append( - { - "path": path_value, - "purpose": purpose, - "concern": concern, - "key_types": key_types, - "see_also": see_also, - } - ) - return normalized - - -def render_header( - *, - project_name: str, - file_name: str, - copyright_owner: str, - copyright_year: int, - concern: str, - purpose: str, - key_types: list[str], - see_also: list[str], -) -> str: - lines = [ - "/*", - project_name, - file_name, - f"© {copyright_owner} {copyright_year}", - "", - f"Concern: {concern}", - f"Purpose: {purpose}", - ] - if key_types: - lines.append(f"Key Types: {', '.join(key_types)}") - if see_also: - lines.append(f"See Also: {', '.join(see_also)}") - lines.extend(["*/", ""]) - return "\n".join(lines) - - -def inferred_project_name(root: Path) -> str: - return root.name - - -def inferred_file_name(target: Path) -> str: - return target.name - - -def inferred_copyright_year(existing: HeaderFields | None) -> int: - if existing is not None: - return existing.copyright_year - return date.today().year - - -def apply_header( - *, - root: Path, - target: Path, - content: str, - purpose: str, - concern: str, - copyright_owner: str, - key_types: list[str], - see_also: list[str], -) -> str: - segments, remainder = first_preamble_segments(content) - existing = None - preserved: list[str] = [] - for segment in segments: - parsed = parse_structured_header(segment.lstrip()) if segment.lstrip().startswith("/*") else None - if parsed is not None: - existing = parsed - continue - preserved.append(segment.rstrip("\n")) - - parts = [segment for segment in preserved if segment] - parts.append( - render_header( - project_name=inferred_project_name(root), - file_name=inferred_file_name(target), - copyright_owner=copyright_owner, - copyright_year=inferred_copyright_year(existing), - concern=concern, - purpose=purpose, - key_types=key_types, - see_also=see_also, - ).rstrip("\n") - ) - - rebuilt_prefix = "\n\n".join(parts) - body = remainder.lstrip("\n") - if body: - return rebuilt_prefix + "\n\n" + body - return rebuilt_prefix + "\n" - - -def apply_inventory(root: Path, inventory_path: Path, *, copyright_owner: str = "Gale Williams") -> dict: - created = 0 - updated = 0 - normalized_paths: list[str] = [] - for entry in load_inventory(inventory_path): - relative = Path(entry["path"]) - target = (root / relative).resolve() - if not target.is_file(): - fail(f"Inventory target does not exist: {relative}") - if target.suffix != ".swift" or target.name == "Package.swift": - fail(f"Inventory target must be a managed Swift source file: {relative}") - - before = target.read_text(encoding="utf-8") - status, _ = header_issue_for_content(before) - after = apply_header( - root=root, - target=target, - content=before, - purpose=entry["purpose"], - concern=entry["concern"], - copyright_owner=copyright_owner, - key_types=entry["key_types"], - see_also=entry["see_also"], - ) - target.write_text(after, encoding="utf-8") - normalized_paths.append(str(relative)) - if status == "missing-header": - created += 1 - else: - updated += 1 - - return { - "status": "success", - "created_headers": created, - "updated_headers": updated, - "normalized_paths": normalized_paths, - } - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", default=".", help="Repository root to scan") - parser.add_argument("--apply", action="store_true", help="Apply header normalization from inventory") - parser.add_argument("--inventory", help="YAML inventory file for --apply mode") - parser.add_argument("--copyright-owner", default="Gale Williams", help="Copyright owner for rendered headers") - return parser - - -def main() -> int: - args = build_parser().parse_args() - root = Path(args.root).expanduser().resolve() - if not root.exists(): - fail(f"Root path does not exist: {root}") - - if args.apply: - if not args.inventory: - fail("--inventory is required when using --apply.") - payload = apply_inventory( - root, - Path(args.inventory).expanduser().resolve(), - copyright_owner=str(args.copyright_owner).strip() or "Gale Williams", - ) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - payload = report_headers(root) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_todo_fixme_ledgers.py b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_todo_fixme_ledgers.py deleted file mode 100755 index d071bfbb7..000000000 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/normalize_todo_fixme_ledgers.py +++ /dev/null @@ -1,581 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Normalize Swift and Objective-C TODO/FIXME comments into repo ledger files.""" - -import argparse -import json -import re -import sys -from dataclasses import dataclass, field -from pathlib import Path - - -SUPPORTED_SUFFIXES = (".swift", ".h", ".m", ".mm") -COMMENT_PATTERNS = ( - ( - "line-comment", - re.compile( - r"^(?P<indent>\s*)//\s*(?P<kind>TODO|FIXME):\s*(?P<body>.*?)\s*$", - re.IGNORECASE, - ), - ), - ( - "objc-warning", - re.compile( - r"^(?P<indent>\s*)#warning\s+(?P<kind>TODO|FIXME):\s*(?P<body>.*?)\s*$", - re.IGNORECASE, - ), - ), - ( - "swift-warning", - re.compile( - r'^(?P<indent>\s*)#warning\(\s*"(?P<kind>TODO|FIXME):\s*(?P<body>.*?)"\s*\)\s*$', - re.IGNORECASE, - ), - ), -) -TICKET_RE = re.compile(r"^(?P<ticket>(TODO|FIXME)-(?P<number>\d{4}))(?:\s+(?P<rest>.*))?$", re.IGNORECASE) -ENTRY_HEADER_RE = re.compile(r"^## (?P<ticket>(TODO|FIXME)-\d{4}): (?P<title>.+?)$", re.MULTILINE) -MILESTONE_HEADING_RE = re.compile(r"^## Milestone (?P<number>\d+): (?P<title>.+?)$", re.MULTILINE) -ROADMAP_TOKEN_RE = re.compile(r"\[(?:ROADMAP:)?M(?P<milestone>\d+)(?:-T(?P<ticket>\d+))?\]", re.IGNORECASE) -PLAN_TOKEN_RE = re.compile(r"\[(?:PLAN|DOC):(?P<path>[^\]]+)\]", re.IGNORECASE) -MARKDOWN_LINK_RE = re.compile(r"\[(?P<label>[^\]]+)\]\((?P<target>[^)]+)\)") -LEDGER_HEADERS = { - "TODO": "# TODO Ledger\n\nTrack normalized TODO tickets extracted from Swift and Objective-C sources.\n", - "FIXME": "# FIXME Ledger\n\nTrack normalized FIXME tickets extracted from Swift and Objective-C sources.\n", -} -FIELD_ORDER = ("Status", "File", "Line", "Source", "Detail", "Roadmap", "Plans") -SOURCE_LABELS = { - "line-comment": "line-comment", - "objc-warning": "objc-warning", - "swift-warning": "swift-warning", -} - - -@dataclass -class CommentOccurrence: - kind: str - file_path: Path - line_number: int - indent: str - raw_body: str - detail: str - ticket_id: str | None - source_kind: str - roadmap_links: list[str] = field(default_factory=list) - plan_links: list[str] = field(default_factory=list) - - -@dataclass -class LedgerEntry: - ticket_id: str - kind: str - status: str - file: str - line: int - title: str - detail: str - source: str - roadmap_links: list[str] = field(default_factory=list) - plan_links: list[str] = field(default_factory=list) - - -@dataclass -class ReferenceIssue: - file: str - line: int - token: str - reason: str - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def normalize_kind(kind: str) -> str: - return kind.upper() - - -def normalize_ticket(ticket_id: str) -> str: - return ticket_id.upper() - - -def iter_source_files(root: Path) -> list[Path]: - return sorted( - path - for path in root.rglob("*") - if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES - ) - - -def slugify_heading(text: str) -> str: - slug = re.sub(r"[^\w\s-]", "", text.lower()) - slug = re.sub(r"\s+", "-", slug.strip()) - slug = re.sub(r"-{2,}", "-", slug) - return slug - - -def build_roadmap_index(root: Path) -> dict[int, tuple[str, str]]: - roadmap_path = root / "ROADMAP.md" - if not roadmap_path.exists(): - return {} - - index: dict[int, tuple[str, str]] = {} - text = roadmap_path.read_text(encoding="utf-8") - for match in MILESTONE_HEADING_RE.finditer(text): - number = int(match.group("number")) - title = match.group("title").strip() - heading = f"Milestone {number}: {title}" - index[number] = (title, f"ROADMAP.md#{slugify_heading(heading)}") - return index - - -def parse_link_list(value: str) -> list[str]: - stripped = value.strip() - if not stripped or stripped.lower() == "none": - return [] - return [match.group(0) for match in MARKDOWN_LINK_RE.finditer(stripped)] - - -def render_link_list(links: list[str]) -> str: - return ", ".join(links) if links else "none" - - -def clean_detail(text: str) -> str: - return " ".join(text.split()) - - -def derive_title(detail: str) -> str: - stripped = clean_detail(detail) - if not stripped: - return "Backfill ledger entry" - return stripped[:77] + "..." if len(stripped) > 80 else stripped - - -def merge_links(*link_groups: list[str]) -> list[str]: - merged: list[str] = [] - seen: set[str] = set() - for link_group in link_groups: - for link in link_group: - if link in seen: - continue - seen.add(link) - merged.append(link) - return merged - - -def resolve_roadmap_links(body: str, roadmap_index: dict[int, tuple[str, str]]) -> tuple[list[str], list[ReferenceIssue]]: - links: list[str] = [] - issues: list[ReferenceIssue] = [] - for match in ROADMAP_TOKEN_RE.finditer(body): - milestone_number = int(match.group("milestone")) - ticket_number = match.group("ticket") - token = match.group(0) - milestone = roadmap_index.get(milestone_number) - if milestone is None: - issues.append(ReferenceIssue(file="", line=0, token=token, reason="Roadmap milestone was not found.")) - continue - _, target = milestone - if ticket_number: - label = f"M{milestone_number}-T{ticket_number}" - else: - label = f"Milestone {milestone_number}" - links.append(f"[{label}]({target})") - return links, issues - - -def resolve_plan_links(body: str, root: Path) -> tuple[list[str], list[ReferenceIssue]]: - links: list[str] = [] - issues: list[ReferenceIssue] = [] - for match in PLAN_TOKEN_RE.finditer(body): - raw_path = match.group("path").strip() - token = match.group(0) - if not raw_path: - issues.append(ReferenceIssue(file="", line=0, token=token, reason="Plan-doc reference did not include a path.")) - continue - if raw_path.startswith("/"): - issues.append(ReferenceIssue(file="", line=0, token=token, reason="Plan-doc reference must stay repo-relative.")) - continue - candidate = (root / raw_path).resolve() - try: - relative = candidate.relative_to(root.resolve()) - except ValueError: - issues.append( - ReferenceIssue( - file="", - line=0, - token=token, - reason="Plan-doc reference escaped the repository root.", - ) - ) - continue - if not candidate.exists(): - issues.append( - ReferenceIssue( - file="", - line=0, - token=token, - reason=f"Plan-doc reference does not exist: {relative.as_posix()}", - ) - ) - continue - relative_path = relative.as_posix() - links.append(f"[{relative_path}]({relative_path})") - return links, issues - - -def parse_body_metadata( - *, - kind: str, - body: str, - root: Path, - roadmap_index: dict[int, tuple[str, str]], -) -> tuple[str | None, str, list[str], list[str], list[ReferenceIssue]]: - stripped = body.strip() - ticket_id: str | None = None - metadata_body = stripped - - ticket_match = TICKET_RE.match(stripped) - if ticket_match: - candidate = normalize_ticket(ticket_match.group("ticket")) - if candidate.startswith(f"{kind}-"): - ticket_id = candidate - metadata_body = (ticket_match.group("rest") or "").strip() - - roadmap_links, roadmap_issues = resolve_roadmap_links(metadata_body, roadmap_index) - plan_links, plan_issues = resolve_plan_links(metadata_body, root) - detail = clean_detail(ROADMAP_TOKEN_RE.sub("", PLAN_TOKEN_RE.sub("", metadata_body))) - return ticket_id, detail, roadmap_links, plan_links, roadmap_issues + plan_issues - - -def rewrite_line(source_kind: str, indent: str, kind: str, ticket_id: str) -> str: - if source_kind == "line-comment": - return f"{indent}// {kind}: {ticket_id}" - if source_kind == "objc-warning": - return f"{indent}#warning {kind}: {ticket_id}" - if source_kind == "swift-warning": - return f'{indent}#warning("{kind}: {ticket_id}")' - fail(f"Unsupported source kind for rewrite: {source_kind}") - return "" - - -def scan_comments( - root: Path, - roadmap_index: dict[int, tuple[str, str]], -) -> tuple[list[CommentOccurrence], list[ReferenceIssue]]: - occurrences: list[CommentOccurrence] = [] - issues: list[ReferenceIssue] = [] - - for file_path in iter_source_files(root): - lines = file_path.read_text(encoding="utf-8").splitlines() - for line_number, line in enumerate(lines, start=1): - matched_pattern = None - match = None - for source_kind, pattern in COMMENT_PATTERNS: - candidate = pattern.match(line) - if candidate: - matched_pattern = source_kind - match = candidate - break - if match is None or matched_pattern is None: - continue - - kind = normalize_kind(match.group("kind")) - ticket_id, detail, roadmap_links, plan_links, reference_issues = parse_body_metadata( - kind=kind, - body=match.group("body"), - root=root, - roadmap_index=roadmap_index, - ) - relative_file = file_path.relative_to(root).as_posix() - for issue in reference_issues: - issues.append( - ReferenceIssue( - file=relative_file, - line=line_number, - token=issue.token, - reason=issue.reason, - ) - ) - - occurrences.append( - CommentOccurrence( - kind=kind, - file_path=file_path, - line_number=line_number, - indent=match.group("indent"), - raw_body=match.group("body").strip(), - detail=detail, - ticket_id=ticket_id, - source_kind=matched_pattern, - roadmap_links=roadmap_links, - plan_links=plan_links, - ) - ) - return occurrences, issues - - -def ledger_path(root: Path, kind: str) -> Path: - return root / f"{kind}.md" - - -def parse_ledger(root: Path, kind: str) -> dict[str, LedgerEntry]: - path = ledger_path(root, kind) - if not path.exists(): - return {} - - text = path.read_text(encoding="utf-8") - matches = list(ENTRY_HEADER_RE.finditer(text)) - entries: dict[str, LedgerEntry] = {} - - for index, match in enumerate(matches): - ticket_id = normalize_ticket(match.group("ticket")) - if not ticket_id.startswith(f"{kind}-"): - continue - block_start = match.end() - block_end = matches[index + 1].start() if index + 1 < len(matches) else len(text) - block = text[block_start:block_end] - fields: dict[str, str] = {} - for raw_line in block.splitlines(): - if not raw_line.startswith("- "): - continue - key, separator, value = raw_line[2:].partition(":") - if not separator: - continue - fields[key.strip().lower()] = value.strip() - - file_value = fields.get("file", "`unknown`").strip("`") - line_value = fields.get("line", "`0`").strip("`") - try: - line_number = int(line_value) - except ValueError: - line_number = 0 - - entries[ticket_id] = LedgerEntry( - ticket_id=ticket_id, - kind=kind, - status=fields.get("status", "open"), - file=file_value, - line=line_number, - title=match.group("title").strip(), - detail=fields.get("detail", f"Backfill detail for {ticket_id}."), - source=fields.get("source", "line-comment").strip("`"), - roadmap_links=parse_link_list(fields.get("roadmap", "none")), - plan_links=parse_link_list(fields.get("plans", "none")), - ) - return entries - - -def next_ticket_id(kind: str, used_ids: set[str]) -> str: - prefix = f"{kind}-" - numbers = [ - int(ticket_id.split("-")[1]) - for ticket_id in used_ids - if ticket_id.startswith(prefix) and TICKET_RE.match(ticket_id) - ] - next_number = (max(numbers) if numbers else 0) + 1 - return f"{kind}-{next_number:04d}" - - -def render_ledger(entries: dict[str, LedgerEntry], kind: str) -> str: - header = LEDGER_HEADERS[kind] - ordered = sorted(entries.values(), key=lambda entry: entry.ticket_id) - body: list[str] = [] - for entry in ordered: - fields = { - "Status": entry.status, - "File": f"`{entry.file}`", - "Line": f"`{entry.line}`", - "Source": f"`{entry.source}`", - "Detail": entry.detail, - "Roadmap": render_link_list(entry.roadmap_links), - "Plans": render_link_list(entry.plan_links), - } - body.append( - "\n".join( - [f"## {entry.ticket_id}: {entry.title}"] - + [f"- {field}: {fields[field]}" for field in FIELD_ORDER] - ) - ) - if not body: - return header + "\n" - return header + "\n\n" + "\n\n".join(body) + "\n" - - -def format_reference_issues(issues: list[ReferenceIssue]) -> str: - formatted = [ - f"{issue.file}:{issue.line}: {issue.token} -> {issue.reason}" - for issue in issues - ] - return "\n".join(formatted) - - -def apply_normalization(root: Path) -> dict: - roadmap_index = build_roadmap_index(root) - occurrences, issues = scan_comments(root, roadmap_index) - if issues: - fail( - "Cannot apply TODO/FIXME normalization until all explicit roadmap and plan-doc " - "references resolve cleanly:\n" - f"{format_reference_issues(issues)}" - ) - - ledgers = {kind: parse_ledger(root, kind) for kind in ("TODO", "FIXME")} - used_ids = set() - for entries in ledgers.values(): - used_ids.update(entries.keys()) - for occurrence in occurrences: - if occurrence.ticket_id: - used_ids.add(occurrence.ticket_id) - - rewrites_by_file: dict[Path, dict[int, str]] = {} - created_entries: list[str] = [] - refreshed_entries: list[str] = [] - source_counts = {label: 0 for label in SOURCE_LABELS.values()} - - for occurrence in occurrences: - kind = occurrence.kind - relative_file = occurrence.file_path.relative_to(root).as_posix() - ticket_id = occurrence.ticket_id - existing_entry = ledgers[kind].get(ticket_id) if ticket_id else None - - if ticket_id is None: - ticket_id = next_ticket_id(kind, used_ids) - used_ids.add(ticket_id) - detail = occurrence.detail or occurrence.raw_body - entry = LedgerEntry( - ticket_id=ticket_id, - kind=kind, - status="open", - file=relative_file, - line=occurrence.line_number, - title=derive_title(detail), - detail=detail, - source=occurrence.source_kind, - roadmap_links=occurrence.roadmap_links, - plan_links=occurrence.plan_links, - ) - ledgers[kind][ticket_id] = entry - created_entries.append(ticket_id) - else: - entry = existing_entry or LedgerEntry( - ticket_id=ticket_id, - kind=kind, - status="open", - file=relative_file, - line=occurrence.line_number, - title="Backfill ledger entry", - detail=f"Backfill detail for {ticket_id}.", - source=occurrence.source_kind, - ) - ledgers[kind][ticket_id] = entry - refreshed_entries.append(ticket_id) - if occurrence.detail: - entry.detail = occurrence.detail - entry.title = derive_title(occurrence.detail) - - entry.file = relative_file - entry.line = occurrence.line_number - entry.source = occurrence.source_kind - entry.roadmap_links = merge_links(entry.roadmap_links, occurrence.roadmap_links) - entry.plan_links = merge_links(entry.plan_links, occurrence.plan_links) - - rewrites_by_file.setdefault(occurrence.file_path, {})[occurrence.line_number] = rewrite_line( - occurrence.source_kind, - occurrence.indent, - kind, - ticket_id, - ) - source_counts[occurrence.source_kind] += 1 - - for file_path, rewrites in rewrites_by_file.items(): - lines = file_path.read_text(encoding="utf-8").splitlines() - for line_number, replacement in rewrites.items(): - lines[line_number - 1] = replacement - file_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - for kind in ("TODO", "FIXME"): - ledger_path(root, kind).write_text(render_ledger(ledgers[kind], kind), encoding="utf-8") - - return { - "status": "success", - "files_scanned": len(iter_source_files(root)), - "comment_count": len(occurrences), - "created_entries": sorted(created_entries), - "refreshed_entries": sorted(set(refreshed_entries)), - "ledger_files": [f"{kind}.md" for kind in ("TODO", "FIXME")], - "source_counts": source_counts, - } - - -def report_normalization(root: Path) -> dict: - roadmap_index = build_roadmap_index(root) - occurrences, issues = scan_comments(root, roadmap_index) - counts = {"TODO": 0, "FIXME": 0} - existing_ids = {"TODO": 0, "FIXME": 0} - textual_comments = {"TODO": 0, "FIXME": 0} - source_counts = {label: 0 for label in SOURCE_LABELS.values()} - linked_roadmap = 0 - linked_plans = 0 - - for occurrence in occurrences: - counts[occurrence.kind] += 1 - source_counts[occurrence.source_kind] += 1 - if occurrence.ticket_id: - existing_ids[occurrence.kind] += 1 - else: - textual_comments[occurrence.kind] += 1 - if occurrence.roadmap_links: - linked_roadmap += 1 - if occurrence.plan_links: - linked_plans += 1 - - return { - "status": "success", - "files_scanned": len(iter_source_files(root)), - "comment_count": len(occurrences), - "counts": counts, - "existing_ids": existing_ids, - "textual_comments": textual_comments, - "ledger_files": [f"{kind}.md" for kind in ("TODO", "FIXME")], - "source_counts": source_counts, - "linked_roadmap_comments": linked_roadmap, - "linked_plan_comments": linked_plans, - "unresolved_references": [ - { - "file": issue.file, - "line": issue.line, - "token": issue.token, - "reason": issue.reason, - } - for issue in issues - ], - } - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path.cwd(), help="Repository root to normalize.") - parser.add_argument( - "--apply", - action="store_true", - help="Rewrite Swift and Objective-C comments and refresh TODO.md / FIXME.md. Defaults to report-only mode.", - ) - return parser - - -def main() -> None: - args = build_parser().parse_args() - root = args.root.resolve() - if not root.is_dir(): - fail(f"Expected --root to be a directory: {root}") - - payload = apply_normalization(root) if args.apply else report_normalization(root) - print(json.dumps(payload, indent=2, sort_keys=True)) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run-workflow.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run-workflow.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run_workflow.py deleted file mode 100755 index 15f606b1c..000000000 --- a/plugins/apple-dev-skills/skills/structure-swift-sources/scripts/run_workflow.py +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Runtime workflow policy engine for structure-swift-sources.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import customization_config - - -VALID_CLEANUP_KINDS = { - "repo-layout-cleanup", - "large-file-split", - "section-and-mark-normalization", - "file-header-normalization", - "todo-fixme-ledger-extraction", - "combined-source-hygiene-pass", -} -VALID_SPLIT_MODES = {"advisory", "required", "full-pass"} -VALID_TODO_FIXME_MODES = {"report-only", "rewrite-ledgers", "normalize-existing"} -VALID_FILE_HEADER_MODES = {"advisory", "required"} -VALID_FILE_HEADER_STYLES = {"project-banner"} - - -def normalize_request_text(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def request_implies_docc_work(text: str) -> bool: - needles = ( - "docc", - "symbol docs", - "symbol documentation", - "doc comments", - "parameter docs", - "landing page", - "topic group", - "docc review", - ) - return any(needle in text for needle in needles) - - -def request_implies_xcode_execution_handoff(text: str) -> bool: - needles = ( - "target membership", - "add to target", - "remove from target", - "xcode target", - "pbxproj", - "project membership", - "file membership", - "scheme validation", - ) - return any(needle in text for needle in needles) - - -def infer_cleanup_kind(request: str | None) -> str | None: - text = normalize_request_text(request) - if not text: - return None - - hits: list[str] = [] - - if any(token in text for token in ("layout", "reorganize", "move files", "directory shape", "repo shape")): - hits.append("repo-layout-cleanup") - if any(token in text for token in ("split", "oversized file", "too large", "extension file")): - hits.append("large-file-split") - if any(token in text for token in ("mark", "// mark", "section group", "declaration grouping")): - hits.append("section-and-mark-normalization") - if any(token in text for token in ("file header", "header comment", "block-comment header", "purpose header", "concern header")): - hits.append("file-header-normalization") - if "todo" in text or "fixme" in text or "ledger" in text: - hits.append("todo-fixme-ledger-extraction") - - unique_hits = sorted(set(hits)) - if not unique_hits: - return None - if len(unique_hits) > 1: - return "combined-source-hygiene-pass" - return unique_hits[0] - - -def detect_repo_state(repo_path: str | None) -> dict: - if not repo_path: - return { - "requested_root": None, - "resolved_root": None, - "package_manifest": None, - "workspace": None, - "project": None, - "swift_files": 0, - } - - requested = Path(repo_path).expanduser().resolve() - existing = requested - while not existing.exists() and existing != existing.parent: - existing = existing.parent - if not existing.exists(): - return { - "requested_root": str(requested), - "resolved_root": None, - "package_manifest": None, - "workspace": None, - "project": None, - "swift_files": 0, - } - - candidate = existing if existing.is_dir() else existing.parent - package_manifest = candidate / "Package.swift" - workspaces = sorted(candidate.rglob("*.xcworkspace"), key=str) - projects = sorted(candidate.rglob("*.xcodeproj"), key=str) - swift_files = [ - path for path in candidate.rglob("*.swift") if path.name != "Package.swift" and ".build" not in path.parts - ] - return { - "requested_root": str(requested), - "resolved_root": str(candidate), - "package_manifest": str(package_manifest) if package_manifest.exists() else None, - "workspace": str(workspaces[0]) if workspaces else None, - "project": str(projects[0]) if projects else None, - "swift_files": len(swift_files), - } - - -def load_effective_config() -> dict: - effective = customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - customization_config.validate_config(effective, allow_partial=False) - return effective - - -def validated_runtime_settings() -> dict: - settings = load_effective_config().get("settings", {}) - file_header_mode = str(settings.get("fileHeaderMode", "advisory")) - file_header_style = str(settings.get("fileHeaderStyle", "project-banner")) - file_header_copyright_owner = str(settings.get("fileHeaderCopyrightOwner", "Gale Williams")).strip() - try: - split_soft_limit = int(settings.get("splitSoftLimit", 400)) - split_hard_limit = int(settings.get("splitHardLimit", 800)) - except (TypeError, ValueError): - split_soft_limit = 400 - split_hard_limit = 800 - - if file_header_mode not in VALID_FILE_HEADER_MODES: - file_header_mode = "advisory" - if file_header_style not in VALID_FILE_HEADER_STYLES: - file_header_style = "project-banner" - if not file_header_copyright_owner: - file_header_copyright_owner = "Gale Williams" - if split_soft_limit < 1: - split_soft_limit = 400 - if split_hard_limit <= split_soft_limit: - split_hard_limit = max(split_soft_limit + 1, 800) - - return { - "fileHeaderMode": file_header_mode, - "fileHeaderStyle": file_header_style, - "fileHeaderCopyrightOwner": file_header_copyright_owner, - "splitSoftLimit": split_soft_limit, - "splitHardLimit": split_hard_limit, - } - - -def helper_scripts_for(cleanup_kind: str) -> list[str]: - helpers = ["scripts/run_workflow.py"] - if cleanup_kind in {"file-header-normalization", "combined-source-hygiene-pass"}: - helpers.append("scripts/normalize_swift_file_headers.py") - if cleanup_kind in {"todo-fixme-ledger-extraction", "combined-source-hygiene-pass"}: - helpers.append("scripts/normalize_todo_fixme_ledgers.py") - return helpers - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-path") - parser.add_argument("--cleanup-kind", choices=sorted(VALID_CLEANUP_KINDS)) - parser.add_argument("--target-scope") - parser.add_argument("--split-mode", choices=sorted(VALID_SPLIT_MODES)) - parser.add_argument("--todo-fixme-mode", choices=sorted(VALID_TODO_FIXME_MODES)) - parser.add_argument("--request") - return parser - - -def main() -> int: - args = build_parser().parse_args() - settings = validated_runtime_settings() - repo_state = detect_repo_state(args.repo_path) - cleanup_kind = args.cleanup_kind or infer_cleanup_kind(args.request) - cleanup_kind_source = "explicit" if args.cleanup_kind else ("inferred" if cleanup_kind else "missing") - request_text = normalize_request_text(args.request) - - if request_implies_docc_work(request_text): - payload = { - "status": "handoff", - "path_type": "primary", - "output": { - "cleanup_kind": cleanup_kind, - "cleanup_kind_source": cleanup_kind_source, - "repo_state": repo_state, - "header_policy": { - "mode": settings["fileHeaderMode"], - "style": settings["fileHeaderStyle"], - "copyright_owner": settings["fileHeaderCopyrightOwner"], - }, - "split_thresholds": { - "soft_limit": settings["splitSoftLimit"], - "hard_limit": settings["splitHardLimit"], - }, - "helper_scripts": helper_scripts_for(cleanup_kind or "combined-source-hygiene-pass"), - "recommended_skill": "author-swift-docc-docs", - "next_step": "Use author-swift-docc-docs because this request is about symbol docs or DocC content rather than file layout and source-structure cleanup.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - if request_implies_xcode_execution_handoff(request_text): - payload = { - "status": "handoff", - "path_type": "primary", - "output": { - "cleanup_kind": cleanup_kind, - "cleanup_kind_source": cleanup_kind_source, - "repo_state": repo_state, - "header_policy": { - "mode": settings["fileHeaderMode"], - "style": settings["fileHeaderStyle"], - "copyright_owner": settings["fileHeaderCopyrightOwner"], - }, - "split_thresholds": { - "soft_limit": settings["splitSoftLimit"], - "hard_limit": settings["splitHardLimit"], - }, - "helper_scripts": helper_scripts_for(cleanup_kind or "combined-source-hygiene-pass"), - "recommended_skill": "xcode-build-run-workflow", - "next_step": "Use xcode-build-run-workflow because this request touches target membership, project membership, or other Xcode-managed project-integrity follow-through.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - if cleanup_kind is None: - payload = { - "status": "blocked", - "path_type": "primary", - "output": { - "cleanup_kind": cleanup_kind, - "cleanup_kind_source": cleanup_kind_source, - "repo_state": repo_state, - "header_policy": { - "mode": settings["fileHeaderMode"], - "style": settings["fileHeaderStyle"], - "copyright_owner": settings["fileHeaderCopyrightOwner"], - }, - "split_thresholds": { - "soft_limit": settings["splitSoftLimit"], - "hard_limit": settings["splitHardLimit"], - }, - "helper_scripts": ["scripts/run_workflow.py"], - "recommended_skill": None, - "next_step": "Pass --cleanup-kind explicitly or describe whether you need repo layout cleanup, large-file splitting, MARK normalization, file-header normalization, TODO/FIXME ledger extraction, or a combined hygiene pass.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - target_scope = args.target_scope or "repo-root" - split_mode = args.split_mode or ("full-pass" if cleanup_kind == "combined-source-hygiene-pass" else "advisory") - todo_fixme_mode = args.todo_fixme_mode or ( - "report-only" if cleanup_kind != "todo-fixme-ledger-extraction" else "normalize-existing" - ) - recommended_path = ( - "Use format-swift-sources first, then apply the structure references for the resolved cleanup kind, " - "use deterministic helpers only where the skill documents them, and finish by returning to format-swift-sources." - ) - next_step = "Stay in structure-swift-sources and apply the resolved structure rules locally." - if cleanup_kind == "file-header-normalization": - next_step = ( - "Audit headers with scripts/normalize_swift_file_headers.py first, then apply header normalization " - "with an explicit inventory when you already have trusted purpose and concern text." - ) - elif cleanup_kind == "todo-fixme-ledger-extraction": - next_step = "Run scripts/normalize_todo_fixme_ledgers.py in report mode first, then apply when the references are clean." - - payload = { - "status": "success", - "path_type": "primary", - "output": { - "cleanup_kind": cleanup_kind, - "cleanup_kind_source": cleanup_kind_source, - "repo_state": repo_state, - "target_scope": target_scope, - "split_mode": split_mode, - "todo_fixme_mode": todo_fixme_mode, - "recommended_path": recommended_path, - "header_policy": { - "mode": settings["fileHeaderMode"], - "style": settings["fileHeaderStyle"], - "copyright_owner": settings["fileHeaderCopyrightOwner"], - }, - "split_thresholds": { - "soft_limit": settings["splitSoftLimit"], - "hard_limit": settings["splitHardLimit"], - }, - "helper_scripts": helper_scripts_for(cleanup_kind), - "verification": "Finish by returning to format-swift-sources so the moved, split, or header-normalized files end in a clean formatting state.", - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/SKILL.md index e116b6141..c56f046ec 100644 --- a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/SKILL.md @@ -7,7 +7,7 @@ description: Guide ordinary build, run, manifest, dependency, resource, Metal-pa ## Purpose -Use this skill as the primary execution workflow for ordinary non-testing work in existing Swift package components. Keep it focused on manifest and dependency changes, package resources, builds, runs, and Release-versus-Debug validation. `scripts/run_workflow.py` resolves the nearest package root and plans commands without classifying the containing repository. +Use this skill as the primary execution workflow for ordinary non-testing work in existing Swift package components. Keep it focused on manifest and dependency changes, package resources, builds, runs, and Release-versus-Debug validation. `scripts/run-workflow.fsx` resolves the nearest package root and plans commands without classifying the containing repository. ## When To Use @@ -52,7 +52,7 @@ Use this skill as the primary execution workflow for ordinary non-testing work i - preserve its simplicity-first, shape-preserving, and anti-ceremony Swift guidance - preserve its explicit `swiftLanguageModes: [.v6]` package-manifest default and prefer that spelling over the legacy `swiftLanguageVersions` alias on current manifest surfaces - preserve its package-appropriate logging, telemetry, and structured-concurrency guidance -4. Run `scripts/run_workflow.py` to resolve the nearest package root, confirm the request stays on the build/run surface, and plan the SwiftPM command path. +4. Run `scripts/run-workflow.fsx` to resolve the nearest package root, confirm the request stays on the build/run surface, and plan the SwiftPM command path. 5. Use `references/cli-command-matrix.md` for agent-executed SwiftPM commands and terminal-first editor workflows. 6. Use `references/package-resources-testing-and-builds.md` when the request touches package resources, Metal artifacts, `Bundle.module`, or Debug/Release and tagged-release validation. 7. Apply `../../shared/execution-surface-routing.md`; use `xcode-build-run-workflow` only when the requested operation needs Xcode-owned state. @@ -69,7 +69,7 @@ Use this skill as the primary execution workflow for ordinary non-testing work i - `request`: optional short natural-language request text used to infer `operation_type` when the explicit operation is omitted. - `repo_root`: optional absolute path for the target package repo. - Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` + - runtime entrypoint: executable `scripts/run-workflow.fsx` - `repo_root=.` when omitted - the runtime may infer `operation_type` from `--request` text when the request wording is clear enough - package execution prefers `swift package`, `swift build`, and `swift run` @@ -118,12 +118,9 @@ Use this skill as the primary execution workflow for ordinary non-testing work i - Recommend `bootstrap-xcode-workspace --operation create --component-kind library` when the repository still needs to be created from scratch. - When maintaining this repository itself, refresh guidance-sync consumers after substantial package-policy changes and keep the top-level export-surface docs aligned. Do not tell users to rely on repo-local installer workflows; this repository does not ship them. -## Customization +## Fixed Policy -- Use `references/customization.template.yaml`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` reads customization state, but the current workflow keeps a fixed SwiftPM-first policy and does not expose ordinary user-facing knobs yet. -- Run the Python wrapper and customization entrypoints through `uv`, because they rely on inline `PyYAML` script metadata rather than a repo-global Python environment. +- `scripts/run-workflow.fsx` enforces the fixed SwiftPM-first policy without runtime customization. ## References @@ -136,8 +133,6 @@ Use this skill as the primary execution workflow for ordinary non-testing work i ### Contract References -- `references/customization.template.yaml` - ### Support References - Recommend `references/snippets/apple-swift-package-core.md` when the user needs reusable SwiftPM baseline policy wording in an end-user repo. @@ -146,5 +141,4 @@ Use this skill as the primary execution workflow for ordinary non-testing work i ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` diff --git a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization-flow.md deleted file mode 100644 index 86093f38a..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization-flow.md +++ /dev/null @@ -1,29 +0,0 @@ -# Swift Package Build Run Workflow Customization Contract - -## Purpose - -Document the fixed SwiftPM-first policy defaults for package build and run execution plus Xcode handoff behavior. - -## Knobs - -This skill does not expose ordinary user-facing customization knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` still loads customization state, but the current workflow uses fixed SwiftPM-first build/run defaults rather than ordinary user-facing customization knobs. -- SwiftPM command execution remains agent-side and is not performed by the local runtime script. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected workflow references to reflect the approved package build/run policy change. -3. Keep `references/customization.template.yaml` present for install-surface consistency even when `settings` is empty. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify `scripts/run_workflow.py --operation-type build --dry-run` still emits the fixed SwiftPM-first build/run workflow defaults. - -## Validation - -1. Verify the docs still describe a SwiftPM-first build/run workflow. -2. Verify the Xcode handoff boundary is stated consistently across the skill and references. -3. Verify `scripts/run_workflow.py` reflects the fixed workflow defaults described above. diff --git a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/customization_config.py deleted file mode 100755 index 077430ebc..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swift-package-build-run-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run-workflow.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run-workflow.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run_workflow.py deleted file mode 100755 index c5b9d2e50..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-build-run-workflow/scripts/run_workflow.py +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Runtime workflow policy engine for swift-package-build-run-workflow.""" - -from __future__ import annotations - -import argparse -import json -import shlex -import sys -from pathlib import Path - -import customization_config - - -VALID_OPERATION_TYPES = { - "package-inspection", - "read-search", - "manifest-dependencies", - "build", - "run", - "plugin", - "toolchain-management", - "mutation", -} - - -def normalize_request_text(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def infer_operation_type_from_request(request: str | None) -> str | None: - text = normalize_request_text(request) - if not text: - return None - - checks: list[tuple[str, tuple[str, ...]]] = [ - ("run", (" run", "launch", "execute", "start")), - ("plugin", ("plugin", "plugins", "macro", "macros", "trait", "traits", "generated source", "codegen")), - ("toolchain-management", ("toolchain", "swift version", "xcrun", "xcodebuild", "metal toolchain", "sdk")), - ("manifest-dependencies", ("package.swift", "manifest", "dependency", "dependencies", "add package", "add target", "resolve", "update package", "package resource", "bundle.module", "metallib", "resource.")), - ("package-inspection", ("describe", "dump-package", "show dependencies", "inspect package", "inspect the package", "package graph")), - ("read-search", ("read", "search", "grep", "find", "lookup", "trace")), - ("build", ("build", "compile", "release build", "debug build", "artifact")), - ("mutation", ("edit", "change", "modify", "rewrite", "refactor", "rename", "move", "add file")), - ] - - padded = f" {text} " - if any(needle in padded for needle in (" plugin", " macro", " trait", " generated source", " codegen")): - return "plugin" - if any(needle in padded for needle in (" test", " tests", "testing", "xctest", "swift testing", "xctestplan", "spec")): - return "test" - for operation_type, needles in checks: - if any(needle in padded for needle in needles): - return operation_type - return None - - -def load_effective_config() -> dict: - return customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - -def shell_join(parts: list[str]) -> str: - return " ".join(shlex.quote(part) for part in parts) - - -def first_matching_file(root: Path, pattern: str) -> list[str]: - return sorted(str(path) for path in root.rglob(pattern)) - - -def infer_package_root(repo_root: str | None) -> tuple[Path, Path | None]: - requested = Path(repo_root or ".").expanduser().resolve() - candidate = requested if requested.is_dir() else requested.parent - - for current in (candidate, *candidate.parents): - if (current / "Package.swift").exists(): - return requested, current - - descendants = sorted( - requested.rglob("Package.swift"), - key=lambda path: (len(path.relative_to(requested).parts), str(path)), - ) - if descendants: - return requested, descendants[0].parent - return requested, None - - -def discover_package_context(repo_root: str | None) -> dict: - requested_root, package_root = infer_package_root(repo_root) - if not requested_root.exists(): - return { - "requested_root": str(requested_root), - "package_root": None, - "exists": False, - "has_package": False, - "xctestplans": [], - "metal_sources": [], - "metal_libraries": [], - "source_targets": [], - "test_targets": [], - "reason": "repo-root-missing", - } - - scan_root = package_root or requested_root - has_package = package_root is not None - sources_dir = scan_root / "Sources" - tests_dir = scan_root / "Tests" - source_targets = sorted(path.name for path in sources_dir.iterdir() if path.is_dir()) if sources_dir.exists() else [] - test_targets = sorted(path.name for path in tests_dir.iterdir() if path.is_dir()) if tests_dir.exists() else [] - xctestplans = first_matching_file(scan_root, "*.xctestplan") - metal_sources = first_matching_file(scan_root, "*.metal") - metal_libraries = first_matching_file(scan_root, "*.metallib") - - return { - "requested_root": str(requested_root), - "package_root": str(scan_root) if has_package else None, - "exists": True, - "has_package": has_package, - "xctestplans": xctestplans, - "metal_sources": metal_sources, - "metal_libraries": metal_libraries, - "source_targets": source_targets, - "test_targets": test_targets, - "reason": ( - "package-root-inferred" - if has_package and scan_root != requested_root - else "ok" - if has_package - else "package-swift-missing" - ), - } - - -def inferred_package_name(package_context: dict) -> str | None: - root = package_context.get("package_root") - return Path(root).name if root else None - - -def request_mentions_resources(request: str | None) -> bool: - text = normalize_request_text(request) - padded = f" {text} " - return any( - needle in padded - for needle in ( - " resource", - " resources", - " bundle.module", - " process(", - " copy(", - " embedincode", - " asset", - " assets", - " fixture", - " fixtures", - " metallib", - ) - ) - - -def infer_build_run_handoff(package_context: dict, request: str | None, operation_type: str) -> str | None: - text = normalize_request_text(request) - if operation_type in {"build", "run", "toolchain-management", "manifest-dependencies"}: - if package_context["metal_sources"] and any( - token in text - for token in ( - " metal ", - " shader", - " compile metal", - " build metal", - " metal toolchain", - " metallib", - ) - ): - return "Use xcode-build-run-workflow because this request touches Metal compilation or Apple-managed Metal toolchain behavior." - if package_context["xctestplans"] and "test plan" in text: - return "Use xcode-build-run-workflow because this package repo already carries .xctestplan coverage and the request is crossing into Xcode-managed package behavior." - if any( - token in text - for token in ( - " xcode target", - " target membership", - " build phase", - " resource inclusion", - " copy into app", - " bundle in app", - ) - ): - return "Use xcode-build-run-workflow because this package-resource request is crossing into Xcode-managed target or bundle integration." - return None - - -def build_commands(operation_type: str, package_context: dict, request: str | None) -> list[str]: - inferred_target = package_context["source_targets"][0] if len(package_context["source_targets"]) == 1 else "<target>" - resource_focused = request_mentions_resources(request) - if operation_type == "package-inspection": - return ["swift package describe", "swift package dump-package"] - if operation_type == "read-search": - return ["swift package describe"] - if operation_type == "manifest-dependencies": - commands = [ - "swift package dump-package", - "swift package add-dependency <url>", - "swift package resolve", - "swift package update", - ] - if resource_focused: - commands.append("Review Package.swift resource declarations and keep Bundle.module access aligned with the owning target.") - return commands - if operation_type == "build": - commands = ["swift build"] - if resource_focused: - commands.append("swift package dump-package") - commands.append("Verify Package.swift resource declarations for Resource.process(...), Resource.copy(...), or Resource.embedInCode(...).") - commands.append("Verify resource loading paths use Bundle.module and that test fixtures stay under the owning test target.") - if package_context["metal_libraries"]: - commands.append("Verify bundled .metallib resources are declared intentionally in Package.swift and loaded through Bundle.module.") - return commands - if operation_type == "run": - commands = [f"swift run {inferred_target}"] - if resource_focused: - commands.append("swift package dump-package") - return commands - if operation_type == "plugin": - return ["swift package plugin --list"] - if operation_type == "toolchain-management": - commands = ["swift --version", "swift package --help", "xcrun --find swift"] - if package_context["metal_sources"] or package_context["metal_libraries"]: - commands.append("xcrun --find metal") - return commands - if operation_type == "mutation": - return [ - "Edit package sources or Package.swift directly when the change stays inside SwiftPM-managed scope.", - shell_join(["swift", "package", "dump-package"]), - ] - return [] - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--operation-type", choices=sorted(VALID_OPERATION_TYPES)) - parser.add_argument("--request") - parser.add_argument("--repo-root") - parser.add_argument("--dry-run", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - load_effective_config() - - inferred_operation_type = infer_operation_type_from_request(args.request) - operation_type = args.operation_type or inferred_operation_type - - if operation_type is None: - payload = { - "status": "blocked", - "path_type": "primary", - "output": { - "operation_type": None, - "operation_type_source": "missing", - "package_context": discover_package_context(args.repo_root), - "planned_commands": [], - "next_step": "Pass --operation-type explicitly or provide --request text that makes the intended SwiftPM workflow obvious.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - if operation_type == "test": - payload = { - "status": "handoff", - "path_type": "fallback", - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "package_context": discover_package_context(args.repo_root), - "planned_commands": [], - "next_step": "Use swift-package-testing-workflow because this request is primarily about package tests.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - if operation_type == "plugin": - payload = { - "status": "handoff", - "path_type": "fallback", - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "package_context": discover_package_context(args.repo_root), - "planned_commands": [], - "next_step": "Use swift-package-extension-workflow because this request is primarily about a package plugin, macro, trait, generated source, or plugin permission.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - package_context = discover_package_context(args.repo_root) - status = "success" - path_type = "primary" - next_step = "Proceed with the SwiftPM-first path." - specialized_handoff = infer_build_run_handoff(package_context, args.request, operation_type) - - if not package_context["exists"]: - status = "blocked" - next_step = "Resolve the repo root before continuing." - elif not package_context["has_package"]: - status = "blocked" - next_step = "Use a Swift package repo with Package.swift at the selected root." - elif specialized_handoff: - status = "handoff" - next_step = specialized_handoff - - payload = { - "status": status, - "path_type": path_type, - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "package_context": package_context, - "planned_commands": build_commands(operation_type, package_context, args.request), - "inferred_context": { - "package_name": inferred_package_name(package_context), - "primary_target": package_context["source_targets"][0] if len(package_context["source_targets"]) == 1 else None, - "has_xcode_test_plan": bool(package_context["xctestplans"]), - "has_metal_sources": bool(package_context["metal_sources"]), - "has_bundled_metallib": bool(package_context["metal_libraries"]), - "resource_request": request_mentions_resources(args.request), - }, - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if status != "blocked" else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/SKILL.md index dc54dabd1..4356b69c0 100644 --- a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/SKILL.md @@ -32,7 +32,7 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or - Do not assume the two toolchains expose identical SwiftPM commands, flags, manifest APIs, macro support, or plugin behavior. 3. Read the relevant official SwiftPM, Swift Evolution, or Apple/Xcode documentation and state the behavior relied on before editing. 4. Classify the primary extension concern as `build-tool-plugin`, `command-plugin`, `macro`, `traits`, or `generated-source`. -5. Run `scripts/run_workflow.py` for nearest-package resolution and a non-mutating command plan. +5. Run the managed FSX planner through the owning repository recipe for nearest-package resolution and a non-mutating command plan. 6. Load only the reference needed for the selected concern: - plugins: `references/package-plugins-build-command-and-xcode.md` - permissions: `references/plugin-permissions-sandbox-and-outputs.md` @@ -77,10 +77,8 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or - Hand Xcode-managed builds to `xcode-build-run-workflow` and Xcode-native tests to `xcode-testing-workflow` with the exact package, plugin, macro, trait, scheme, and destination context. - Use `format-swift-sources` for formatter-specific behavior without duplicating the general plugin permission model. -## Customization +## Fixed Policy -- Use `references/customization.template.yaml` and `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. - The workflow currently keeps fixed package-first and least-permission defaults. ## References @@ -96,9 +94,6 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or ### Contract References -- `references/customization.template.yaml` -- `references/customization-flow.md` - ### Support References - Recommend `references/snippets/apple-swift-package-core.md` when reusable package policy is needed in an end-user repo. @@ -106,5 +101,4 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` diff --git a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization-flow.md deleted file mode 100644 index 9428917f4..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization-flow.md +++ /dev/null @@ -1,22 +0,0 @@ -# Swift Package Extension Workflow Customization Contract - -## Purpose - -Keep package-first, dual-toolchain, least-permission defaults explicit. - -## Knobs - -This skill does not expose ordinary user-facing customization knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the state but keeps fixed routing and command-planning policy. -- Commands remain agent-executed; the runtime script does not mutate packages or invoke plugins. - -## Update Flow - -1. Inspect settings with `scripts/customization_config.py effective`. -2. Update the skill and affected references together. -3. Preserve the empty template until a real stable knob exists. -4. Re-run the runtime dry runs and targeted tests. diff --git a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/customization_config.py deleted file mode 100755 index a834969ad..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swift-package-extension-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run-workflow.fsx new file mode 100644 index 000000000..a87cc9edd --- /dev/null +++ b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run-workflow.fsx @@ -0,0 +1,49 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 |> Array.toList +let value name = args |> List.tryFindIndex ((=) name) |> Option.bind (fun index -> args |> List.tryItem (index + 1)) +let normalize (text: string) = String.Join(" ", text.Trim().ToLowerInvariant().Split([| ' '; '\t'; '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries)) +let infer request = + let text = normalize request + if text.Contains("macro") || text.Contains("expansion") then Some "macro" + elif text.Contains("trait") || text.Contains("feature flag") then Some "traits" + elif text.Contains("command plugin") || text.Contains("plugin command") || text.Contains("format plugin") then Some "command-plugin" + elif text.Contains("generated") || text.Contains("codegen") || text.Contains("code generation") then Some "generated-source" + elif text.Contains("build tool plugin") || text.Contains("build plugin") || text.Contains("plugin") then Some "build-tool-plugin" + else None +let requested = value "--repo-root" |> Option.defaultValue "." |> Path.GetFullPath +let candidate = if Directory.Exists(requested) then DirectoryInfo(requested) else FileInfo(requested).Directory +let packageRoot = + Seq.unfold (fun (directory: DirectoryInfo) -> if isNull directory then None else Some(directory, directory.Parent)) candidate + |> Seq.tryFind (fun directory -> File.Exists(Path.Combine(directory.FullName, "Package.swift"))) + |> Option.map (fun directory -> directory.FullName) + |> Option.orElseWith (fun () -> + if Directory.Exists(requested) then Directory.GetFiles(requested, "Package.swift", SearchOption.AllDirectories) |> Array.sort |> Array.tryHead |> Option.map Path.GetDirectoryName + else None) +let extensionType = value "--extension-type" |> Option.orElseWith (fun () -> value "--request" |> Option.bind infer) +let scope = value "--toolchain-scope" |> Option.defaultValue "both" +let identity = [ if scope = "swiftly" || scope = "both" then yield! [ "swiftly use --print-location"; "swift --version" ]; if scope = "xcode" || scope = "both" then yield! [ "xcode-select -p"; "xcrun --find swift"; "xcrun swift --version" ] ] +let baseCommands kind = + match kind with + | "build-tool-plugin" -> [ "swift package plugin --list"; "swift package init --type build-tool-plugin" ] + | "command-plugin" -> [ "swift package plugin --list"; "swift package plugin --help"; "swift package init --type command-plugin" ] + | "macro" -> [ "swift package init --type macro"; "swift build"; "swift test" ] + | "traits" -> [ "swift package show-traits --format json"; "swift build"; "swift test"; "swift build --disable-default-traits"; "swift test --disable-default-traits"; "swift build --enable-all-traits"; "swift test --enable-all-traits" ] + | _ -> [ "swift package dump-package"; "swift build"; "swift build -v" ] +let planned = extensionType |> Option.map (fun kind -> let commands = baseCommands kind in identity @ [ if scope = "swiftly" || scope = "both" then yield! commands; if scope = "xcode" || scope = "both" then yield! commands |> List.map (fun command -> "xcrun " + command) ]) |> Option.defaultValue [] +let status, next = + if extensionType.IsNone then "blocked", "Pass --extension-type or a request identifying plugin, macro, trait, or generated-source work." + elif not (Directory.Exists(requested) || File.Exists(requested)) then "blocked", "Resolve the requested repository path before continuing." + elif packageRoot.IsNone then "blocked", "Use a Swift package repository containing Package.swift." + else "success", "Proceed with the package-first extension plan." +let source = if value "--extension-type" |> Option.isSome then "explicit" elif extensionType.IsSome then "inferred" else "missing" +let context = {| requested_root = requested; package_root = packageRoot; exists = Directory.Exists(requested) || File.Exists(requested); has_package = packageRoot.IsSome |} +let support = {| minimum = "6.2"; policy = "latest stable minor plus previous stable minor" |} +let output = {| extension_type = extensionType; extension_type_source = source; package_context = context; toolchain_scope = scope; planned_commands = planned; support_window = support; next_step = next |} +let payload = {| status = status; path_type = "primary"; output = output |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if status = "blocked" then exit 1 diff --git a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run_workflow.py deleted file mode 100755 index 30f23f153..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-extension-workflow/scripts/run_workflow.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Plan SwiftPM plugin, macro, trait, and generated-source work.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - -import customization_config - -EXTENSION_TYPES = { - "build-tool-plugin", - "command-plugin", - "macro", - "traits", - "generated-source", -} -TOOLCHAIN_SCOPES = {"swiftly", "xcode", "both"} - - -def normalize(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def infer_extension_type(request: str | None) -> str | None: - text = normalize(request) - if not text: - return None - if "macro" in text or "expansion" in text: - return "macro" - if "trait" in text or "feature flag" in text: - return "traits" - if "command plugin" in text or "plugin command" in text or "format plugin" in text: - return "command-plugin" - if "generated" in text or "codegen" in text or "code generation" in text: - return "generated-source" - if "build tool plugin" in text or "build plugin" in text or "plugin" in text: - return "build-tool-plugin" - return None - - -def resolve_package_root(repo_root: str | None) -> tuple[Path, Path | None]: - requested = Path(repo_root or ".").expanduser().resolve() - candidate = requested if requested.is_dir() else requested.parent - for current in (candidate, *candidate.parents): - if (current / "Package.swift").exists(): - return requested, current - if requested.exists() and requested.is_dir(): - manifests = sorted(requested.rglob("Package.swift"), key=lambda path: (len(path.parts), str(path))) - if manifests: - return requested, manifests[0].parent - return requested, None - - -def package_context(repo_root: str | None) -> dict: - requested, package_root = resolve_package_root(repo_root) - scan_root = package_root or requested - plugin_sources = [] - if scan_root.exists(): - plugin_dir = scan_root / "Plugins" - if plugin_dir.exists(): - plugin_sources = sorted(str(path) for path in plugin_dir.rglob("*.swift")) - return { - "requested_root": str(requested), - "package_root": str(scan_root) if package_root is not None else None, - "exists": requested.exists(), - "has_package": package_root is not None, - "plugin_sources": plugin_sources, - } - - -def identity_commands(scope: str) -> list[str]: - commands: list[str] = [] - if scope in {"swiftly", "both"}: - commands.extend(["swiftly use --print-location", "swift --version"]) - if scope in {"xcode", "both"}: - commands.extend(["xcode-select -p", "xcrun --find swift", "xcrun swift --version"]) - return commands - - -def prefixed_commands(scope: str, commands: list[str]) -> list[str]: - planned: list[str] = [] - if scope in {"swiftly", "both"}: - planned.extend(commands) - if scope in {"xcode", "both"}: - planned.extend(f"xcrun {command}" for command in commands) - return planned - - -def extension_commands(extension_type: str, scope: str) -> list[str]: - if extension_type == "build-tool-plugin": - commands = ["swift package plugin --list", "swift package init --type build-tool-plugin"] - elif extension_type == "command-plugin": - commands = ["swift package plugin --list", "swift package plugin --help", "swift package init --type command-plugin"] - elif extension_type == "macro": - commands = ["swift package init --type macro", "swift build", "swift test"] - elif extension_type == "traits": - commands = [ - "swift package show-traits --format json", - "swift build", - "swift test", - "swift build --disable-default-traits", - "swift test --disable-default-traits", - "swift build --enable-all-traits", - "swift test --enable-all-traits", - ] - else: - commands = ["swift package dump-package", "swift build", "swift build -v"] - return identity_commands(scope) + prefixed_commands(scope, commands) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--extension-type", choices=sorted(EXTENSION_TYPES)) - parser.add_argument("--request") - parser.add_argument("--repo-root") - parser.add_argument("--toolchain-scope", choices=sorted(TOOLCHAIN_SCOPES), default="both") - parser.add_argument("--dry-run", action="store_true") - args = parser.parse_args() - customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - extension_type = args.extension_type or infer_extension_type(args.request) - context = package_context(args.repo_root) - status = "success" - next_step = "Proceed with the package-first extension plan." - if extension_type is None: - status = "blocked" - next_step = "Pass --extension-type or provide a request that identifies plugin, macro, trait, or generated-source work." - elif not context["exists"]: - status = "blocked" - next_step = "Resolve the requested repository path before continuing." - elif not context["has_package"]: - status = "blocked" - next_step = "Use a Swift package repository containing Package.swift." - commands = extension_commands(extension_type, args.toolchain_scope) if extension_type else [] - payload = { - "status": status, - "path_type": "primary", - "output": { - "extension_type": extension_type, - "extension_type_source": "explicit" if args.extension_type else "inferred" if extension_type else "missing", - "package_context": context, - "toolchain_scope": args.toolchain_scope, - "planned_commands": commands, - "support_window": {"minimum": "6.2", "policy": "latest stable minor plus previous stable minor"}, - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 if status == "blocked" else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/SKILL.md index e6386b6e8..e32ec0366 100644 --- a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/SKILL.md @@ -7,7 +7,7 @@ description: Guide Swift Testing, XCTest holdouts, code coverage, xctestplan han ## Purpose -Use this skill as the primary execution workflow for test-focused work in existing Swift package components. Keep it focused on Swift Testing, XCTest holdouts, code coverage, `.xctestplan` handoff conditions, async-test guidance, semantic accessibility-test boundaries, performance-sensitive package workload profiling, filters, retries, fixtures, and package-level test diagnosis. `scripts/run_workflow.py` resolves the nearest package root and plans the test surface without classifying the containing repository. +Use this skill as the primary execution workflow for test-focused work in existing Swift package components. Keep it focused on Swift Testing, XCTest holdouts, code coverage, `.xctestplan` handoff conditions, async-test guidance, semantic accessibility-test boundaries, performance-sensitive package workload profiling, filters, retries, fixtures, and package-level test diagnosis. `scripts/run-workflow.fsx` resolves the nearest package root and plans the test surface without classifying the containing repository. ## When To Use @@ -47,7 +47,7 @@ Use this skill as the primary execution workflow for test-focused work in existi - preserve its simplicity-first, shape-preserving, and anti-ceremony Swift guidance - preserve its explicit `swiftLanguageModes: [.v6]` package-manifest default and prefer that spelling over the legacy `swiftLanguageVersions` alias on current manifest surfaces - preserve its package-appropriate logging, telemetry, structured-concurrency, and Swift Testing guidance -4. Run `scripts/run_workflow.py` to resolve the nearest package root, confirm the request stays on the testing surface, and plan the package-testing command path. +4. Run `scripts/run-workflow.fsx` to resolve the nearest package root, confirm the request stays on the testing surface, and plan the package-testing command path. 5. Use `references/code-coverage.md` when the request needs SwiftPM collection, exported-JSON discovery, coverage reporting, comparison, or an explicit coverage-gate boundary. 6. Use `references/package-resources-testing-and-builds.md` when the request touches Swift Testing, XCTest, `.xctestplan`, accessibility-related semantic tests, fixtures, async test discipline, or test-related Debug/Release validation. 7. Use `references/performance-sensitive-testing-and-profiling.md` when the request touches package-first instrumentation, `OSSignposter`, `xctrace`, Time Profiler, Metal System Trace, Allocations, VM Tracker, Audio, MLX, local AI, streaming, or other performance-sensitive Apple silicon workloads. @@ -60,7 +60,7 @@ Use this skill as the primary execution workflow for test-focused work in existi - `request`: optional short natural-language request text used to infer `operation_type` when the explicit operation is omitted. - `repo_root`: optional absolute path for the target package repo. - Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` + - runtime entrypoint: executable `scripts/run-workflow.fsx` - `repo_root=.` when omitted - the runtime may infer `operation_type` from `--request` text when the request wording is clear enough - package testing prefers `swift test`, filtered `swift test` runs, and `xcodebuild` test-plan follow-through only when the package surface truly needs it @@ -111,12 +111,9 @@ Use this skill as the primary execution workflow for test-focused work in existi - Recommend `bootstrap-xcode-workspace --operation create --component-kind library` when the repository still needs to be created from scratch. - When maintaining this repository itself, refresh guidance-sync consumers after substantial package-testing policy changes and keep the top-level export-surface docs aligned. Do not tell users to rely on repo-local installer workflows; this repository does not ship them. -## Customization +## Fixed Policy -- Use `references/customization.template.yaml`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` reads customization state, but the current workflow keeps a fixed package-testing policy and does not expose ordinary user-facing knobs yet. -- Run the Python wrapper and customization entrypoints through `uv`, because they rely on inline `PyYAML` script metadata rather than a repo-global Python environment. +- `scripts/run-workflow.fsx` enforces the fixed package-testing policy without runtime customization. ## References @@ -130,8 +127,6 @@ Use this skill as the primary execution workflow for test-focused work in existi ### Contract References -- `references/customization.template.yaml` - ### Support References - Recommend `references/snippets/apple-swift-package-core.md` when the user needs reusable SwiftPM baseline policy wording in an end-user repo. @@ -139,5 +134,4 @@ Use this skill as the primary execution workflow for test-focused work in existi ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` diff --git a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization-flow.md deleted file mode 100644 index 5a65d9338..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization-flow.md +++ /dev/null @@ -1,29 +0,0 @@ -# Swift Package Testing Workflow Customization Contract - -## Purpose - -Document the fixed SwiftPM-first policy defaults for package testing and Xcode handoff behavior. - -## Knobs - -This skill does not expose ordinary user-facing customization knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` still loads customization state, but the current workflow uses fixed SwiftPM-first testing defaults rather than ordinary user-facing customization knobs. -- SwiftPM command execution remains agent-side and is not performed by the local runtime script. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected workflow references to reflect the approved package-testing policy change. -3. Keep `references/customization.template.yaml` present for install-surface consistency even when `settings` is empty. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify `scripts/run_workflow.py --operation-type test --dry-run` still emits the fixed SwiftPM-first testing workflow defaults. - -## Validation - -1. Verify the docs still describe a SwiftPM-first testing workflow. -2. Verify the Xcode handoff boundary is stated consistently across the skill and references. -3. Verify `scripts/run_workflow.py` reflects the fixed workflow defaults described above. diff --git a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/customization_config.py deleted file mode 100755 index 0c4b1f046..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swift-package-testing-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run-workflow.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run-workflow.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run_workflow.py deleted file mode 100755 index cd6af90ea..000000000 --- a/plugins/apple-dev-skills/skills/swift-package-testing-workflow/scripts/run_workflow.py +++ /dev/null @@ -1,287 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Runtime workflow policy engine for swift-package-testing-workflow.""" - -from __future__ import annotations - -import argparse -import json -import shlex -import sys -from pathlib import Path - -import customization_config - - -VALID_OPERATION_TYPES = { - "package-inspection", - "read-search", - "test", - "mutation", -} - - -def normalize_request_text(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def infer_operation_type_from_request(request: str | None) -> str | None: - text = normalize_request_text(request) - if not text: - return None - - checks: list[tuple[str, tuple[str, ...]]] = [ - ("test", (" test", " tests", "testing", "xctest", "swift testing", "xctestplan", "spec")), - ("package-inspection", ("describe", "dump-package", "show dependencies", "inspect package", "inspect the package", "package graph")), - ("read-search", ("read", "search", "grep", "find", "lookup", "trace")), - ("mutation", ("edit test", "change test", "modify test", "rewrite test", "refactor test", "rename test", "move test", "add test", "fix test")), - ] - - padded = f" {text} " - if any(needle in padded for needle in (" plugin", " macro", " trait", " generated source", " codegen")): - return "extension" - if any( - needle in padded - for needle in ( - " build", - " compile", - " release build", - " debug build", - " artifact", - " run", - " launch", - " execute", - " start", - " plugin", - " plugins", - " package.swift", - " manifest", - " dependency", - " dependencies", - " add package", - " add target", - " resolve", - " update package", - " package resource", - " bundle.module", - " metallib", - " resource.", - ) - ): - return "build" - for operation_type, needles in checks: - if any(needle in padded for needle in needles): - return operation_type - return None - - -def load_effective_config() -> dict: - return customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - -def shell_join(parts: list[str]) -> str: - return " ".join(shlex.quote(part) for part in parts) - - -def first_matching_file(root: Path, pattern: str) -> list[str]: - return sorted(str(path) for path in root.rglob(pattern)) - - -def infer_package_root(repo_root: str | None) -> tuple[Path, Path | None]: - requested = Path(repo_root or ".").expanduser().resolve() - candidate = requested if requested.is_dir() else requested.parent - - for current in (candidate, *candidate.parents): - if (current / "Package.swift").exists(): - return requested, current - - descendants = sorted( - requested.rglob("Package.swift"), - key=lambda path: (len(path.relative_to(requested).parts), str(path)), - ) - if descendants: - return requested, descendants[0].parent - return requested, None - - -def discover_package_context(repo_root: str | None) -> dict: - requested_root, package_root = infer_package_root(repo_root) - if not requested_root.exists(): - return { - "requested_root": str(requested_root), - "package_root": None, - "exists": False, - "has_package": False, - "xctestplans": [], - "test_targets": [], - "ui_test_targets": [], - "metal_sources": [], - "reason": "repo-root-missing", - } - - scan_root = package_root or requested_root - has_package = package_root is not None - tests_dir = scan_root / "Tests" - test_targets = sorted(path.name for path in tests_dir.iterdir() if path.is_dir()) if tests_dir.exists() else [] - ui_test_targets = sorted(name for name in test_targets if "UI" in name or "UITest" in name) - xctestplans = first_matching_file(scan_root, "*.xctestplan") - metal_sources = first_matching_file(scan_root, "*.metal") - - return { - "requested_root": str(requested_root), - "package_root": str(scan_root) if has_package else None, - "exists": True, - "has_package": has_package, - "xctestplans": xctestplans, - "test_targets": test_targets, - "ui_test_targets": ui_test_targets, - "metal_sources": metal_sources, - "reason": ( - "package-root-inferred" - if has_package and scan_root != requested_root - else "ok" - if has_package - else "package-swift-missing" - ), - } - - -def inferred_package_name(package_context: dict) -> str | None: - root = package_context.get("package_root") - return Path(root).name if root else None - - -def inferred_xcode_scheme(package_context: dict) -> str: - plans = package_context.get("xctestplans", []) - if len(plans) == 1: - return Path(plans[0]).stem - package_name = inferred_package_name(package_context) - return package_name or "<package-scheme>" - - -def build_commands(operation_type: str, package_context: dict) -> list[str]: - if operation_type == "package-inspection": - return ["swift package describe", "swift package dump-package"] - if operation_type == "read-search": - return ["swift package describe"] - if operation_type == "test": - commands = ["swift test", "swift test --filter <pattern>"] - if package_context["xctestplans"]: - commands.append(f"xcodebuild -scheme {inferred_xcode_scheme(package_context)} -showTestPlans") - commands.append( - f"xcodebuild -scheme {inferred_xcode_scheme(package_context)} -testPlan {Path(package_context['xctestplans'][0]).stem} test" - ) - return commands - if operation_type == "mutation": - return [ - "Edit package test sources or test fixtures directly when the change stays inside SwiftPM-managed scope.", - shell_join(["swift", "test", "--filter", "<pattern>"]), - ] - return [] - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--operation-type", choices=sorted(VALID_OPERATION_TYPES)) - parser.add_argument("--request") - parser.add_argument("--repo-root") - parser.add_argument("--dry-run", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - load_effective_config() - - inferred_operation_type = infer_operation_type_from_request(args.request) - operation_type = args.operation_type or inferred_operation_type - - if operation_type is None: - payload = { - "status": "blocked", - "path_type": "primary", - "output": { - "operation_type": None, - "operation_type_source": "missing", - "package_context": discover_package_context(args.repo_root), - "planned_commands": [], - "next_step": "Pass --operation-type explicitly or provide --request text that makes the intended SwiftPM workflow obvious.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - if operation_type == "extension": - payload = { - "status": "handoff", - "path_type": "fallback", - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "package_context": discover_package_context(args.repo_root), - "planned_commands": [], - "next_step": "Use swift-package-extension-workflow because traits, macros, plugins, or generated-source behavior is shaping this test request.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - if operation_type == "build": - payload = { - "status": "handoff", - "path_type": "fallback", - "output": { - "operation_type": "build-or-run", - "operation_type_source": "explicit" if args.operation_type else "inferred", - "package_context": discover_package_context(args.repo_root), - "planned_commands": [], - "next_step": "Use swift-package-build-run-workflow because this request is primarily about ordinary package build, run, manifest, dependency, resource, or Metal-distribution work.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - package_context = discover_package_context(args.repo_root) - status = "success" - path_type = "primary" - next_step = "Proceed with the SwiftPM-first path." - - if not package_context["exists"]: - status = "blocked" - next_step = "Resolve the repo root before continuing." - elif not package_context["has_package"]: - status = "blocked" - next_step = "Use a Swift package repo with Package.swift at the selected root." - payload = { - "status": status, - "path_type": path_type, - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "package_context": package_context, - "planned_commands": build_commands(operation_type, package_context), - "inferred_context": { - "package_name": inferred_package_name(package_context), - "primary_test_target": package_context["test_targets"][0] if len(package_context["test_targets"]) == 1 else None, - "ui_test_targets": package_context["ui_test_targets"], - "has_xcode_test_plan": bool(package_context["xctestplans"]), - "xcode_scheme_hint": inferred_xcode_scheme(package_context) if package_context["xctestplans"] else None, - "has_metal_sources": bool(package_context["metal_sources"]), - }, - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if status != "blocked" else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/apple-dev-skills/skills/swiftdata-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swiftdata-workflow/SKILL.md index 26cdfcd30..c98101561 100644 --- a/plugins/apple-dev-skills/skills/swiftdata-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swiftdata-workflow/SKILL.md @@ -51,14 +51,9 @@ Return the documented behavior, schema and ownership decision, naming decision, - Hand view composition to `swiftui-app-architecture-workflow` and source naming cleanup to `structure-swift-sources`. - Hand build or test execution to the focused Xcode workflows. -## Customization - -Use `references/customization-flow.md`. The first version has no runtime-enforced knobs; `scripts/customization_config.py` preserves the shared configuration contract. - ## References - `references/models-containers-and-contexts.md` - `references/swiftui-integration.md` - `references/migrations-testing-and-boundaries.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable repository policy rather than a one-off SwiftData decision. diff --git a/plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization-flow.md deleted file mode 100644 index 0c9fb1e80..000000000 --- a/plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# SwiftData Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swiftdata-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swiftdata-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swiftdata-workflow/scripts/customization_config.py deleted file mode 100755 index 021a927ea..000000000 --- a/plugins/apple-dev-skills/skills/swiftdata-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftdata-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swiftui-animation-workflow/SKILL.md index 0bdf680ec..c38f661d1 100644 --- a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swiftui-animation-workflow/SKILL.md @@ -120,11 +120,7 @@ It is not the SwiftUI app architecture workflow, not the SF Symbols selection wo - Recommend `explore-apple-swift-docs` when the user primarily needs raw Apple documentation lookup. - Recommend `references/snippets/apple-xcode-project-core.md` when repo policy or Xcode project-integrity guidance is needed before validating animation in an app project. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on motion classification, SwiftUI primitive choice, reduce-motion behavior, and validation handoffs. If future iterations add deterministic preview or screenshot helpers, document those helpers before relying on them. @@ -134,7 +130,6 @@ Keep the first release focused on motion classification, SwiftUI primitive choic - `references/animation-decision-rules.md` - `references/transitions-effects-and-accessibility.md` -- `references/customization-flow.md` ### Support References @@ -143,5 +138,3 @@ Keep the first release focused on motion classification, SwiftUI primitive choic - Apple documentation anchors to verify include SwiftUI Animations, Managing user interface state, Controlling the timing and movements of your animations, Unifying your app's animations, SwiftUI symbol effects, and Human Interface Guidelines Motion. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization-flow.md deleted file mode 100644 index 878ee6ff2..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# SwiftUI App Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `swiftui-app-architecture-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first boundary and decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `swiftui-app-architecture-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swiftui-animation-workflow/scripts/customization_config.py deleted file mode 100755 index 03f219a78..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-animation-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-app-architecture-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/SKILL.md index c568c2627..d53ba4e7e 100644 --- a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/SKILL.md @@ -157,11 +157,7 @@ Extract a custom `ViewModifier` when a view accumulates more than eight chained - Recommend `xcode-testing-workflow` when the next honest step is test execution or test diagnosis. - Recommend `apple-ui-accessibility-workflow` when the next honest step is accessibility-specific implementation or review. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep the first release focused on the decision model and the documented boundary. If future iterations add a real deterministic need for runtime knobs, document them explicitly before letting runtime behavior depend on them. @@ -176,7 +172,6 @@ Keep the first release focused on the decision model and the documented boundary - `references/environment-and-preferences.md` - `references/architecture-decision-rules.md` - `references/anti-patterns-and-corrections.md` -- `references/customization-flow.md` ### Support References @@ -184,5 +179,3 @@ Keep the first release focused on the decision model and the documented boundary - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable repo policy rather than a one-off architecture recommendation. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization-flow.md deleted file mode 100644 index 878ee6ff2..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# SwiftUI App Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `swiftui-app-architecture-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first boundary and decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `swiftui-app-architecture-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/scripts/customization_config.py deleted file mode 100755 index 03f219a78..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-app-architecture-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-app-architecture-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/SKILL.md b/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/SKILL.md index dcc9fb555..e5f99b6a8 100644 --- a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/SKILL.md @@ -85,17 +85,10 @@ Keep SwiftData directly integrated with SwiftUI through `modelContainer`, enviro - Recommend `xcode-build-run-workflow` for previews, build, run, project membership, or guarded mutations. - Recommend `xcode-testing-workflow` for Swift Testing, XCTest, XCUITest, or test diagnosis. -## Customization - -Use `references/customization-flow.md`. This workflow has no runtime-enforced knobs; keep audits grounded in the repository and Apple documentation. - ## References - `references/component-rules-and-examples.md` - `references/audit-checklist.md` -- `references/customization-flow.md` - Recommend `swiftui-app-architecture-workflow/references/snippets/apple-xcode-project-core.md` when the target repo needs durable Apple project policy. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization-flow.md deleted file mode 100644 index 276eede18..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization-flow.md +++ /dev/null @@ -1,7 +0,0 @@ -# Customization Flow - -This skill has no runtime customization knobs. Keep the audit contract stable and make repository-specific exceptions explicit in the audit result rather than persisting hidden policy changes. - -## Validation - -Run `scripts/customization_config.py effective` after changing the shared customization contract, and verify that the workflow text still describes the same behavior. diff --git a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/scripts/customization_config.py deleted file mode 100755 index a6229fa57..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-component-audit-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-component-audit-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/SKILL.md b/plugins/apple-dev-skills/skills/swiftui-liquid-glass/SKILL.md index a9d828625..8f8be3bec 100644 --- a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/SKILL.md +++ b/plugins/apple-dev-skills/skills/swiftui-liquid-glass/SKILL.md @@ -57,14 +57,9 @@ Use the platform's native glass system deliberately rather than reproducing it w - Recommend `apple-ui-accessibility-workflow` for contrast, VoiceOver, Dynamic Type, reduced motion, and alternate input review. - Recommend `xcode-build-run-workflow` for previews, build, run, and visual validation. -## Customization - -Use `references/customization-flow.md`. The first version has no runtime-enforced appearance knob because visual-system choices must remain tied to the app's deployment target, semantics, and current Apple documentation. - ## References - `references/glass-composition-and-fallbacks.md` - `references/review-and-validation.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the app needs reusable Xcode-project policy alongside Liquid Glass implementation. - [Applying Liquid Glass to custom views](https://developer.apple.com/documentation/swiftui/applying-liquid-glass-to-custom-views) documents native glass composition and customization. diff --git a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization-flow.md b/plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization-flow.md deleted file mode 100644 index 2e9f11c41..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# TipKit Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swiftui-liquid-glass/scripts/customization_config.py deleted file mode 100755 index 6530641ab..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-liquid-glass/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-liquid-glass" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/swiftui-performance-audit/SKILL.md b/plugins/apple-dev-skills/skills/swiftui-performance-audit/SKILL.md index 7e467fa5a..f33ddb0d7 100644 --- a/plugins/apple-dev-skills/skills/swiftui-performance-audit/SKILL.md +++ b/plugins/apple-dev-skills/skills/swiftui-performance-audit/SKILL.md @@ -49,13 +49,8 @@ Make performance work evidence-led: inspect the smallest relevant SwiftUI data-f - Recommend `swift-package-testing-workflow` for package-first signposts and profiling workloads. - Recommend `swiftui-app-architecture-workflow` or `swiftui-component-audit-workflow` only when the evidence shows a real component-ownership issue. -## Customization - -Use `references/customization-flow.md`. The workflow has no knobs that can weaken the distinction between a code suspicion and trace-backed performance evidence. - ## References - `references/code-smells-and-remediation.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the app needs reusable Xcode-project policy alongside profiling work. - [Understanding and improving SwiftUI performance](https://developer.apple.com/documentation/xcode/understanding-and-improving-swiftui-performance) documents SwiftUI performance analysis in Xcode. diff --git a/plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization-flow.md b/plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization-flow.md deleted file mode 100644 index 2e9f11c41..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# TipKit Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization.template.yaml b/plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-performance-audit/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/swiftui-performance-audit/scripts/customization_config.py b/plugins/apple-dev-skills/skills/swiftui-performance-audit/scripts/customization_config.py deleted file mode 100755 index db7612c09..000000000 --- a/plugins/apple-dev-skills/skills/swiftui-performance-audit/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swiftui-performance-audit" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/tipkit-workflow/SKILL.md b/plugins/apple-dev-skills/skills/tipkit-workflow/SKILL.md index bce102038..ced8a949e 100644 --- a/plugins/apple-dev-skills/skills/tipkit-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/tipkit-workflow/SKILL.md @@ -63,13 +63,8 @@ Return the documented Apple behavior, chosen presentation, configuration locatio - Recommend `xcode-testing-workflow` for automated validation. - Use a native SwiftUI `help` modifier on macOS or a custom help presentation only when the product requirement is persistent contextual help rather than TipKit feature education; verify that API through Apple docs before implementing it. -## Customization - -Use `references/customization-flow.md`. The first version has no runtime-enforced knobs; `scripts/customization_config.py` preserves the shared configuration contract. - ## References - `references/presentation-and-platform-patterns.md` - `references/eligibility-lifecycle-and-testing.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when the app needs reusable repository policy alongside TipKit implementation guidance. diff --git a/plugins/apple-dev-skills/skills/tipkit-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/tipkit-workflow/references/customization-flow.md deleted file mode 100644 index 2e9f11c41..000000000 --- a/plugins/apple-dev-skills/skills/tipkit-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# TipKit Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/tipkit-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/tipkit-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/tipkit-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/tipkit-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/tipkit-workflow/scripts/customization_config.py deleted file mode 100755 index 45e26bcbf..000000000 --- a/plugins/apple-dev-skills/skills/tipkit-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "tipkit-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/SKILL.md b/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/SKILL.md index fad42121d..4ac95bb8b 100644 --- a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/SKILL.md @@ -52,12 +52,7 @@ Use the macOS Tips HelpViewer catalog as a read-only discovery surface for insta - Recommend `explore-apple-swift-docs` for Apple framework APIs, Xcode-local documentation, Dash, or official documentation-source routing. - Recommend the owning app, Xcode, device, test, or Creator Studio workflow when the user needs to act on a project or system state rather than read guidance. -## Customization - -Use `references/customization-flow.md`. The first version has no runtime-enforced knobs; `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract. - ## References - `references/catalog-and-fallback-contract.md` -- `references/customization-flow.md` - Recommend `references/snippets/apple-xcode-project-core.md` when an Apple app repository needs its reusable project-policy baseline alongside a local documentation lookup. diff --git a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization-flow.md deleted file mode 100644 index bebbe39de..000000000 --- a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Tips and HelpViewer Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/scripts/customization_config.py deleted file mode 100755 index 3cef853e6..000000000 --- a/plugins/apple-dev-skills/skills/tips-helpviewer-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "tips-helpviewer-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/SKILL.md b/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/SKILL.md index 17a9b8112..95909f90b 100644 --- a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/SKILL.md @@ -138,13 +138,6 @@ only for a demonstrated geometry or lifecycle constraint. - Recommend `explore-apple-swift-docs` when the real need is current Apple documentation rather than a design decision. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the shared customization-file -contract. This first version has no runtime-enforced knobs. - ## References ### Workflow References @@ -152,7 +145,6 @@ contract. This first version has no runtime-enforced knobs. - `references/focus-layout-and-input.md` - `references/platform-beta-and-migration.md` - `references/validation-expectations.md` -- `references/customization-flow.md` ### Support References @@ -162,5 +154,3 @@ contract. This first version has no runtime-enforced knobs. accessibility beyond this skill's tvOS focus and Large Text boundary. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization-flow.md deleted file mode 100644 index 6975c1ddc..000000000 --- a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization-flow.md +++ /dev/null @@ -1,28 +0,0 @@ -# tvOS App Experience Workflow Customization Contract - -## Purpose - -Preserve the repository customization-file contract without inventing persistent -behavior for a documentation-first tvOS decision workflow. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` can inspect, apply, and reset the standard - customization file shape. -- `tvos-app-experience-workflow` ignores persisted settings because focus, - device capability, beta-SDK, and migration decisions require current evidence. -- Add a knob only after its deterministic behavior and documentation are clear. - -## Update Flow - -1. Inspect with `scripts/customization_config.py effective`. -2. Document a real deterministic knob in `SKILL.md` and this file first. -3. Apply a reviewed YAML overlay and rerun `effective`. - -## Validation - -Do not claim a runtime-tunable behavior that the workflow does not implement. diff --git a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/scripts/customization_config.py deleted file mode 100755 index 9033580ca..000000000 --- a/plugins/apple-dev-skills/skills/tvos-app-experience-workflow/scripts/customization_config.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Maintain policy-only customization metadata for the tvOS app workflow.""" - -from __future__ import annotations - -import argparse -import os -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "tvos-app-experience-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -REQUIRED_KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def template_path() -> Path: - return Path(__file__).parents[1] / "references" / "customization.template.yaml" - - -def load_yaml(path: Path, *, partial: bool = False) -> dict: - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except FileNotFoundError: - fail(f"Missing YAML file: {path}") - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - unknown = set(value) - REQUIRED_KEYS - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial and set(value) != REQUIRED_KEYS: - fail("Customization file must contain schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - if any(isinstance(item, (dict, list)) for item in value["settings"].values()): - fail("settings values must be scalar") - return value - - -def load_template() -> dict: - return load_yaml(template_path()) - - -def config_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def effective() -> dict: - base = load_template() - path = config_path() - if not path.exists(): - return base - overlay = load_yaml(path, partial=True) - return { - "schemaVersion": overlay.get("schemaVersion", base["schemaVersion"]), - "isCustomized": overlay.get("isCustomized", base["isCustomized"]), - "settings": {**base["settings"], **overlay.get("settings", {})}, - } - - -def emit(value: dict) -> None: - print(yaml.safe_dump(value, sort_keys=False).strip()) - - -def main() -> None: - parser = argparse.ArgumentParser() - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - path = config_path() - if args.command == "effective": - emit(effective()) - elif args.command == "apply": - overlay = load_yaml(Path(args.input), partial=True) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(overlay, sort_keys=False), encoding="utf-8") - print(path) - else: - path.unlink(missing_ok=True) - print(path) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/SKILL.md b/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/SKILL.md index 129c2391b..ce9a6c0d5 100644 --- a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/SKILL.md @@ -124,20 +124,12 @@ session policy, or Xcode execution workflows. - Recommend `explore-apple-swift-docs` when current AVKit or tvOS docs lookup is the real task. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the shared customization-file -contract. This first version has no runtime-enforced knobs. - ## References ### Workflow References - `references/system-player-and-remote-commands.md` - `references/playback-validation-and-handoffs.md` -- `references/customization-flow.md` ### Support References @@ -147,5 +139,3 @@ contract. This first version has no runtime-enforced knobs. release-note evidence. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization-flow.md deleted file mode 100644 index 971f19e33..000000000 --- a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization-flow.md +++ /dev/null @@ -1,28 +0,0 @@ -# tvOS Media Playback Workflow Customization Contract - -## Purpose - -Preserve the standard customization-file contract without hiding media-command -or runtime decisions behind unverified persisted settings. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` supports the standard configuration shape. -- `tvos-media-playback-workflow` ignores persisted settings because player - choice, commands, stream support, and device behavior require live evidence. -- Add a knob only after its deterministic runtime behavior is documented. - -## Update Flow - -1. Inspect with `scripts/customization_config.py effective`. -2. Document a real deterministic knob in `SKILL.md` and this file first. -3. Apply a reviewed YAML overlay and rerun `effective`. - -## Validation - -Do not let customization metadata imply that remote-command behavior has been -validated on hardware. diff --git a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/scripts/customization_config.py deleted file mode 100755 index c41c623e1..000000000 --- a/plugins/apple-dev-skills/skills/tvos-media-playback-workflow/scripts/customization_config.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Maintain policy-only customization metadata for the tvOS playback workflow.""" - -from __future__ import annotations - -import argparse -import os -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "tvos-media-playback-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -REQUIRED_KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def template_path() -> Path: - return Path(__file__).parents[1] / "references" / "customization.template.yaml" - - -def load_yaml(path: Path, *, partial: bool = False) -> dict: - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except FileNotFoundError: - fail(f"Missing YAML file: {path}") - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - unknown = set(value) - REQUIRED_KEYS - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial and set(value) != REQUIRED_KEYS: - fail("Customization file must contain schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - if any(isinstance(item, (dict, list)) for item in value["settings"].values()): - fail("settings values must be scalar") - return value - - -def load_template() -> dict: - return load_yaml(template_path()) - - -def config_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def effective() -> dict: - base = load_template() - path = config_path() - if not path.exists(): - return base - overlay = load_yaml(path, partial=True) - return { - "schemaVersion": overlay.get("schemaVersion", base["schemaVersion"]), - "isCustomized": overlay.get("isCustomized", base["isCustomized"]), - "settings": {**base["settings"], **overlay.get("settings", {})}, - } - - -def emit(value: dict) -> None: - print(yaml.safe_dump(value, sort_keys=False).strip()) - - -def main() -> None: - parser = argparse.ArgumentParser() - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - path = config_path() - if args.command == "effective": - emit(effective()) - elif args.command == "apply": - overlay = load_yaml(Path(args.input), partial=True) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(overlay, sort_keys=False), encoding="utf-8") - print(path) - else: - path.unlink(missing_ok=True) - print(path) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/SKILL.md b/plugins/apple-dev-skills/skills/video-codec-processing-workflow/SKILL.md index 6aa30d584..7b969b0d0 100644 --- a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/video-codec-processing-workflow/SKILL.md @@ -79,19 +79,12 @@ Guide low-level Apple video encode, decode, and pixel-buffer work while keeping - Recommend `xcode-testing-workflow` for encoded fixtures, round trips, corruption tests, color/HDR checks, and performance baselines. - Recommend `explore-apple-swift-docs` for current codec or media-buffer research. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/compression-decompression-and-session-lifecycle.md` - `references/pixel-buffers-metal-color-and-hdr.md` - `references/compressed-samples-diagnostics-and-performance.md` -- `references/customization-flow.md` - `../../shared/references/apple-media-type-ownership.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization-flow.md deleted file mode 100644 index d021c36b7..000000000 --- a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Video Codec Processing Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/video-codec-processing-workflow/scripts/customization_config.py deleted file mode 100755 index 2b840d964..000000000 --- a/plugins/apple-dev-skills/skills/video-codec-processing-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist Video codec processing workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "video-codec-processing-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/SKILL.md b/plugins/apple-dev-skills/skills/virtualization-framework-workflow/SKILL.md index f8cbb2fa9..ab8ef7cb5 100644 --- a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/virtualization-framework-workflow/SKILL.md @@ -60,10 +60,6 @@ Implement one explicit macOS or Linux Virtualization framework path without flat - Use `xcode-build-run-workflow` and `xcode-testing-workflow` for execution and tests. - Use `prepare-isolated-analysis-lab` for hostile-workload control policy. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [macOS and Linux guest matrix](references/macos-and-linux-guest-matrix.md) diff --git a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/virtualization-framework-workflow/scripts/customization_config.py deleted file mode 100755 index 0c2ec7f6d..000000000 --- a/plugins/apple-dev-skills/skills/virtualization-framework-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "virtualization-framework-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/SKILL.md b/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/SKILL.md index 57f494e0d..25770d2a5 100644 --- a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/SKILL.md @@ -77,18 +77,11 @@ Guide custom image-model integration through Vision while keeping Core ML model - Recommend `xcode-testing-workflow` for fixtures, evaluation harnesses, performance baselines, and regression tests. - Recommend `explore-apple-swift-docs` for current Vision or Core ML research. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/vision-coreml-model-integration.md` - `references/model-evaluation-performance-and-diagnostics.md` -- `references/customization-flow.md` - `../../shared/references/apple-vision-analysis-contract.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization-flow.md deleted file mode 100644 index 4968bc6b9..000000000 --- a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vision Core ML Recognition Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/scripts/customization_config.py deleted file mode 100755 index e624d4aa5..000000000 --- a/plugins/apple-dev-skills/skills/vision-coreml-recognition-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist Vision Core ML recognition workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "vision-coreml-recognition-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/SKILL.md b/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/SKILL.md index 8c8fd6375..36bb4cbc5 100644 --- a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/SKILL.md @@ -81,18 +81,11 @@ Guide Apple-provided image and video analysis while keeping Vision request owner - Recommend `xcode-testing-workflow` for fixtures, coordinate tests, sequence regressions, and performance baselines. - Recommend `explore-apple-swift-docs` for documentation research. -## Customization - -Use `references/customization-flow.md`. This workflow defines no runtime-enforced knobs. - ## References - `references/vision-requests-observations-and-sequences.md` - `references/vision-coordinates-live-frames-and-diagnostics.md` -- `references/customization-flow.md` - `../../shared/references/apple-vision-analysis-contract.md` - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode-project policy. ## Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization-flow.md deleted file mode 100644 index d967f94b5..000000000 --- a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vision Image Analysis Workflow Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; future settings must be documented here and in `SKILL.md` before runtime behavior depends on them. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/scripts/customization_config.py deleted file mode 100755 index 486e6def2..000000000 --- a/plugins/apple-dev-skills/skills/vision-image-analysis-workflow/scripts/customization_config.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist Vision image analysis workflow customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "vision-image-analysis-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {SKILL_NAME} customization: {message}", file=sys.stderr) - raise SystemExit(1) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate(config: dict, *, partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial: - missing = ALLOWED_TOP_LEVEL - set(config) - if missing: - fail(f"Missing required keys: {', '.join(sorted(missing))}") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merged(base: dict, overlay: dict) -> dict: - result = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - result[key] = overlay[key] - if "settings" in overlay: - result["settings"].update(overlay["settings"]) - return result - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_effective() -> dict: - template = parse_yaml(template_path()) - validate(template, partial=False) - durable = parse_yaml(durable_path()) if durable_path().exists() else {} - if durable: - validate(durable, partial=False) - return merged(template, durable) - - -def dump(config: dict) -> str: - return yaml.safe_dump(config, sort_keys=False) - - -def main() -> None: - parser = argparse.ArgumentParser(description=f"Manage {SKILL_NAME} customization") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - - target = durable_path() - if args.command == "path": - print(target) - elif args.command == "effective": - print(dump(load_effective()), end="") - elif args.command == "apply": - incoming = parse_yaml(Path(args.input)) - validate(incoming, partial=True) - updated = merged(load_effective(), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate(updated, partial=False) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump(updated), encoding="utf-8") - print(target) - elif args.command == "reset": - target.unlink(missing_ok=True) - print(target) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/SKILL.md b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/SKILL.md index abb31e429..9dc2e382a 100644 --- a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/SKILL.md @@ -7,7 +7,7 @@ description: Guide build, run, preview-adjacent, workspace-inspection, diagnosti ## Purpose -Use this skill as the primary execution workflow for non-testing work in or around Xcode-managed projects and workspaces. Keep it focused on workspace inspection, read/search diagnostics, builds, runs, previews, toolchain management, file membership, Release-versus-Debug validation, and the `.pbxproj` warning boundary. `scripts/run_workflow.py` is the runtime entrypoint for MCP-first build/run execution, official CLI fallback planning, and direct `.pbxproj` warning enforcement. +Use this skill as the primary execution workflow for non-testing work in or around Xcode-managed projects and workspaces. Keep it focused on workspace inspection, read/search diagnostics, builds, runs, previews, toolchain management, file membership, Release-versus-Debug validation, and the `.pbxproj` warning boundary. `scripts/run-workflow.fsx` is the runtime entrypoint for MCP-first build/run execution, official CLI fallback planning, and direct `.pbxproj` warning enforcement. ## When To Use @@ -41,11 +41,11 @@ Use this skill as the primary execution workflow for non-testing work in or arou - apply the detailed local policy in `references/snippets/apple-xcode-project-core.md` - preserve its simplicity-first, shape-preserving, and anti-ceremony Swift guidance - preserve its project-appropriate logging, telemetry, and SwiftUI architecture guidance -4. Run `scripts/run_workflow.py` to apply runtime configuration, `.pbxproj` warning safeguards, and CLI fallback planning. +4. Run `scripts/run-workflow.fsx` to apply runtime configuration, `.pbxproj` warning safeguards, and CLI fallback planning. 5. Use the guidance in `references/mcp-tool-matrix.md` for agent-executed MCP operations. 6. Use `references/xcodegen-project-maintenance.md` when the repo is XcodeGen-backed and the task touches generated targets, schemes, build settings, packages, file membership, resource membership, or generation options. 7. Use `references/testing-plans-file-membership-and-configurations.md` when the task touches file membership after filesystem edits or Debug/Release validation. -8. If MCP fails, use the structured fallback output from `scripts/run_workflow.py` together with `references/cli-fallback-matrix.md`. +8. If MCP fails, use the structured fallback output from `scripts/run-workflow.fsx` together with `references/cli-fallback-matrix.md`. 9. Report which parts were agent-executed, which parts were locally enforced by script, the Apple docs relied on, any tracked `.pbxproj` diff that must be staged and committed with the branch, and any required next step. ## Inputs @@ -58,7 +58,7 @@ Use this skill as the primary execution workflow for non-testing work in or arou - `direct_pbxproj_edit`: optional flag when the requested mutation would directly edit a `.pbxproj` file. - `direct_pbxproj_edit_opt_in`: optional explicit opt-in after the user has been warned about direct `.pbxproj` edit risks. - Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` + - runtime entrypoint: executable `scripts/run-workflow.fsx` - the runtime may infer `operation_type` from `--request` text when the request wording is clear enough - agent-side MCP retries once for transient failures - direct edits are allowed by default when they do not directly edit `.pbxproj` @@ -101,14 +101,12 @@ Use this skill as the primary execution workflow for non-testing work in or arou - Recommend `structure-swift-sources` directly when the task becomes structural source cleanup work. - Recommend `bootstrap-xcode-workspace --operation create --component-kind library` directly when the task becomes new-package scaffolding. - Recommend `bootstrap-xcode-workspace --operation align` directly when the repo needs Xcode-specific guidance alignment rather than execution. -- `scripts/run_workflow.py` plans fallback commands; MCP execution itself remains agent-side tool usage guided by this skill. +- `scripts/run-workflow.fsx` plans fallback commands; MCP execution itself remains agent-side tool usage guided by this skill. - When maintaining this repository itself, refresh repo-guidance consumers after substantial Xcode-policy changes and keep the top-level export-surface docs aligned. Do not tell users to rely on repo-local installer workflows; this repository does not ship them. -## Customization +## Fixed Policy -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` reads customization state for the remaining user-facing execution knobs. +- `scripts/run-workflow.fsx` enforces the fixed MCP-first execution policy. - MCP tool execution itself remains agent-side and is not performed by the local runtime entrypoint or by the skill as a direct runtime. ## References @@ -127,7 +125,6 @@ Use this skill as the primary execution workflow for non-testing work in or arou ### Contract References - `references/mcp-failure-handoff.md` -- `references/customization-flow.md` ### Support References @@ -143,6 +140,5 @@ Use this skill as the primary execution workflow for non-testing work in or arou ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/detect_xcode_managed_scope.sh` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` +- `scripts/detect-xcode-managed-scope.fsx` diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization-flow.md deleted file mode 100644 index 416394ed6..000000000 --- a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization-flow.md +++ /dev/null @@ -1,33 +0,0 @@ -# Xcode Workflow Customization Contract - -## Purpose - -Tune the documented policy defaults for MCP-first execution, fallback planning, and the remaining `.pbxproj`-edit safeguard. - -## Knobs - -| Setting | Default | Status | Meaning | -| --- | --- | --- | --- | -| `mcpRetryCount` | `1` | `runtime-enforced` | Controls how many retry attempts are allowed after transient MCP failures before switching to the CLI fallback path. | -| `fallbackCommandMappingProfile` | `official-default` | `runtime-enforced` | Controls which documented fallback-command profile `scripts/run_workflow.py` uses when MCP cannot complete. Supported values are `official-default` and `xcode-only`. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the runtime-enforced knobs above and still keeps `.pbxproj` warning behavior outside ordinary customization. -- `scripts/detect_xcode_managed_scope.sh` remains a helper script used by `scripts/run_workflow.py`. -- MCP tool execution remains agent-side and is not performed by the local runtime script. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected workflow references to reflect the approved policy change. -3. Keep `references/customization.template.yaml` aligned with the runtime-enforced knobs above. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify `scripts/run_workflow.py --operation-type build --dry-run` still emits the configured fallback behavior. - -## Validation - -1. Verify the docs still describe a single MCP-first execution workflow. -2. Verify the `.pbxproj` warning path and fallback posture are still stated consistently across the skill and references. -3. Verify `scripts/run_workflow.py` reflects the runtime-enforced knobs described above. diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization.template.yaml deleted file mode 100644 index 7ad9c4d30..000000000 --- a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/references/customization.template.yaml +++ /dev/null @@ -1,5 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - mcpRetryCount: 1 - fallbackCommandMappingProfile: "official-default" diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/customization_config.py deleted file mode 100755 index b567e3384..000000000 --- a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "xcode-build-run-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect-xcode-managed-scope.fsx b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect-xcode-managed-scope.fsx new file mode 100644 index 000000000..54d411f05 --- /dev/null +++ b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect-xcode-managed-scope.fsx @@ -0,0 +1,19 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let requested = fsi.CommandLineArgs |> Array.skip 1 |> Array.tryHead |> Option.defaultValue "." +let root = Path.GetFullPath(requested) +let markers = + if not (Directory.Exists(root)) then [||] + else + Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories) + |> Seq.filter (fun path -> + let depth = Path.GetRelativePath(root, path).Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Length + depth <= 4 && (path.EndsWith(".xcodeproj") || path.EndsWith(".xcworkspace") || path.EndsWith(".pbxproj"))) + |> Seq.truncate 20 + |> Seq.toArray +let payload = {| managed = not (Array.isEmpty markers); path = requested; markers = markers |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect_xcode_managed_scope.sh b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect_xcode_managed_scope.sh deleted file mode 100755 index 87c9e213b..000000000 --- a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/detect_xcode_managed_scope.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - detect_xcode_managed_scope.sh [PATH] - -Return JSON describing whether PATH contains Xcode-managed markers -(.xcodeproj, .xcworkspace, .pbxproj) within depth 4. -EOF -} - -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - usage - exit 0 -fi - -ROOT="${1:-.}" -if [ ! -d "$ROOT" ]; then - echo "{\"managed\":false,\"reason\":\"path-not-directory\",\"path\":\"$ROOT\"}" - exit 0 -fi - -found="$(find "$ROOT" -maxdepth 4 \( -name "*.xcodeproj" -o -name "*.xcworkspace" -o -name "*.pbxproj" \) -print 2>/dev/null | head -n 20)" - -if [ -n "$found" ]; then - printf '{"managed":true,"path":"%s","markers":[\n' "$ROOT" - first=1 - while IFS= read -r line; do - [ -z "$line" ] && continue - if [ "$first" -eq 0 ]; then - printf ',\n' - fi - first=0 - esc="${line//\"/\\\"}" - printf ' "%s"' "$esc" - done <<< "$found" - printf '\n]}\n' -else - printf '{"managed":false,"path":"%s","markers":[]}\n' "$ROOT" -fi diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run-workflow.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run-workflow.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run_workflow.py deleted file mode 100755 index a51a7809f..000000000 --- a/plugins/apple-dev-skills/skills/xcode-build-run-workflow/scripts/run_workflow.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Runtime workflow policy engine for xcode-build-run-workflow.""" - -from __future__ import annotations - -import argparse -import json -import os -import shlex -import shutil -import subprocess -import sys -from pathlib import Path - - -VALID_OPERATION_TYPES = { - "workspace-inspection", - "session-inspection", - "read-search-diagnostics", - "build", - "run", - "package-toolchain-management", - "mutation", -} - - -def load_customization_config(): - try: - import customization_config as loaded_config - except ModuleNotFoundError as error: - if error.name != "yaml": - raise - reexec_with_uv_script() - return loaded_config - - -def reexec_with_uv_script() -> None: - if os.environ.get("APPLE_DEV_SKILLS_UV_SCRIPT_REEXEC") == "1": - print( - "ERROR: xcode-build-run-workflow still cannot import PyYAML after re-running through " - "`uv run --script`. Confirm uv can read this script's inline dependency metadata and " - "install PyYAML.", - file=sys.stderr, - ) - raise SystemExit(1) - - uv_path = shutil.which("uv") - if uv_path is None: - print( - "ERROR: xcode-build-run-workflow requires PyYAML for customization config, but PyYAML " - "is not installed in this Python environment and uv is unavailable. Run this helper as " - "`uv run --script run_workflow.py ...` or install uv so the script can resolve its " - "inline PyYAML dependency.", - file=sys.stderr, - ) - raise SystemExit(1) - - env = dict(os.environ) - env["APPLE_DEV_SKILLS_UV_SCRIPT_REEXEC"] = "1" - os.execve( - uv_path, - [uv_path, "run", "--script", str(Path(__file__).resolve()), *sys.argv[1:]], - env, - ) - - -customization_config = load_customization_config() - - -def normalize_request_text(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def infer_operation_type_from_request(request: str | None) -> str | None: - text = normalize_request_text(request) - if not text: - return None - - padded = f" {text} " - if any( - needle in padded - for needle in (" test", " tests", "testing", "xctest", "xcuitest", "ui test", "ui tests", "xctestplan") - ): - return "test" - - checks: list[tuple[str, tuple[str, ...]]] = [ - ("run", (" run", "launch", "open simulator", "simulator", "device", "preview")), - ("build", ("build", "compile", "archive", "release build", "debug build", "artifact")), - ("package-toolchain-management", ("toolchain", "xcode-select", "swift version", "xcrun", "metal toolchain", "sdk", "package resolve", "dependency update")), - ("read-search-diagnostics", ("diagnostic", "diagnostics", "error", "warning", "issue", "issues", "grep", "search", "find", "read", "navigator")), - ("workspace-inspection", ("workspace", "scheme list", "inspect project", "inspect workspace", "session")), - ("mutation", ("edit", "change", "modify", "rewrite", "refactor", "rename", "move file", "add file", "target membership", "pbxproj")), - ] - - for operation_type, needles in checks: - if any(needle in padded for needle in needles): - return operation_type - return None - - -def load_effective_config() -> dict: - return customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - -def detect_managed_scope(workspace_path: str | None) -> dict: - if not workspace_path: - return {"managed": False, "path": None, "markers": [], "reason": "workspace-path-missing"} - - script_path = Path(__file__).with_name("detect_xcode_managed_scope.sh") - proc = subprocess.run( - [str(script_path), workspace_path], - capture_output=True, - text=True, - check=False, - ) - try: - payload = json.loads(proc.stdout or "{}") - except json.JSONDecodeError: - payload = { - "managed": False, - "path": workspace_path, - "markers": [], - "reason": "scope-detection-json-error", - } - if proc.returncode != 0 and "reason" not in payload: - payload["reason"] = "scope-detection-failed" - return payload - - -def discover_workspace_state(workspace_path: str | None) -> dict: - if not workspace_path: - return { - "workspace": None, - "project": None, - "swift_package": False, - "requested_root": None, - "resolved_root": None, - "xctestplans": [], - "metal_sources": [], - "metal_libraries": [], - "scheme_hints": [], - "test_targets": [], - } - - requested = Path(workspace_path).expanduser().resolve() - existing = requested - while not existing.exists() and existing != existing.parent: - existing = existing.parent - if not existing.exists(): - return { - "workspace": None, - "project": None, - "swift_package": False, - "requested_root": str(requested), - "resolved_root": None, - "xctestplans": [], - "metal_sources": [], - "metal_libraries": [], - "scheme_hints": [], - "test_targets": [], - } - - candidate = existing if existing.is_dir() else existing.parent - direct_workspace = requested if requested.suffix == ".xcworkspace" else None - direct_project = requested if requested.suffix == ".xcodeproj" else None - - parent_workspace = None - parent_project = None - for current in (candidate, *candidate.parents): - if not parent_workspace: - matches = sorted(current.glob("*.xcworkspace")) - if matches: - parent_workspace = matches[0] - if not parent_project: - matches = sorted(current.glob("*.xcodeproj")) - if matches: - parent_project = matches[0] - if parent_workspace or parent_project: - break - - scan_root = candidate - workspace = direct_workspace or parent_workspace - project = direct_project or parent_project - if workspace: - scan_root = workspace.parent - elif project: - scan_root = project.parent - - if not workspace: - descendants = sorted(scan_root.rglob("*.xcworkspace"), key=str) - if descendants: - workspace = descendants[0] - scan_root = workspace.parent - if not project: - descendants = sorted(scan_root.rglob("*.xcodeproj"), key=str) - if descendants: - project = descendants[0] - if not workspace: - scan_root = project.parent - - xctestplans = sorted(str(path) for path in scan_root.rglob("*.xctestplan")) - metal_sources = sorted(str(path) for path in scan_root.rglob("*.metal")) - metal_libraries = sorted(str(path) for path in scan_root.rglob("*.metallib")) - test_root = scan_root / "Tests" - test_targets = sorted(path.name for path in test_root.iterdir() if path.is_dir()) if test_root.exists() else [] - scheme_hints = [] - if workspace: - scheme_hints.append(workspace.stem) - if project: - scheme_hints.append(project.stem) - scheme_hints.extend(Path(path).stem for path in xctestplans) - scheme_hints = sorted(dict.fromkeys(scheme_hints)) - swift_package = any((current / "Package.swift").exists() for current in (scan_root, *scan_root.parents)) - return { - "requested_root": str(requested), - "resolved_root": str(scan_root), - "workspace": str(workspace) if workspace else None, - "project": str(project) if project else None, - "swift_package": swift_package, - "xctestplans": xctestplans, - "metal_sources": metal_sources, - "metal_libraries": metal_libraries, - "scheme_hints": scheme_hints, - "test_targets": test_targets, - } - - -def shell_join(parts: list[str]) -> str: - return " ".join(shlex.quote(part) for part in parts) - - -def inferred_scheme(state: dict) -> str: - hints = state.get("scheme_hints", []) - return hints[0] if hints else "<scheme>" - - -def build_fallback_commands(operation_type: str, workspace_path: str | None, mapping_profile: str) -> list[str]: - state = discover_workspace_state(workspace_path) - commands: list[str] = [] - include_swift_package = mapping_profile != "xcode-only" - scheme = inferred_scheme(state) - - if operation_type in {"workspace-inspection", "session-inspection", "read-search-diagnostics"}: - if state["workspace"]: - commands.append(shell_join(["xcodebuild", "-workspace", state["workspace"], "-list"])) - if state["project"]: - commands.append(shell_join(["xcodebuild", "-project", state["project"], "-list"])) - if include_swift_package and state["swift_package"]: - commands.append("swift package describe") - elif operation_type == "build": - if state["workspace"]: - commands.append(shell_join(["xcodebuild", "-workspace", state["workspace"], "-scheme", scheme, "build"])) - if state["project"]: - commands.append(shell_join(["xcodebuild", "-project", state["project"], "-scheme", scheme, "build"])) - if include_swift_package and state["swift_package"]: - commands.append("swift build") - if state["metal_sources"] or state["metal_libraries"]: - commands.append("xcrun --find metal") - elif operation_type == "run": - if include_swift_package and state["swift_package"]: - commands.append("swift run <target>") - commands.append("xcrun simctl list") - elif operation_type == "package-toolchain-management": - if include_swift_package and state["swift_package"]: - commands.extend(["swift package describe", "swift package resolve", "swift package update"]) - commands.extend(["xcrun --find swift", "xcrun --find xcodebuild"]) - elif operation_type == "mutation": - commands.append("Verify target membership, build phases, and resource inclusion after filesystem edits.") - if state["workspace"]: - commands.append(shell_join(["xcodebuild", "-workspace", state["workspace"], "-scheme", scheme, "build"])) - elif state["project"]: - commands.append(shell_join(["xcodebuild", "-project", state["project"], "-scheme", scheme, "build"])) - return commands - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--operation-type", choices=sorted(VALID_OPERATION_TYPES)) - parser.add_argument("--request") - parser.add_argument("--workspace-path") - parser.add_argument("--tab-identifier") - parser.add_argument("--mcp-failure-reason") - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--direct-pbxproj-edit", action="store_true") - parser.add_argument("--direct-pbxproj-edit-opt-in", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - config = load_effective_config() - settings = config["settings"] - inferred_operation_type = infer_operation_type_from_request(args.request) - operation_type = args.operation_type or inferred_operation_type - - if operation_type is None: - payload = { - "status": "blocked", - "path_type": "primary", - "output": { - "operation_type": None, - "operation_type_source": "missing", - "workspace_path": args.workspace_path, - "tab_identifier": args.tab_identifier, - "mcp_failure_reason": args.mcp_failure_reason, - "guard_result": { - "applied": False, - "managed_scope": False, - "reason": "not-applicable", - }, - "fallback_commands": [], - "retry_count": int(settings.get("mcpRetryCount", 1)), - "next_step": "Pass --operation-type explicitly or provide --request text that makes the intended Xcode build, run, diagnostics, or mutation workflow obvious.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - if operation_type == "test": - payload = { - "status": "handoff", - "path_type": "primary", - "output": { - "operation_type": "test", - "operation_type_source": "explicit" if args.operation_type else "inferred", - "workspace_path": args.workspace_path, - "tab_identifier": args.tab_identifier, - "mcp_failure_reason": args.mcp_failure_reason, - "guard_result": { - "applied": False, - "managed_scope": False, - "reason": "testing-handoff", - }, - "fallback_commands": [], - "retry_count": int(settings.get("mcpRetryCount", 1)), - "next_step": "Use xcode-testing-workflow because this request is primarily about tests, test plans, or test diagnosis.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - workspace_state = discover_workspace_state(args.workspace_path) - fallback_commands = build_fallback_commands( - operation_type, - args.workspace_path, - str(settings.get("fallbackCommandMappingProfile", "official-default")), - ) - - guard_result = { - "applied": False, - "managed_scope": False, - "direct_edits_allowed": True, - "direct_pbxproj_edit_warning_required": False, - "reason": "not-applicable", - } - status = "success" - path_type = "primary" - next_step = "Proceed with the agent-side MCP path." - - if operation_type == "mutation": - scope = detect_managed_scope(args.workspace_path) - markers = scope.get("markers", []) - has_pbxproj_marker = any(str(marker).endswith(".pbxproj") for marker in markers) - guard_result = { - "applied": True, - "managed_scope": bool(scope.get("managed")), - "direct_edits_allowed": True, - "direct_pbxproj_edit_warning_required": False, - "reason": "ordinary-direct-edits-allowed", - "markers": markers, - } - if args.direct_pbxproj_edit or has_pbxproj_marker: - guard_result["direct_pbxproj_edit_warning_required"] = True - guard_result["reason"] = "direct-pbxproj-edit-warning-required" - if args.direct_pbxproj_edit and not args.direct_pbxproj_edit_opt_in: - status = "blocked" - next_step = "Warn the user about direct .pbxproj edit risks and rerun with --direct-pbxproj-edit-opt-in only if they explicitly approve that path." - - if args.mcp_failure_reason and status != "blocked": - path_type = "fallback" - next_step = ( - f"Use the first documented fallback command because MCP reported {args.mcp_failure_reason}." - if fallback_commands - else "No documented CLI fallback is available for this operation type." - ) - - payload = { - "status": status, - "path_type": path_type, - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "workspace_path": args.workspace_path, - "workspace_state": workspace_state, - "tab_identifier": args.tab_identifier, - "mcp_failure_reason": args.mcp_failure_reason, - "guard_result": guard_result, - "fallback_commands": fallback_commands, - "inferred_context": { - "scheme_hint": inferred_scheme(workspace_state), - "has_xcode_test_plan": bool(workspace_state.get("xctestplans")), - "has_metal_sources": bool(workspace_state.get("metal_sources")), - "has_bundled_metallib": bool(workspace_state.get("metal_libraries")), - }, - "retry_count": int(settings.get("mcpRetryCount", 1)), - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if status != "blocked" else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/SKILL.md b/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/SKILL.md index 480fb5417..5de5aa0f4 100644 --- a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/SKILL.md @@ -164,11 +164,7 @@ Current note: Apple documents ACP agent setup in Xcode 26.6 and Xcode 27. Xcode - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode project guidance in a repo that will rely on Xcode coding intelligence. - Keep custom Xcode plug-in writers research-first until the live package, runtime behavior, and permission surface are verified. Route ACP-agent implementation to Agent Portability Skills. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` exists to preserve the repo-wide customization-file contract, but the first version of this skill defines no runtime-enforced knobs. +## Fixed Policy Keep this skill focused on setup and permission decisions. If future iterations add deterministic checks for Xcode settings exports, agent config folders, or MCP bridge status, document the knobs before runtime behavior depends on them. @@ -180,7 +176,6 @@ Keep this skill focused on setup and permission decisions. If future iterations - `references/mcpbridge-and-external-agents.md` - `references/permissions-and-artifacts.md` - `references/source-evidence.md` -- `references/customization-flow.md` ### Support References @@ -191,5 +186,3 @@ Keep this skill focused on setup and permission decisions. If future iterations - Recommend `references/snippets/apple-xcode-project-core.md` when the user needs reusable Xcode project guidance for a repo that will use Xcode coding intelligence. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization-flow.md deleted file mode 100644 index d3c345540..000000000 --- a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Safari Extension Control Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `safari-extension-control-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its docs-first Safari surface decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `safari-extension-control-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/scripts/customization_config.py deleted file mode 100755 index 27ef917b5..000000000 --- a/plugins/apple-dev-skills/skills/xcode-coding-intelligence-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-extension-control-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/xcode-localization-workflow/SKILL.md b/plugins/apple-dev-skills/skills/xcode-localization-workflow/SKILL.md index a9b4ed8d5..fc7265fcb 100644 --- a/plugins/apple-dev-skills/skills/xcode-localization-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/xcode-localization-workflow/SKILL.md @@ -94,9 +94,7 @@ Stop and surface the decision when the requested change needs a new catalog owne - Use `apple-ui-accessibility-workflow` when Dynamic Type, right-to-left, VoiceOver, or semantic UI behavior is the primary concern. - Use `xcode-coding-intelligence-workflow` only for Xcode 27 agent setup, permissions, or live localization-MCP capability discovery; return here for the catalog-first implementation and review path. -## Customization - -Use `references/customization-flow.md`. The initial workflow has no runtime-enforced customization knobs; it keeps the shared configuration surface available without turning locale or translation policy into hidden machine state. +## Fixed Policy Recommend `references/snippets/apple-xcode-project-core.md` when a target app repository needs durable Xcode project guidance alongside its localization contract. @@ -106,5 +104,4 @@ Recommend `references/snippets/apple-xcode-project-core.md` when a target app re - `references/source-apis-and-translator-context.md` - `references/translation-review-and-validation.md` - `references/agent-assisted-translation.md` -- `references/customization-flow.md` - `references/snippets/apple-xcode-project-core.md` diff --git a/plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization-flow.md deleted file mode 100644 index 33c8c2b03..000000000 --- a/plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization-flow.md +++ /dev/null @@ -1,30 +0,0 @@ -# Xcode Localization Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without pretending the first version of `xcode-localization-workflow` already has runtime-tunable behavior. - -## Knobs - -The first version defines no documented runtime-enforced knobs. - -Keep the skill stable around its catalog-first localization decision model before introducing persistent settings. - -## Runtime Behavior - -- `scripts/customization_config.py` exists so the skill participates cleanly in the shared repo customization surface. -- `xcode-localization-workflow` currently ignores persisted settings at runtime because no runtime-enforced knobs are documented yet. -- Future runtime knobs should only be added after the skill proves a stable need for deterministic configuration. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. If a real runtime knob is being introduced, update `SKILL.md` and the affected references first. -3. Persist the metadata change with `scripts/customization_config.py apply --input <yaml-file>`. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the documented knob set. -5. Verify the skill text and any future runtime logic agree on the same contract. - -## Validation - -1. Verify the skill does not claim runtime-tunable behavior that is not actually implemented. -2. Verify future knobs are documented in both `SKILL.md` and this file before runtime behavior depends on them. diff --git a/plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/plugins/apple-dev-skills/skills/xcode-localization-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/plugins/apple-dev-skills/skills/xcode-localization-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/xcode-localization-workflow/scripts/customization_config.py deleted file mode 100755 index 39bade188..000000000 --- a/plugins/apple-dev-skills/skills/xcode-localization-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "xcode-localization-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/SKILL.md b/plugins/apple-dev-skills/skills/xcode-testing-workflow/SKILL.md index 7d6674b4a..f0a44f602 100644 --- a/plugins/apple-dev-skills/skills/xcode-testing-workflow/SKILL.md +++ b/plugins/apple-dev-skills/skills/xcode-testing-workflow/SKILL.md @@ -7,7 +7,7 @@ description: Guide Swift Testing, XCTest, XCUITest, XCUIAutomation-oriented mech ## Purpose -Use this skill as the primary execution workflow for test-focused work in or around Xcode-managed projects and workspaces. Keep it focused on Swift Testing, XCTest, XCUITest, XCUIAutomation-oriented mechanics, code coverage, `.xctestplan`, destinations, launch arguments, interruption handling, attachments, accessibility-verification follow-through, Instruments profiling, `xctrace` trace capture, filters, retries, diagnostics, and test-specific Debug/Release validation instead of broad build/run or toolchain work. `scripts/run_workflow.py` is the runtime entrypoint for MCP-first test execution, official CLI fallback planning, and the remaining `.pbxproj` warning boundary when mutation enters project-file territory. +Use this skill as the primary execution workflow for test-focused work in or around Xcode-managed projects and workspaces. Keep it focused on Swift Testing, XCTest, XCUITest, XCUIAutomation-oriented mechanics, code coverage, `.xctestplan`, destinations, launch arguments, interruption handling, attachments, accessibility-verification follow-through, Instruments profiling, `xctrace` trace capture, filters, retries, diagnostics, and test-specific Debug/Release validation instead of broad build/run or toolchain work. `scripts/run-workflow.fsx` is the runtime entrypoint for MCP-first test execution, official CLI fallback planning, and the remaining `.pbxproj` warning boundary when mutation enters project-file territory. ## When To Use @@ -39,7 +39,7 @@ Use this skill as the primary execution workflow for test-focused work in or aro - apply the detailed local policy in `references/snippets/apple-xcode-project-core.md` - preserve its simplicity-first, shape-preserving, and anti-ceremony Swift guidance - preserve its project-appropriate logging, telemetry, and SwiftUI architecture guidance -4. Run `scripts/run_workflow.py` to apply runtime configuration, `.pbxproj` warning safeguards, and CLI fallback planning. +4. Run `scripts/run-workflow.fsx` to apply runtime configuration, `.pbxproj` warning safeguards, and CLI fallback planning. 5. Use the focused references for the right testing surface: - `references/code-coverage.md` for Xcode coverage collection, `.xcresult` artifacts, `xccov` reporting, comparison, and the Xcode 27 MCP boundary - `references/xctestplan-configurations-and-matrix.md` for `.xctestplan`, launch-argument matrices, named configurations, and Debug/Release test coverage @@ -50,7 +50,7 @@ Use this skill as the primary execution workflow for test-focused work in or aro - `references/instruments-performance-profiling.md` for Instruments, `xctrace`, Time Profiler, Metal System Trace, Allocations, VM Tracker, Points of Interest, and signpost-aligned trace evidence - `references/testing-plans-file-membership-and-configurations.md` for the condensed cross-cutting summary and file-membership reminder 6. Use `references/xcodegen-project-maintenance.md` when the repo is XcodeGen-backed and the task touches generated test targets, scheme test actions, test-plan references, launch arguments, environment variables, or test bundle membership. -7. If MCP fails, use the structured fallback output from `scripts/run_workflow.py` together with `references/cli-fallback-matrix.md`. +7. If MCP fails, use the structured fallback output from `scripts/run-workflow.fsx` together with `references/cli-fallback-matrix.md`. 8. Report which parts were agent-executed, which parts were locally enforced by script, the Apple docs relied on, any tracked `.pbxproj` diff that must be staged and committed with the branch, and any required next step. ## Inputs @@ -63,7 +63,7 @@ Use this skill as the primary execution workflow for test-focused work in or aro - `direct_pbxproj_edit`: optional flag when the requested mutation would directly edit a `.pbxproj` file. - `direct_pbxproj_edit_opt_in`: optional explicit opt-in after the user has been warned about direct `.pbxproj` edit risks. - Defaults: - - runtime entrypoint: executable `scripts/run_workflow.py` + - runtime entrypoint: executable `scripts/run-workflow.fsx` - the runtime may infer `operation_type` from `--request` text when the request wording is clear enough - agent-side MCP retries once for transient failures - direct edits are allowed by default when they do not directly edit `.pbxproj` @@ -105,14 +105,12 @@ Use this skill as the primary execution workflow for test-focused work in or aro - Recommend `format-swift-sources` directly when the task becomes SwiftLint or SwiftFormat setup, config export, or style-tooling maintenance work. - Recommend `structure-swift-sources` directly when the task becomes structural source cleanup work. - Recommend `bootstrap-xcode-workspace --operation align` directly when the repo needs Xcode-specific guidance alignment rather than execution. -- `scripts/run_workflow.py` plans fallback commands; MCP execution itself remains agent-side tool usage guided by this skill. +- `scripts/run-workflow.fsx` plans fallback commands; MCP execution itself remains agent-side tool usage guided by this skill. - When maintaining this repository itself, refresh repo-guidance consumers after substantial Xcode-testing-policy changes and keep the top-level export-surface docs aligned. Do not tell users to rely on repo-local installer workflows; this repository does not ship them. -## Customization +## Fixed Policy -- Use `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. -- `scripts/run_workflow.py` reads customization state for the remaining user-facing execution knobs. +- `scripts/run-workflow.fsx` enforces the fixed MCP-first execution policy. - MCP tool execution itself remains agent-side and is not performed by the local runtime entrypoint or by the skill as a direct runtime. ## References @@ -137,7 +135,6 @@ Use this skill as the primary execution workflow for test-focused work in or aro ### Contract References - `references/mcp-failure-handoff.md` -- `references/customization-flow.md` ### Support References @@ -153,6 +150,5 @@ Use this skill as the primary execution workflow for test-focused work in or aro ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/detect_xcode_managed_scope.sh` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` +- `scripts/detect-xcode-managed-scope.fsx` diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization-flow.md b/plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization-flow.md deleted file mode 100644 index 416394ed6..000000000 --- a/plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization-flow.md +++ /dev/null @@ -1,33 +0,0 @@ -# Xcode Workflow Customization Contract - -## Purpose - -Tune the documented policy defaults for MCP-first execution, fallback planning, and the remaining `.pbxproj`-edit safeguard. - -## Knobs - -| Setting | Default | Status | Meaning | -| --- | --- | --- | --- | -| `mcpRetryCount` | `1` | `runtime-enforced` | Controls how many retry attempts are allowed after transient MCP failures before switching to the CLI fallback path. | -| `fallbackCommandMappingProfile` | `official-default` | `runtime-enforced` | Controls which documented fallback-command profile `scripts/run_workflow.py` uses when MCP cannot complete. Supported values are `official-default` and `xcode-only`. | - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the runtime-enforced knobs above and still keeps `.pbxproj` warning behavior outside ordinary customization. -- `scripts/detect_xcode_managed_scope.sh` remains a helper script used by `scripts/run_workflow.py`. -- MCP tool execution remains agent-side and is not performed by the local runtime script. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Update `SKILL.md` and the affected workflow references to reflect the approved policy change. -3. Keep `references/customization.template.yaml` aligned with the runtime-enforced knobs above. -4. Re-run `scripts/customization_config.py effective` and confirm the stored values match the docs. -5. Verify `scripts/run_workflow.py --operation-type build --dry-run` still emits the configured fallback behavior. - -## Validation - -1. Verify the docs still describe a single MCP-first execution workflow. -2. Verify the `.pbxproj` warning path and fallback posture are still stated consistently across the skill and references. -3. Verify `scripts/run_workflow.py` reflects the runtime-enforced knobs described above. diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization.template.yaml b/plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization.template.yaml deleted file mode 100644 index 7ad9c4d30..000000000 --- a/plugins/apple-dev-skills/skills/xcode-testing-workflow/references/customization.template.yaml +++ /dev/null @@ -1,5 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: - mcpRetryCount: 1 - fallbackCommandMappingProfile: "official-default" diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/customization_config.py b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/customization_config.py deleted file mode 100755 index 27a3fc427..000000000 --- a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "xcode-testing-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect-xcode-managed-scope.fsx b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect-xcode-managed-scope.fsx new file mode 100644 index 000000000..704905c8c --- /dev/null +++ b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect-xcode-managed-scope.fsx @@ -0,0 +1,3 @@ +#!/usr/bin/env -S dotnet fsi + +#load "../../xcode-build-run-workflow/scripts/detect-xcode-managed-scope.fsx" diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect_xcode_managed_scope.sh b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect_xcode_managed_scope.sh deleted file mode 100755 index 87c9e213b..000000000 --- a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/detect_xcode_managed_scope.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - detect_xcode_managed_scope.sh [PATH] - -Return JSON describing whether PATH contains Xcode-managed markers -(.xcodeproj, .xcworkspace, .pbxproj) within depth 4. -EOF -} - -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - usage - exit 0 -fi - -ROOT="${1:-.}" -if [ ! -d "$ROOT" ]; then - echo "{\"managed\":false,\"reason\":\"path-not-directory\",\"path\":\"$ROOT\"}" - exit 0 -fi - -found="$(find "$ROOT" -maxdepth 4 \( -name "*.xcodeproj" -o -name "*.xcworkspace" -o -name "*.pbxproj" \) -print 2>/dev/null | head -n 20)" - -if [ -n "$found" ]; then - printf '{"managed":true,"path":"%s","markers":[\n' "$ROOT" - first=1 - while IFS= read -r line; do - [ -z "$line" ] && continue - if [ "$first" -eq 0 ]; then - printf ',\n' - fi - first=0 - esc="${line//\"/\\\"}" - printf ' "%s"' "$esc" - done <<< "$found" - printf '\n]}\n' -else - printf '{"managed":false,"path":"%s","markers":[]}\n' "$ROOT" -fi diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run-workflow.fsx b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run-workflow.fsx new file mode 100644 index 000000000..872631cb2 --- /dev/null +++ b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run-workflow.fsx @@ -0,0 +1,60 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let arguments = fsi.CommandLineArgs |> Array.skip 1 +let has flag = arguments |> Array.contains flag +let value flag = arguments |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < arguments.Length then Some arguments[index + 1] else None) +let skill = DirectoryInfo(Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, ".."))).Name +let start = + [ value "--repo-root"; value "--repo-path"; value "--workspace-path" ] + |> List.choose id + |> List.tryHead + |> Option.defaultValue (Directory.GetCurrentDirectory()) + |> Path.GetFullPath + +let rec nearestWith marker (directory: DirectoryInfo) = + if File.Exists(Path.Combine(directory.FullName, marker)) || Directory.GetDirectories(directory.FullName, marker).Length > 0 then Some directory.FullName + elif isNull directory.Parent then None + else nearestWith marker directory.Parent + +let packageRoot = nearestWith "Package.swift" (DirectoryInfo start) +let xcodeRoot = + match value "--workspace-path" with + | Some path -> Some(Path.GetFullPath path) + | None -> nearestWith "*.xcworkspace" (DirectoryInfo start) |> Option.orElseWith (fun () -> nearestWith "*.xcodeproj" (DirectoryInfo start)) +let operation = value "--operation-type" |> Option.orElseWith (fun () -> value "--cleanup-kind") |> Option.orElseWith (fun () -> value "--task-type") |> Option.defaultValue "inspect" +let request = value "--request" |> Option.defaultValue "" +let directEdit = has "--direct-pbxproj-edit" +let optedIn = has "--direct-pbxproj-edit-opt-in" + +let surface, root, commands = + if skill.StartsWith("swift-package-", StringComparison.Ordinal) then + "swift-package", packageRoot, [| "swift package describe"; if skill.Contains("testing") then "swift test" else "swift build" |] + elif skill.StartsWith("xcode-", StringComparison.Ordinal) then + "xcode", xcodeRoot, [| "Xcode MCP first"; "xcodebuild only as the documented fallback" |] + elif skill = "author-swift-docc-docs" then + "documentation", packageRoot |> Option.orElse xcodeRoot, [| "author or review DocC sources"; if has "--needs-generation" then "generate documentation with the owning SwiftPM or Xcode surface" |] + elif skill = "structure-swift-sources" then + "source-structure", Some start, [| "inventory Swift source structure"; "apply managed headers and TODO/FIXME ledgers only when explicitly requested" |] + else "apple-workflow", Some start, [| "inspect the owning project surface" |] + +let blockedReason = + if directEdit && not optedIn then Some "Direct project.pbxproj editing requires the explicit --direct-pbxproj-edit-opt-in flag." + elif root.IsNone then Some $"{skill} could not locate its required project surface from {start}." + else None +let payload = + {| status = if blockedReason.IsSome then "blocked" else "success" + skill = skill + execution_surface = surface + root = root + operation = operation + request = request + dry_run = has "--dry-run" + policy = {| customization = "fixed"; mcp_first = surface = "xcode"; direct_pbxproj_edit = directEdit && optedIn |} + commands = commands + error = blockedReason |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if blockedReason.IsSome then exit 2 diff --git a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run_workflow.py b/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run_workflow.py deleted file mode 100755 index adaf1542a..000000000 --- a/plugins/apple-dev-skills/skills/xcode-testing-workflow/scripts/run_workflow.py +++ /dev/null @@ -1,439 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Runtime workflow policy engine for xcode-testing-workflow.""" - -from __future__ import annotations - -import argparse -import json -import shlex -import subprocess -import sys -from pathlib import Path - -import customization_config - - -VALID_OPERATION_TYPES = { - "workspace-inspection", - "session-inspection", - "read-search-diagnostics", - "test", - "mutation", -} - - -def normalize_request_text(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def infer_operation_type_from_request(request: str | None) -> str | None: - text = normalize_request_text(request) - if not text: - return None - - padded = f" {text} " - if any( - needle in padded - for needle in ( - " build", - " compile", - " archive", - " release build", - " debug build", - " artifact", - " run", - " launch", - " preview", - " simulator", - " device", - " xcrun", - " toolchain", - " xcode-select", - " metal toolchain", - " sdk", - " package resolve", - ) - ): - return "build" - - checks: list[tuple[str, tuple[str, ...]]] = [ - ("test", (" test", " tests", "testing", "xctest", "xcuitest", "ui test", "ui tests", "xctestplan")), - ("read-search-diagnostics", ("diagnostic", "diagnostics", "error", "warning", "issue", "issues", "grep", "search", "find", "read", "navigator", "flake")), - ("workspace-inspection", ("workspace", "scheme list", "inspect project", "inspect workspace", "session")), - ("mutation", ("edit test", "change test", "modify test", "rewrite test", "refactor test", "rename test", "move test", "add test", "fix test", "pbxproj")), - ] - for operation_type, needles in checks: - if any(needle in padded for needle in needles): - return operation_type - return None - - -def load_effective_config() -> dict: - return customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - -def detect_managed_scope(workspace_path: str | None) -> dict: - if not workspace_path: - return {"managed": False, "path": None, "markers": [], "reason": "workspace-path-missing"} - - script_path = Path(__file__).with_name("detect_xcode_managed_scope.sh") - proc = subprocess.run( - [str(script_path), workspace_path], - capture_output=True, - text=True, - check=False, - ) - try: - payload = json.loads(proc.stdout or "{}") - except json.JSONDecodeError: - payload = { - "managed": False, - "path": workspace_path, - "markers": [], - "reason": "scope-detection-json-error", - } - if proc.returncode != 0 and "reason" not in payload: - payload["reason"] = "scope-detection-failed" - return payload - - -def discover_workspace_state(workspace_path: str | None) -> dict: - if not workspace_path: - return { - "workspace": None, - "project": None, - "swift_package": False, - "requested_root": None, - "resolved_root": None, - "xctestplans": [], - "scheme_hints": [], - "test_targets": [], - "ui_test_targets": [], - } - - requested = Path(workspace_path).expanduser().resolve() - existing = requested - while not existing.exists() and existing != existing.parent: - existing = existing.parent - if not existing.exists(): - return { - "workspace": None, - "project": None, - "swift_package": False, - "requested_root": str(requested), - "resolved_root": None, - "xctestplans": [], - "scheme_hints": [], - "test_targets": [], - "ui_test_targets": [], - } - - candidate = existing if existing.is_dir() else existing.parent - direct_workspace = requested if requested.suffix == ".xcworkspace" else None - direct_project = requested if requested.suffix == ".xcodeproj" else None - - parent_workspace = None - parent_project = None - for current in (candidate, *candidate.parents): - if not parent_workspace: - matches = sorted(current.glob("*.xcworkspace")) - if matches: - parent_workspace = matches[0] - if not parent_project: - matches = sorted(current.glob("*.xcodeproj")) - if matches: - parent_project = matches[0] - if parent_workspace or parent_project: - break - - scan_root = candidate - workspace = direct_workspace or parent_workspace - project = direct_project or parent_project - if workspace: - scan_root = workspace.parent - elif project: - scan_root = project.parent - - if not workspace: - descendants = sorted(scan_root.rglob("*.xcworkspace"), key=str) - if descendants: - workspace = descendants[0] - scan_root = workspace.parent - if not project: - descendants = sorted(scan_root.rglob("*.xcodeproj"), key=str) - if descendants: - project = descendants[0] - if not workspace: - scan_root = project.parent - - xctestplans = sorted(str(path) for path in scan_root.rglob("*.xctestplan")) - test_root = scan_root / "Tests" - test_targets = sorted(path.name for path in test_root.iterdir() if path.is_dir()) if test_root.exists() else [] - ui_test_targets = sorted(name for name in test_targets if "UI" in name or "UITest" in name) - scheme_hints = [] - if workspace: - scheme_hints.append(workspace.stem) - if project: - scheme_hints.append(project.stem) - scheme_hints.extend(Path(path).stem for path in xctestplans) - scheme_hints = sorted(dict.fromkeys(scheme_hints)) - swift_package = any((current / "Package.swift").exists() for current in (scan_root, *scan_root.parents)) - return { - "requested_root": str(requested), - "resolved_root": str(scan_root), - "workspace": str(workspace) if workspace else None, - "project": str(project) if project else None, - "swift_package": swift_package, - "xctestplans": xctestplans, - "scheme_hints": scheme_hints, - "test_targets": test_targets, - "ui_test_targets": ui_test_targets, - } - - -def shell_join(parts: list[str]) -> str: - return " ".join(shlex.quote(part) for part in parts) - - -def inferred_scheme(state: dict) -> str: - hints = state.get("scheme_hints", []) - return hints[0] if hints else "<scheme>" - - -def build_fallback_commands(operation_type: str, workspace_path: str | None, mapping_profile: str) -> list[str]: - state = discover_workspace_state(workspace_path) - commands: list[str] = [] - include_swift_package = mapping_profile != "xcode-only" - scheme = inferred_scheme(state) - - if operation_type in {"workspace-inspection", "session-inspection", "read-search-diagnostics"}: - if state["workspace"]: - commands.append(shell_join(["xcodebuild", "-workspace", state["workspace"], "-list"])) - commands.append(shell_join(["xcodebuild", "-workspace", state["workspace"], "-showTestPlans", "-scheme", scheme])) - if state["project"]: - commands.append(shell_join(["xcodebuild", "-project", state["project"], "-list"])) - if include_swift_package and state["swift_package"]: - commands.append("swift package describe") - elif operation_type == "test": - if state["workspace"]: - commands.append(shell_join(["xcodebuild", "-workspace", state["workspace"], "-showTestPlans", "-scheme", scheme])) - commands.append( - shell_join( - [ - "xcodebuild", - "test", - "-workspace", - state["workspace"], - "-scheme", - scheme, - "-destination", - "<destination>", - ] - ) - ) - if state["xctestplans"]: - commands.append( - shell_join( - [ - "xcodebuild", - "test", - "-workspace", - state["workspace"], - "-scheme", - scheme, - "-testPlan", - Path(state["xctestplans"][0]).stem, - "-destination", - "<destination>", - ] - ) - ) - if state["project"]: - commands.append( - shell_join( - [ - "xcodebuild", - "test", - "-project", - state["project"], - "-scheme", - scheme, - "-destination", - "<destination>", - ] - ) - ) - if include_swift_package and state["swift_package"]: - commands.append("swift test") - elif operation_type == "mutation": - commands.append("Verify the affected test target, test plan, and target membership after filesystem edits.") - if state["workspace"]: - commands.append( - shell_join( - [ - "xcodebuild", - "test", - "-workspace", - state["workspace"], - "-scheme", - scheme, - "-destination", - "<destination>", - ] - ) - ) - return commands - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--operation-type", choices=sorted(VALID_OPERATION_TYPES)) - parser.add_argument("--request") - parser.add_argument("--workspace-path") - parser.add_argument("--tab-identifier") - parser.add_argument("--mcp-failure-reason") - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--direct-pbxproj-edit", action="store_true") - parser.add_argument("--direct-pbxproj-edit-opt-in", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - config = load_effective_config() - settings = config["settings"] - inferred_operation_type = infer_operation_type_from_request(args.request) - operation_type = args.operation_type or inferred_operation_type - - if operation_type is None: - payload = { - "status": "blocked", - "path_type": "primary", - "output": { - "operation_type": None, - "operation_type_source": "missing", - "workspace_path": args.workspace_path, - "tab_identifier": args.tab_identifier, - "mcp_failure_reason": args.mcp_failure_reason, - "guard_result": { - "applied": False, - "managed_scope": False, - "reason": "not-applicable", - }, - "fallback_commands": [], - "retry_count": int(settings.get("mcpRetryCount", 1)), - "next_step": "Pass --operation-type explicitly or provide --request text that makes the intended Xcode testing workflow obvious.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - if operation_type == "build": - payload = { - "status": "handoff", - "path_type": "primary", - "output": { - "operation_type": "build-or-run", - "operation_type_source": "explicit" if args.operation_type else "inferred", - "workspace_path": args.workspace_path, - "tab_identifier": args.tab_identifier, - "mcp_failure_reason": args.mcp_failure_reason, - "guard_result": { - "applied": False, - "managed_scope": False, - "reason": "build-run-handoff", - }, - "fallback_commands": [], - "retry_count": int(settings.get("mcpRetryCount", 1)), - "next_step": "Use xcode-build-run-workflow because this request is primarily about build, run, previews, toolchain, or project-integrity work.", - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - workspace_state = discover_workspace_state(args.workspace_path) - fallback_commands = build_fallback_commands( - operation_type, - args.workspace_path, - str(settings.get("fallbackCommandMappingProfile", "official-default")), - ) - - guard_result = { - "applied": False, - "managed_scope": False, - "direct_edits_allowed": True, - "direct_pbxproj_edit_warning_required": False, - "reason": "not-applicable", - } - status = "success" - path_type = "primary" - next_step = "Proceed with the agent-side MCP path." - - if operation_type == "mutation": - scope = detect_managed_scope(args.workspace_path) - markers = scope.get("markers", []) - has_pbxproj_marker = any(str(marker).endswith(".pbxproj") for marker in markers) - guard_result = { - "applied": True, - "managed_scope": bool(scope.get("managed")), - "direct_edits_allowed": True, - "direct_pbxproj_edit_warning_required": False, - "reason": "ordinary-direct-edits-allowed", - "markers": markers, - } - if args.direct_pbxproj_edit or has_pbxproj_marker: - guard_result["direct_pbxproj_edit_warning_required"] = True - guard_result["reason"] = "direct-pbxproj-edit-warning-required" - if args.direct_pbxproj_edit and not args.direct_pbxproj_edit_opt_in: - status = "blocked" - next_step = "Warn the user about direct .pbxproj edit risks and rerun with --direct-pbxproj-edit-opt-in only if they explicitly approve that path." - - if args.mcp_failure_reason and status != "blocked": - path_type = "fallback" - next_step = ( - f"Use the first documented fallback command because MCP reported {args.mcp_failure_reason}." - if fallback_commands - else "No documented CLI fallback is available for this operation type." - ) - - payload = { - "status": status, - "path_type": path_type, - "output": { - "operation_type": operation_type, - "operation_type_source": "explicit" if args.operation_type else "inferred", - "workspace_path": args.workspace_path, - "workspace_state": workspace_state, - "tab_identifier": args.tab_identifier, - "mcp_failure_reason": args.mcp_failure_reason, - "guard_result": guard_result, - "fallback_commands": fallback_commands, - "inferred_context": { - "scheme_hint": inferred_scheme(workspace_state), - "has_xcode_test_plan": bool(workspace_state.get("xctestplans")), - "ui_test_targets": workspace_state.get("ui_test_targets"), - "primary_test_target": ( - workspace_state.get("test_targets", [None])[0] - if len(workspace_state.get("test_targets", [])) == 1 - else None - ), - }, - "retry_count": int(settings.get("mcpRetryCount", 1)), - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if status != "blocked" else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/cloud-inference-skills/skills/flash/evals/client-external-image.eval.md b/plugins/cloud-inference-skills/skills/flash/evals/client-external-image.eval.md deleted file mode 100644 index 8d2f85016..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/client-external-image.eval.md +++ /dev/null @@ -1,23 +0,0 @@ -# Deploy a prebuilt vLLM image and call it over HTTP - -## Prompt - -I have a prebuilt Docker image `myorg/vllm-server:latest` that serves an -OpenAI-compatible API on an A100. Using runpod-flash, deploy it to a Runpod -serverless GPU endpoint and send a completion request to `/v1/completions`. - -## Expected behavior - -The agent should: - -1. Create an `Endpoint` with `image="myorg/vllm-server:latest"` (client mode) plus `name=`, `gpu=GpuGroup.AMPERE_80` -2. Recognize that `image=` means client mode (deploys the image, then calls it via HTTP) — no decorated function -3. Call the deployed endpoint with `await server.post("/v1/completions", {...})` - -## Assertions - -- Creates an `Endpoint(name=..., image="myorg/vllm-server:latest", gpu=GpuGroup.AMPERE_80, ...)` -- Does NOT wrap a Python function in a decorator (client mode, not decorator mode) -- Calls the endpoint with `await <ep>.post("/v1/completions", <payload>)` -- Uses `await` on the HTTP call -- Does NOT set `id=` together with `image=` (mutually exclusive) diff --git a/plugins/cloud-inference-skills/skills/flash/evals/connect-existing-endpoint.eval.md b/plugins/cloud-inference-skills/skills/flash/evals/connect-existing-endpoint.eval.md deleted file mode 100644 index 6f3b9412b..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/connect-existing-endpoint.eval.md +++ /dev/null @@ -1,23 +0,0 @@ -# Call an existing Runpod endpoint by ID - -## Prompt - -I already have a Runpod serverless endpoint with ID `abc123xyz`. Using -runpod-flash, send it a synchronous job `{"prompt": "hello"}` and print the -output. The first request may cold-start and take longer than a minute. - -## Expected behavior - -The agent should: - -1. Create `Endpoint(id="abc123xyz")` — connects to the existing endpoint, no provisioning -2. Submit the job with `runsync`, raising the timeout above the 60s default to survive cold start -3. Print `job.output` - -## Assertions - -- Creates `Endpoint(id="abc123xyz")` (no `name=`, `gpu=`, or `image=` needed) -- Uses `await ep.runsync({"prompt": "hello"}, timeout=...)` with a timeout > 60 (e.g. 120) OR uses `await ep.run(...)` + `await job.wait()` to avoid the 60s cap -- Accesses the result via `job.output` -- Uses `await` on the call -- Does NOT pass `id=` together with `image=` (`id=` + `name=` is legal and harmless) diff --git a/plugins/cloud-inference-skills/skills/flash/evals/cpu-gpu-pipeline.eval.md b/plugins/cloud-inference-skills/skills/flash/evals/cpu-gpu-pipeline.eval.md deleted file mode 100644 index 9a36bcd21..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/cpu-gpu-pipeline.eval.md +++ /dev/null @@ -1,25 +0,0 @@ -# Build a CPU-preprocess then GPU-inference pipeline - -## Prompt - -Using runpod-flash, build a two-stage pipeline: a CPU stage that cleans raw data -with pandas, then a GPU stage that runs inference with torch. The CPU stage -should use a compute CPU instance and the GPU stage an A100. Wire them together. - -## Expected behavior - -The agent should: - -1. Define a CPU `@Endpoint(cpu=CpuInstanceType.<type>, dependencies=["pandas"])` function -2. Define a GPU `@Endpoint(gpu=GpuGroup.AMPERE_80, dependencies=["torch"])` function -3. Import `pandas`/`torch` inside the respective functions -4. Chain them: `await infer(await preprocess(raw))` - -## Assertions - -- CPU stage uses `cpu=CpuInstanceType.<member>` and does NOT set `gpu=` -- GPU stage uses `gpu=GpuGroup.AMPERE_80` and does NOT set `cpu=` -- `pandas` listed in the CPU stage `dependencies`, `torch` in the GPU stage `dependencies` -- Imports are inside each decorated function -- Stages are chained with `await` (the GPU call awaits the result of the CPU call) -- Does NOT put `gpu=` and `cpu=` on the same Endpoint diff --git a/plugins/cloud-inference-skills/skills/flash/evals/dev-loop-iteration.eval.md b/plugins/cloud-inference-skills/skills/flash/evals/dev-loop-iteration.eval.md deleted file mode 100644 index d931517c4..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/dev-loop-iteration.eval.md +++ /dev/null @@ -1,59 +0,0 @@ -# Iterate on a flash GPU handler against a live remote worker - -> **LIVE eval.** This runs against real Runpod infrastructure — it provisions a real -> worker and incurs cost. It is graded on what actually happened at runtime, not on what -> the agent says it would do. - -## Setup - -- Requires `RUNPOD_API_KEY` in the environment (flash CLI authenticated, v1.17.0+). -- Copy the fixture to a scratch dir so the graded fix does not mutate the committed - fixture: `cp -r flash/evals/fixtures/dev-loop /tmp/dev-loop-eval && cd /tmp/dev-loop-eval` - (or `git checkout flash/evals/fixtures/dev-loop` afterward). -- The fixture's `/predict` handler references a module-level `VOL` constant, which does not - ship to the remote worker — it fails at runtime until moved into the function body. - -## Prompt - -Use the runpod-flash project in this directory (an image-to-3D style endpoint). The -`/predict` route fails when it actually runs on a worker. Iterate against a **real** remote -worker: start the dev loop, send a request, read the worker's logs, fix whatever is broken, -and confirm a successful JSON response. I don't want to re-deploy on every change. - -## Expected behavior - -The agent should actually execute (not merely describe) the following: - -1. Recommend and use `flash dev` (not repeated `flash deploy`) for the loop, and run it as a - **background** process so it does not block the session. -2. Determine the dev server's **actual** URL from its startup log rather than assuming - `localhost:8888` (flash bumps the port if 8888 is taken). -3. Send a real request to the correct **file-namespaced** route (`main.py` → `/main/predict`), - which provisions and dispatches to the remote worker. -4. Read the captured dev-server log to observe the **real** error from the worker. -5. Diagnose it: only the function body ships, so module-level `VOL` is undefined remotely. - Fix by moving `VOL` inside the handler and rely on hot-reload (no redeploy). -6. Re-send the request and confirm a real successful response. -7. Undeploy everything it provisioned. - -## Assertions - -- Runs `flash dev` as a background / non-blocking process (does NOT run it as a plain - blocking command and hang) -- Determines the actual host:port from the dev-server output (does NOT hardcode `8888` when - it was bumped) -- Sends the request to the file-namespaced route (`/main/predict`), not the bare `/predict` -- Observes the **verbatim** runtime error `NameError: name 'VOL' is not defined` in the - worker's streamed logs (it is reported from the live run, not guessed) -- Fixes the bug by moving `VOL` into the function body and re-tests via hot-reload, without - running `flash deploy` -- Obtains a real **HTTP 200** whose body contains `"ok": true` (e.g. - `{"ok":true,"vol":"/runpod-volume/models","echo":...}`) -- Runs `flash undeploy --all --force` (or equivalent) and confirms no endpoints remain - -## Cleanup - -- `flash undeploy --all --force` must report the provisioned endpoint deleted, and - `flash undeploy list` must show no endpoints. -- The agent must only stop processes/ports it started. -- Restore the fixture if it was edited in place: `git checkout flash/evals/fixtures/dev-loop`. diff --git a/plugins/cloud-inference-skills/skills/flash/evals/fixtures/dev-loop/main.py b/plugins/cloud-inference-skills/skills/flash/evals/fixtures/dev-loop/main.py deleted file mode 100644 index 4b0986a65..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/fixtures/dev-loop/main.py +++ /dev/null @@ -1,21 +0,0 @@ -from runpod_flash import Endpoint, GpuGroup - -# BUG (intentional — do NOT "pre-fix" this): a module-level constant referenced -# inside the handler. Under `flash dev` only the function body ships to the -# remote worker, so this raises `NameError: name 'VOL' is not defined` remotely -# until it is moved inside predict(). `flash deploy` imports the whole module and -# masks the bug; `flash dev` surfaces it. The eval's job is to reproduce, observe -# in the live worker logs, and fix it. -VOL = "/runpod-volume/models" - -api = Endpoint(name="dev-loop-eval", gpu=GpuGroup.AMPERE_16, workers=(0, 1), dependencies=[]) - - -@api.post("/predict") -async def predict(data: dict): - return {"ok": True, "vol": VOL, "echo": data} - - -@api.get("/health") -async def health(): - return {"status": "ok"} diff --git a/plugins/cloud-inference-skills/skills/flash/evals/lb-multi-route-api.eval.md b/plugins/cloud-inference-skills/skills/flash/evals/lb-multi-route-api.eval.md deleted file mode 100644 index 4c4c87648..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/lb-multi-route-api.eval.md +++ /dev/null @@ -1,25 +0,0 @@ -# Serve multiple HTTP routes from one pool of GPU workers - -## Prompt - -Using runpod-flash, I want a single GPU endpoint that exposes two HTTP routes: -`POST /predict` for inference and `GET /health` for a health check, sharing the -same pool of workers (1 to 5). Write the code. - -## Expected behavior - -The agent should: - -1. Create an `Endpoint` INSTANCE (not a decorator on a function): `api = Endpoint(name=..., gpu=..., workers=(1, 5), ...)` -2. Register routes with `@api.post("/predict")` and `@api.get("/health")` -3. Put heavy imports inside the route handlers -4. Make handlers `async def` - -## Assertions - -- Creates an `Endpoint(...)` instance assigned to a variable (load-balanced mode) -- Uses `@<instance>.post("/predict")` and `@<instance>.get("/health")` to register routes -- Uses `workers=(1, 5)` (explicit min/max tuple), NOT `workers=5` -- Does NOT define each route as its own separate `@Endpoint(name=...)` decorator (that would be separate endpoints, not shared workers) -- Route handlers are `async def` -- Heavy/GPU imports are inside the handler functions diff --git a/plugins/cloud-inference-skills/skills/flash/evals/qb-gpu-function.eval.md b/plugins/cloud-inference-skills/skills/flash/evals/qb-gpu-function.eval.md deleted file mode 100644 index e18e89f1e..000000000 --- a/plugins/cloud-inference-skills/skills/flash/evals/qb-gpu-function.eval.md +++ /dev/null @@ -1,26 +0,0 @@ -# Run a GPU function on Runpod serverless - -## Prompt - -I have a Python function that runs a PyTorch model on a GPU. I want to run it on -Runpod serverless using runpod-flash, with up to 5 workers. Write the code. - -## Expected behavior - -The agent should: - -1. Import `Endpoint` and `GpuGroup` from `runpod_flash` -2. Decorate the function with `@Endpoint(name=..., gpu=GpuGroup.<type>, workers=5, dependencies=["torch"])` -3. Put the `import torch` (and any other deps) INSIDE the decorated function -4. Make the function `async def` -5. Call it with `await` - -## Assertions - -- Uses `@Endpoint(...)` as a decorator with a `name=` (queue-based mode) -- Sets `gpu=` to a `GpuGroup` member and `workers` to 5 -- Lists `torch` in `dependencies=[...]` -- The `import torch` statement is INSIDE the function body, not at module top level -- The function is `async def` and is invoked with `await` -- Does NOT use the deprecated `@remote` decorator -- Does NOT set both `gpu=` and `cpu=` diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/cpu-pod-create.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/cpu-pod-create.eval.md deleted file mode 100644 index b975c4d2a..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/cpu-pod-create.eval.md +++ /dev/null @@ -1,19 +0,0 @@ -# Create a CPU-only pod - -## Prompt - -Create a CPU-only pod for lightweight file preprocessing using the image -`ubuntu:22.04`. Give me the exact command. - -## Expected behavior - -The agent should: - -1. Use `runpodctl pod create` with `--compute-type cpu` -2. Pass `--image ubuntu:22.04` -3. NOT pass any GPU flags (`--gpu-id`, `--gpu-count`) — they don't belong on a CPU pod - -## Assertions - -- Runs `runpodctl pod create --compute-type cpu --image ubuntu:22.04` -- Does NOT include `--gpu-id` or `--gpu-count` diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/hub-deploy-serverless.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/hub-deploy-serverless.eval.md deleted file mode 100644 index 6cae768a5..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/hub-deploy-serverless.eval.md +++ /dev/null @@ -1,31 +0,0 @@ -# Deploy a vLLM serverless worker from the Runpod Hub - -## Prompt - -Deploy the vLLM serverless worker from the Runpod Hub. Use an available GPU, and -have it scale from 0 up to 2 workers. Give me the exact command(s). - -## Expected behavior - -The agent should: - -1. Find the hub listing id with `runpodctl hub search vllm` -2. Create the endpoint with `runpodctl serverless create --hub-id <id> --workers-min 0 --workers-max 2` -3. Handle the GPU correctly (this is the easy thing to get wrong): - - Preferably omit `--gpu-id` and let the hub config's default GPU apply, OR - - If specifying `--gpu-id`, use a GPU **pool ID** (e.g. `AMPERE_48`, `ADA_24`, `HOPPER_141`) — NOT a display name like `"NVIDIA A40"` from `runpodctl gpu list`. On the `--hub-id` path the API rejects display names with `Invalid GPU Pool ID`. - -## Assertions - -- Finds the hub id via `runpodctl hub search vllm` (does not invent one) -- Runs `runpodctl serverless create --hub-id <id> ...` -- Sets `--workers-min 0` and `--workers-max 2` -- If `--gpu-id` is passed at all, its value is a GPU pool ID (e.g. `AMPERE_48`), NOT a `gpu list` display name like `"NVIDIA A40"` -- Does NOT pass a `gpu list` display name to `--gpu-id` on the hub path - -## Notes - -This encodes the gotcha found via live testing and tracked upstream as -runpod/runpodctl#287: `serverless create --gpu-id` on the `--hub-id` path requires -GPU pool IDs, while `gpu list` (and the `--help` text) surface display names. Until -that is reconciled, the safe answer is to omit `--gpu-id` or use a pool ID. diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/image-to-template-to-serverless.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/image-to-template-to-serverless.eval.md deleted file mode 100644 index 186204589..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/image-to-template-to-serverless.eval.md +++ /dev/null @@ -1,29 +0,0 @@ -# Run a custom image as a serverless endpoint (template first) - -## Prompt - -I have my own custom Docker image `myrepo/infer:latest`. I want to run it as a -Runpod serverless endpoint that scales from 0 to 3 workers on an A40 GPU. Give me -the exact commands. - -## Expected behavior - -The agent should: - -1. Recognize that serverless endpoints are created from a `--template-id` or `--hub-id`, NOT directly from a raw image -2. First create a serverless template: `runpodctl template create --name ... --image myrepo/infer:latest --serverless` -3. Then create the endpoint from that template id: `runpodctl serverless create --template-id <id> --workers-min 0 --workers-max 3 ...` -4. Order the two steps correctly (template before endpoint) - -## Assertions - -- Step 1 creates a template with `runpodctl template create --image myrepo/infer:latest --serverless` -- Step 2 creates the endpoint with `runpodctl serverless create --template-id <id-from-step-1>` -- Sets `--workers-min 0` and `--workers-max 3` -- Does NOT attempt `runpodctl serverless create --image ...` (no `--image` flag exists on serverless create) - -## Notes - -The template-id path is more lenient about `--gpu-id` than the hub path (it accepts -display names like `"NVIDIA A40"`), but see hub-deploy-serverless.eval.md and -runpod/runpodctl#287 for the pool-id inconsistency. diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-auto-terminate.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-auto-terminate.eval.md deleted file mode 100644 index 9891ffdb4..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-auto-terminate.eval.md +++ /dev/null @@ -1,22 +0,0 @@ -# Create a pod that auto-terminates at a datetime - -## Prompt - -Create a GPU pod from the Docker image `myorg/trainer:latest` that automatically -terminates itself at 2026-07-01T00:00:00Z. Give me the exact command. - -## Expected behavior - -The agent should: - -1. Use `runpodctl pod create` with `--image myorg/trainer:latest` -2. Use the `--terminate-after` flag with the given datetime -3. Choose `--terminate-after` (deletes the pod) over `--stop-after` (only stops it), since the user asked for termination -4. Recognize this flag exists rather than declaring it impossible - -## Assertions - -- Runs `runpodctl pod create --image myorg/trainer:latest ...` -- Uses `--terminate-after 2026-07-01T00:00:00Z` -- Does NOT use `--stop-after` for this request -- Does NOT claim auto-termination is unsupported diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-from-template-with-volume.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-from-template-with-volume.eval.md deleted file mode 100644 index cb96ddb48..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-from-template-with-volume.eval.md +++ /dev/null @@ -1,22 +0,0 @@ -# Create a pod from a template with a network volume - -## Prompt - -Create a GPU pod named `trainer` from template id `tmpl-1`, and attach the network -volume with id `nv-9`. Give me the exact command. - -## Expected behavior - -The agent should: - -1. Use `runpodctl pod create` with `--template-id tmpl-1` -2. Set `--name trainer` -3. Attach the volume with `--network-volume-id nv-9` -4. Not need any extra GPU flag, since GPU is the default compute type - -## Assertions - -- Runs `runpodctl pod create ...` -- Uses `--template-id tmpl-1` -- Uses `--name trainer` -- Uses `--network-volume-id nv-9` diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-ssh-connect.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-ssh-connect.eval.md deleted file mode 100644 index ffcb9174a..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/pod-ssh-connect.eval.md +++ /dev/null @@ -1,20 +0,0 @@ -# Get SSH connection details for a running pod - -## Prompt - -I have a running pod with id `pod-xyz`. I want to SSH into it to debug a process -interactively. What runpodctl command(s) should I use to get connected? - -## Expected behavior - -The agent should: - -1. Retrieve connection details with `runpodctl ssh info pod-xyz` (or `runpodctl pod get pod-xyz`) -2. Connect using the SSH command/key those return -3. NOT use any deprecated interactive SSH subcommand to open the session - -## Assertions - -- Uses `runpodctl ssh info pod-xyz` or `runpodctl pod get pod-xyz` to obtain host/port/key -- Does NOT rely on a deprecated interactive `runpodctl ssh`/`exec` session command -- Final guidance results in a usable `ssh ...` connection to the pod diff --git a/plugins/cloud-inference-skills/skills/runpodctl/evals/serverless-autoscale-by-requests.eval.md b/plugins/cloud-inference-skills/skills/runpodctl/evals/serverless-autoscale-by-requests.eval.md deleted file mode 100644 index d2c9b326a..000000000 --- a/plugins/cloud-inference-skills/skills/runpodctl/evals/serverless-autoscale-by-requests.eval.md +++ /dev/null @@ -1,21 +0,0 @@ -# Update a serverless endpoint to autoscale by pending requests - -## Prompt - -Update my serverless endpoint `ep-abc123` so it autoscales based on the number of -pending requests, triggering when there are 4 pending. Give me the exact command. - -## Expected behavior - -The agent should: - -1. Identify that `runpodctl serverless update <endpoint-id>` is the right command -2. Use the v2.3 autoscaler flags `--scale-by requests` and `--scale-threshold 4` -3. NOT use the older `--scaler-type` / `--scaler-value` flags (removed in v2.3) or values like `REQUEST_COUNT` / `QUEUE_DELAY` - -## Assertions - -- Runs `runpodctl serverless update ep-abc123 ...` -- Sets `--scale-by requests` (strategy = pending request count) -- Sets `--scale-threshold 4` -- Does NOT use `--scaler-type`, `--scaler-value`, `REQUEST_COUNT`, or `QUEUE_DELAY` diff --git a/plugins/cybersecurity-skills/AGENTS.md b/plugins/cybersecurity-skills/AGENTS.md index 153edfdac..19f1f162c 100644 --- a/plugins/cybersecurity-skills/AGENTS.md +++ b/plugins/cybersecurity-skills/AGENTS.md @@ -33,4 +33,4 @@ This file is the Cybersecurity Skills child-repo override for work done from `so - Keep `SKILL.md` procedural and concise, with tool matrices, schemas, version-sensitive facts, and larger examples in directly linked `references/`. - Keep every skill portable unless a concrete host-specific tool contract requires otherwise. Update the Hermes export and grouping in the same pass as any portable skill change. -- Run `uv run scripts/validate_repo_metadata.py` from this child root and `uv run scripts/validate_socket_metadata.py` from the Socket root before review. +- Run `just repo-validate` and `just test` from the Socket root before review. diff --git a/plugins/cybersecurity-skills/scripts/validate_repo_metadata.py b/plugins/cybersecurity-skills/scripts/validate_repo_metadata.py deleted file mode 100755 index f631aad6f..000000000 --- a/plugins/cybersecurity-skills/scripts/validate_repo_metadata.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "pyyaml>=6.0.2,<7", -# ] -# /// -"""Validate the Cybersecurity Skills authored and packaged surfaces.""" - -from __future__ import annotations - -import json -import re -import sys -from dataclasses import dataclass -from pathlib import Path - -import yaml - - -REPO_ROOT = Path(__file__).resolve().parent.parent -SKILLS_ROOT = REPO_ROOT / "skills" -PLUGIN_MANIFEST = REPO_ROOT / ".codex-plugin" / "plugin.json" -SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -MARKDOWN_LINK = re.compile(r"\[[^]]*]\(([^)]+)\)") -MACHINE_LOCAL_MARKERS = ("/Users/", "~/", "../") -EXPECTED_SKILLS = frozenset( - { - "analyze-suspicious-script-or-document", - "assess-and-explain-threat", - "assess-exposure-and-impact", - "assess-macos-threat", - "author-detection-content", - "author-yara-x-rules", - "check-artifact-reputation", - "contain-and-recover-macos", - "contain-security-incident", - "harden-macos", - "hunt-security-indicators", - "inspect-macos-persistence", - "inspect-macos-runtime-activity", - "map-malware-behavior", - "operate-agentic-security-tools", - "perform-dynamic-malware-analysis", - "perform-static-malware-analysis", - "preserve-security-evidence", - "prepare-isolated-analysis-lab", - "recover-security-incident", - "report-security-assessment", - "route-security-work", - "scope-authorized-security-test", - "select-analysis-isolation", - "test-network-services", - "test-web-and-api-security", - "triage-security-incident", - "triage-suspicious-content", - "triage-vulnerability-report", - "use-objective-see-tools", - "validate-vulnerability", - } -) - - -@dataclass(frozen=True) -class Finding: - """Describe one actionable metadata validation failure.""" - - path: str - message: str - - -def parse_frontmatter(path: Path) -> tuple[dict[str, object] | None, str, list[Finding]]: - """Split and validate one skill entry point's YAML frontmatter.""" - - text = path.read_text(encoding="utf-8") - relative_path = str(path.relative_to(REPO_ROOT)) - if not text.startswith("---\n"): - return None, text, [Finding(relative_path, "must begin with YAML frontmatter")] - try: - raw_frontmatter, body = text[4:].split("\n---\n", 1) - except ValueError: - return None, text, [Finding(relative_path, "has unterminated YAML frontmatter")] - try: - parsed = yaml.safe_load(raw_frontmatter) - except yaml.YAMLError as error: - return None, body, [Finding(relative_path, f"has invalid YAML frontmatter: {error}")] - if not isinstance(parsed, dict): - return None, body, [Finding(relative_path, "frontmatter must be a YAML mapping")] - return parsed, body, [] - - -def validate_links(path: Path, body: str) -> list[Finding]: - """Check that relative Markdown links remain inside the plugin and resolve.""" - - findings: list[Finding] = [] - for target in MARKDOWN_LINK.findall(body): - if target.startswith(("https://", "http://", "#", "mailto:")): - continue - relative_target = target.split("#", 1)[0] - if not relative_target: - continue - resolved = (path.parent / relative_target).resolve() - try: - resolved.relative_to(REPO_ROOT.resolve()) - except ValueError: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"links outside the plugin root: {target}")) - continue - if not resolved.exists(): - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"links to a missing local resource: {target}")) - return findings - - -def validate_openai_yaml(skill_dir: Path) -> list[Finding]: - """Validate one skill's OpenAI interface metadata.""" - - path = skill_dir / "agents" / "openai.yaml" - relative_path = str(path.relative_to(REPO_ROOT)) - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return [Finding(str(skill_dir.relative_to(REPO_ROOT)), "is missing agents/openai.yaml")] - except yaml.YAMLError as error: - return [Finding(relative_path, f"contains invalid YAML: {error}")] - if not isinstance(data, dict) or not isinstance(data.get("interface"), dict): - return [Finding(relative_path, "must define an interface mapping")] - findings: list[Finding] = [] - interface = data["interface"] - for key in ("display_name", "short_description", "default_prompt"): - value = interface.get(key) - if not isinstance(value, str) or not value.strip(): - findings.append(Finding(relative_path, f"interface.{key} must be a non-empty string")) - short_description = interface.get("short_description") - if isinstance(short_description, str) and not 25 <= len(short_description) <= 64: - findings.append(Finding(relative_path, "interface.short_description must be 25 to 64 characters")) - default_prompt = interface.get("default_prompt") - if isinstance(default_prompt, str) and f"${skill_dir.name}" not in default_prompt: - findings.append(Finding(relative_path, f"interface.default_prompt must mention `${skill_dir.name}` explicitly")) - return findings - - -def validate_skill(skill_dir: Path) -> list[Finding]: - """Validate one authored skill folder.""" - - path = skill_dir / "SKILL.md" - if not path.is_file(): - return [Finding(str(skill_dir.relative_to(REPO_ROOT)), "is missing its required SKILL.md")] - frontmatter, body, findings = parse_frontmatter(path) - if frontmatter is not None: - unexpected = sorted(set(frontmatter) - {"name", "description"}) - if unexpected: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"frontmatter contains unsupported fields: {', '.join(unexpected)}")) - name = frontmatter.get("name") - if name != skill_dir.name: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"frontmatter name must match directory `{skill_dir.name}`")) - if not isinstance(name, str) or not SKILL_NAME.fullmatch(name): - findings.append(Finding(str(path.relative_to(REPO_ROOT)), "frontmatter name violates skill naming rules")) - description = frontmatter.get("description") - if not isinstance(description, str) or not description.strip(): - findings.append(Finding(str(path.relative_to(REPO_ROOT)), "frontmatter description must be non-empty")) - elif len(description) > 1024: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), "frontmatter description exceeds 1024 characters")) - if "TODO" in body: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), "contains unresolved TODO scaffold text")) - for marker in MACHINE_LOCAL_MARKERS: - if marker in body: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"contains prohibited path marker `{marker}`")) - findings.extend(validate_links(path, body)) - findings.extend(validate_openai_yaml(skill_dir)) - return findings - - -def validate_manifest() -> list[Finding]: - """Validate the plugin identity and authored skill export.""" - - try: - data = json.loads(PLUGIN_MANIFEST.read_text(encoding="utf-8")) - except FileNotFoundError: - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "is missing")] - except json.JSONDecodeError as error: - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), f"contains invalid JSON: {error}")] - findings: list[Finding] = [] - if not isinstance(data, dict): - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "must be a JSON object")] - if data.get("name") != "cybersecurity-skills": - findings.append(Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "must use plugin name `cybersecurity-skills`")) - if data.get("skills") != "./skills/": - findings.append(Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "must export the authored ./skills/ directory")) - return findings - - -def main() -> int: - """Run plugin-local validation and return a shell-compatible status.""" - - findings = validate_manifest() - skill_dirs = sorted(path for path in SKILLS_ROOT.iterdir() if path.is_dir()) if SKILLS_ROOT.is_dir() else [] - if not skill_dirs: - findings.append(Finding("skills", "must contain at least one exported skill directory")) - actual_skills = {path.name for path in skill_dirs} - if actual_skills != EXPECTED_SKILLS: - missing = sorted(EXPECTED_SKILLS - actual_skills) - unexpected = sorted(actual_skills - EXPECTED_SKILLS) - details = [] - if missing: - details.append(f"missing: {', '.join(missing)}") - if unexpected: - details.append(f"unexpected: {', '.join(unexpected)}") - findings.append(Finding("skills", f"inventory differs from the expected 31-skill surface ({'; '.join(details)})")) - for skill_dir in skill_dirs: - findings.extend(validate_skill(skill_dir)) - if findings: - print("Cybersecurity Skills validation failed:", file=sys.stderr) - for finding in findings: - print(f"- {finding.path}: {finding.message}", file=sys.stderr) - return 1 - print(f"Cybersecurity Skills validation passed for {len(skill_dirs)} skills.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/game-dev-skills/AGENTS.md b/plugins/game-dev-skills/AGENTS.md index 79d5bed0f..7ca73885c 100644 --- a/plugins/game-dev-skills/AGENTS.md +++ b/plugins/game-dev-skills/AGENTS.md @@ -27,5 +27,5 @@ uv run python "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-creator/scripts/ Run the root Socket metadata validator after plugin metadata, marketplace wiring, or root docs change: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate ``` diff --git a/plugins/messaging-collaboration-skills/AGENTS.md b/plugins/messaging-collaboration-skills/AGENTS.md index e4ca7573f..9c769245e 100644 --- a/plugins/messaging-collaboration-skills/AGENTS.md +++ b/plugins/messaging-collaboration-skills/AGENTS.md @@ -27,7 +27,7 @@ This file refines the Socket root guidance for `messaging-collaboration-skills`. ```bash uv run python "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-creator/scripts/quick_validate.py" skills/<skill-name> -uv run scripts/validate_socket_metadata.py -uv run scripts/export_hermes_skills.py -uv run scripts/validate_hermes_compatibility.py +just repo-sync +just repo-validate +just test ``` diff --git a/plugins/model-lab-skills/skills/compare-model-checkpoints/SKILL.md b/plugins/model-lab-skills/skills/compare-model-checkpoints/SKILL.md index 0275b474d..83f44ebd8 100644 --- a/plugins/model-lab-skills/skills/compare-model-checkpoints/SKILL.md +++ b/plugins/model-lab-skills/skills/compare-model-checkpoints/SKILL.md @@ -11,7 +11,7 @@ Identify the exact model and tokenizer revisions, chat template, adapter/merge s ## Workflow -1. Preserve every source artifact as immutable, snapshot its provenance with `scripts/snapshot_model_provenance.py`, and write the snapshot outside the artifact directory. +1. Preserve every source artifact as immutable, snapshot its provenance with `scripts/snapshot-model-provenance.fsx`, and write the snapshot outside the artifact directory. 2. Verify that every comparison artifact can be loaded and produces output on the same smoke cases. 3. Use `evaluate-language-model` for paired quality and behavior evidence. 4. Use `benchmark-model-runtime` when deployment properties affect the decision. @@ -30,4 +30,4 @@ Identify the exact model and tokenizer revisions, chat template, adapter/merge s - `assets/model-comparison-report.md`: selection report. - `references/checkpoint-provenance.md`: provenance field guide. -- `scripts/snapshot_model_provenance.py`: deterministic local artifact inventory. +- `scripts/snapshot-model-provenance.fsx`: deterministic local artifact inventory. diff --git a/plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx b/plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx new file mode 100644 index 000000000..175d17043 --- /dev/null +++ b/plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx @@ -0,0 +1,57 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Security.Cryptography +open System.Text +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let valueAfter flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let positional = args |> Array.filter (fun value -> not (value.StartsWith("--")) && not (args |> Array.exists (fun flag -> (flag = "--model-id" || flag = "--revision" || flag = "--output") && valueAfter flag = Some value))) +if positional.Length <> 1 then + eprintfn "Usage: snapshot-model-provenance.fsx <artifact> [--model-id ID] [--revision REVISION] [--output PATH]" + exit 2 + +let artifact = Path.GetFullPath(positional[0]) +if not (File.Exists artifact || Directory.Exists artifact) then + eprintfn "Model artifact does not exist: %s" artifact + exit 2 + +let output = valueAfter "--output" |> Option.map Path.GetFullPath +match output with +| Some path when path = artifact || (Directory.Exists artifact && path.StartsWith(artifact + string Path.DirectorySeparatorChar, StringComparison.Ordinal)) -> + eprintfn "Provenance output must not overwrite or be inside the model artifact: %s" path + exit 2 +| _ -> () + +let digest path = + use stream = File.OpenRead path + SHA256.HashData(stream) |> Convert.ToHexString |> fun value -> value.ToLowerInvariant() + +let files = + if File.Exists artifact then [| artifact |] + else Directory.GetFiles(artifact, "*", SearchOption.AllDirectories) |> Array.sort +let entries = + files + |> Array.map (fun path -> + let name = if File.Exists artifact then Path.GetFileName path else Path.GetRelativePath(artifact, path) + {| path = name; bytes = FileInfo(path).Length; sha256 = digest path |}) +let aggregateText = entries |> Array.map (fun entry -> $"{entry.path}\000{entry.sha256}\n") |> String.concat "" +let aggregate = SHA256.HashData(Encoding.UTF8.GetBytes aggregateText) |> Convert.ToHexString |> fun value -> value.ToLowerInvariant() +let payload = + {| artifact = artifact + kind = if File.Exists artifact then "file" else "directory" + model_id = valueAfter "--model-id" + revision = valueAfter "--revision" + file_count = entries.Length + total_bytes = entries |> Array.sumBy _.bytes + inventory_sha256 = aggregate + files = entries |} +let rendered = JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true)) + "\n" +match output with +| Some path -> + Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore + File.WriteAllText(path, rendered) + printfn "Wrote model provenance snapshot: %s" path +| None -> printf "%s" rendered diff --git a/plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py b/plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py deleted file mode 100644 index 18f578e4e..000000000 --- a/plugins/model-lab-skills/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -"""Create a deterministic provenance snapshot for a local model artifact.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import sys -from pathlib import Path - - -def digest(path: Path) -> str: - value = hashlib.sha256() - with path.open("rb") as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - value.update(chunk) - return value.hexdigest() - - -def validate_output_path(artifact: Path, output: Path | None) -> None: - if output is None: - return - resolved_output = output.resolve() - same_artifact_file = ( - output.exists() and artifact.is_file() and output.samefile(artifact) - ) - if artifact.is_file() and (resolved_output == artifact or same_artifact_file): - raise ValueError( - f"Provenance output would overwrite the model artifact: {resolved_output}" - ) - if artifact.is_dir() and ( - resolved_output == artifact or artifact in resolved_output.parents - ): - raise ValueError( - "Provenance output must be outside the model artifact directory so the " - f"snapshot cannot hash or overwrite itself: {resolved_output}" - ) - if artifact.is_dir() and output.exists(): - for artifact_file in artifact.rglob("*"): - if artifact_file.is_file() and output.samefile(artifact_file): - raise ValueError( - "Provenance output is a hard-link alias of a file inside the model " - f"artifact directory: {artifact_file}" - ) - - -def write_output(path: Path, rendered: str) -> None: - try: - path.write_text(rendered, encoding="utf-8") - except OSError as error: - raise ValueError( - f"Model provenance snapshot could not write output to {path}: {error}" - ) from error - - -def build_snapshot( - artifact: Path, model_id: str | None, revision: str | None -) -> dict[str, object]: - files = ( - [artifact] - if artifact.is_file() - else sorted(path for path in artifact.rglob("*") if path.is_file()) - ) - entries = [ - { - "path": path.name - if artifact.is_file() - else str(path.relative_to(artifact)), - "bytes": path.stat().st_size, - "sha256": digest(path), - } - for path in files - ] - aggregate = hashlib.sha256() - for entry in entries: - aggregate.update(f"{entry['path']}\0{entry['sha256']}\n".encode()) - return { - "artifact": str(artifact), - "kind": "file" if artifact.is_file() else "directory", - "model_id": model_id, - "revision": revision, - "file_count": len(entries), - "total_bytes": sum(path.stat().st_size for path in files), - "inventory_sha256": aggregate.hexdigest(), - "files": entries, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("artifact", type=Path) - parser.add_argument("--model-id") - parser.add_argument("--revision") - parser.add_argument("--output", type=Path) - arguments = parser.parse_args() - artifact = arguments.artifact.resolve() - if not artifact.exists(): - print(f"Model artifact does not exist: {artifact}", file=sys.stderr) - return 2 - try: - validate_output_path(artifact, arguments.output) - except ValueError as error: - print( - f"Model provenance snapshot rejected its output path: {error}", - file=sys.stderr, - ) - return 2 - payload = build_snapshot(artifact, arguments.model_id, arguments.revision) - rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" - if arguments.output: - try: - write_output(arguments.output, rendered) - except ValueError as error: - print(error, file=sys.stderr) - return 2 - print(f"Wrote model provenance snapshot: {arguments.output}") - else: - print(rendered, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/model-lab-skills/skills/design-model-experiment/SKILL.md b/plugins/model-lab-skills/skills/design-model-experiment/SKILL.md index 8846d4db4..3be68267c 100644 --- a/plugins/model-lab-skills/skills/design-model-experiment/SKILL.md +++ b/plugins/model-lab-skills/skills/design-model-experiment/SKILL.md @@ -19,10 +19,10 @@ Produce an experiment manifest that another operator can run, audit, and compare 6. Define primary metrics, guardrail metrics, uncertainty treatment, and failure thresholds before the run. 7. Estimate compute, storage, time, and paid cost. Set smoke-test and full-run stop conditions. 8. Define raw and derived artifacts, retention, and sensitive-data handling. -9. Copy `assets/experiment-manifest.yaml`, fill it, then run: +9. Copy `assets/experiment-manifest.json`, fill it, then run: ```bash -python3 scripts/validate_experiment_manifest.py path/to/experiment.yaml +dotnet fsi scripts/validate-experiment-manifest.fsx path/to/experiment.json ``` 10. Run the smallest experiment capable of detecting configuration or pipeline failure before spending the full budget. @@ -33,6 +33,6 @@ Keep configuration validation, smoke-run evidence, and final experimental eviden ## Resources -- `assets/experiment-manifest.yaml`: portable experiment template. +- `assets/experiment-manifest.json`: managed experiment template. - `references/experiment-design.md`: field semantics and comparison rules. -- `scripts/validate_experiment_manifest.py`: deterministic structural validation. +- `scripts/validate-experiment-manifest.fsx`: deterministic structural validation. diff --git a/plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.json b/plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.json new file mode 100644 index 000000000..218bfc0a7 --- /dev/null +++ b/plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "experiment": { "id": "replace-with-stable-id", "title": "Replace with a concise title", "hypothesis": "Replace with a falsifiable statement", "decision": "Replace with the decision this run informs", "owner": "replace-with-owner" }, + "provenance": { + "code_revision": "replace-with-commit", + "model": { "id": "replace-with-model-id", "revision": "replace-with-model-revision", "license": "replace-with-license" }, + "tokenizer": { "id": "replace-with-tokenizer-id", "revision": "replace-with-tokenizer-revision" }, + "dataset": { "id": "replace-with-dataset-id", "revision": "replace-with-dataset-revision" }, + "environment": { "lockfile": "replace-with-lockfile", "hardware": "replace-with-hardware" } + }, + "method": { "controlled_variable": "replace-with-one-primary-variable", "baseline": "replace-with-baseline", "treatment": "replace-with-treatment", "seed": 42, "generation_parameters": {} }, + "evaluation": { "primary_metrics": ["replace-with-primary-metric"], "guardrail_metrics": ["replace-with-guardrail-metric"], "failure_thresholds": { "replace-with-metric": "replace-with-threshold" } }, + "budget": { "smoke_run": "replace-with-limit", "full_run": "replace-with-limit", "maximum_cost_usd": 0, "stop_conditions": ["replace-with-stop-condition"] }, + "artifacts": { "raw_results": "artifacts/raw", "derived_results": "artifacts/derived", "report": "artifacts/report.md", "sensitive_data": false } +} diff --git a/plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.yaml b/plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.yaml deleted file mode 100644 index 19684bfda..000000000 --- a/plugins/model-lab-skills/skills/design-model-experiment/assets/experiment-manifest.yaml +++ /dev/null @@ -1,46 +0,0 @@ -schema_version: 1 -experiment: - id: replace-with-stable-id - title: Replace with a concise title - hypothesis: Replace with a falsifiable statement - decision: Replace with the decision this run informs - owner: replace-with-owner -provenance: - code_revision: replace-with-commit - model: - id: replace-with-model-id - revision: replace-with-model-revision - license: replace-with-license - tokenizer: - id: replace-with-tokenizer-id - revision: replace-with-tokenizer-revision - dataset: - id: replace-with-dataset-id - revision: replace-with-dataset-revision - environment: - lockfile: replace-with-lockfile - hardware: replace-with-hardware -method: - controlled_variable: replace-with-one-primary-variable - baseline: replace-with-baseline - treatment: replace-with-treatment - seed: 42 - generation_parameters: {} -evaluation: - primary_metrics: - - replace-with-primary-metric - guardrail_metrics: - - replace-with-guardrail-metric - failure_thresholds: - replace-with-metric: replace-with-threshold -budget: - smoke_run: replace-with-limit - full_run: replace-with-limit - maximum_cost_usd: 0 - stop_conditions: - - replace-with-stop-condition -artifacts: - raw_results: artifacts/raw - derived_results: artifacts/derived - report: artifacts/report.md - sensitive_data: false diff --git a/plugins/model-lab-skills/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx b/plugins/model-lab-skills/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx new file mode 100644 index 000000000..213622767 --- /dev/null +++ b/plugins/model-lab-skills/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx @@ -0,0 +1,40 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +if args.Length <> 1 then eprintfn "Usage: validate-experiment-manifest.fsx <manifest.json>"; exit 2 +let required = + [ "schema_version"; "experiment.id"; "experiment.title"; "experiment.hypothesis"; "experiment.decision"; "experiment.owner" + "provenance.code_revision"; "provenance.model.id"; "provenance.model.revision"; "provenance.model.license" + "provenance.tokenizer.id"; "provenance.tokenizer.revision"; "provenance.dataset.id"; "provenance.dataset.revision" + "provenance.environment.lockfile"; "provenance.environment.hardware"; "method.controlled_variable"; "method.baseline" + "method.treatment"; "method.seed"; "method.generation_parameters"; "evaluation.primary_metrics"; "evaluation.guardrail_metrics" + "evaluation.failure_thresholds"; "budget.smoke_run"; "budget.full_run"; "budget.maximum_cost_usd"; "budget.stop_conditions" + "artifacts.raw_results"; "artifacts.derived_results"; "artifacts.report"; "artifacts.sensitive_data" ] +let tryAt (root: JsonElement) (path: string) = + ((Some root), path.Split('.')) ||> Array.fold (fun state name -> + state |> Option.bind (fun value -> let mutable child = Unchecked.defaultof<JsonElement> in if value.TryGetProperty(name, &child) then Some child else None)) +let empty (value: JsonElement) = + value.ValueKind = JsonValueKind.Null || value.ValueKind = JsonValueKind.Undefined || + (value.ValueKind = JsonValueKind.String && String.IsNullOrWhiteSpace(value.GetString())) || + (value.ValueKind = JsonValueKind.Array && value.GetArrayLength() = 0) +let placeholder (value: JsonElement) = value.ToString().ToLowerInvariant().Contains("replace-with") || value.ToString().ToLowerInvariant().Contains("replace with") +try + use document = JsonDocument.Parse(File.ReadAllText args[0]) + let root = document.RootElement + let errors = ResizeArray<string>() + for path in required do + match tryAt root path with + | None -> errors.Add($"Required field `{path}` is missing or empty.") + | Some value when empty value -> errors.Add($"Required field `{path}` is missing or empty.") + | Some value when placeholder value -> errors.Add($"Required field `{path}` still contains a template placeholder.") + | _ -> () + match tryAt root "schema_version" with Some value when value.ValueKind = JsonValueKind.Number && value.GetInt32() = 1 -> () | _ -> errors.Add("`schema_version` must be the integer 1.") + match tryAt root "method.seed" with Some value when value.ValueKind = JsonValueKind.Number -> () | _ -> errors.Add("`method.seed` must be an integer.") + match tryAt root "artifacts.sensitive_data" with Some value when value.ValueKind = JsonValueKind.True || value.ValueKind = JsonValueKind.False -> () | _ -> errors.Add("`artifacts.sensitive_data` must be a boolean.") + if errors.Count > 0 then errors |> Seq.iter (eprintfn "%s"); exit 1 + printfn "Experiment manifest is structurally valid: %s" args[0] +with error -> eprintfn "Experiment manifest is not valid JSON: %s" error.Message; exit 2 diff --git a/plugins/model-lab-skills/skills/design-model-experiment/scripts/validate_experiment_manifest.py b/plugins/model-lab-skills/skills/design-model-experiment/scripts/validate_experiment_manifest.py deleted file mode 100644 index 51340578e..000000000 --- a/plugins/model-lab-skills/skills/design-model-experiment/scripts/validate_experiment_manifest.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the required structure of a Model Lab experiment manifest.""" - -from __future__ import annotations - -import argparse -import math -import sys -from pathlib import Path -from typing import Any - -try: - import yaml -except ImportError as error: - raise SystemExit( - "Experiment manifest validation requires PyYAML. Install the repository development dependencies with `uv sync --dev`." - ) from error - -REQUIRED_PATHS = ( - "schema_version", - "experiment.id", - "experiment.title", - "experiment.hypothesis", - "experiment.decision", - "experiment.owner", - "provenance.code_revision", - "provenance.model.id", - "provenance.model.revision", - "provenance.model.license", - "provenance.tokenizer.id", - "provenance.tokenizer.revision", - "provenance.dataset.id", - "provenance.dataset.revision", - "provenance.environment.lockfile", - "provenance.environment.hardware", - "method.controlled_variable", - "method.baseline", - "method.treatment", - "method.seed", - "method.generation_parameters", - "evaluation.primary_metrics", - "evaluation.guardrail_metrics", - "evaluation.failure_thresholds", - "budget.smoke_run", - "budget.full_run", - "budget.maximum_cost_usd", - "budget.stop_conditions", - "artifacts.raw_results", - "artifacts.derived_results", - "artifacts.report", - "artifacts.sensitive_data", -) - - -def value_at(document: dict[str, Any], dotted_path: str) -> Any: - value: Any = document - for component in dotted_path.split("."): - if not isinstance(value, dict) or component not in value: - return None - value = value[component] - return value - - -def contains_placeholder(value: Any) -> bool: - if isinstance(value, str): - normalized = value.lower() - return normalized.startswith("replace with") or normalized.startswith( - "replace-with" - ) - if isinstance(value, list): - return any(contains_placeholder(item) for item in value) - if isinstance(value, dict): - return any( - contains_placeholder(key) or contains_placeholder(item) - for key, item in value.items() - ) - return False - - -def validate(document: Any) -> list[str]: - if not isinstance(document, dict): - return ["The manifest root must be a YAML mapping."] - errors = [] - for path in REQUIRED_PATHS: - value = value_at(document, path) - if value is None or value == "" or value == []: - errors.append(f"Required field `{path}` is missing or empty.") - elif contains_placeholder(value): - errors.append( - f"Required field `{path}` still contains a template placeholder." - ) - if document.get("schema_version") != 1: - errors.append("`schema_version` must be the integer 1.") - for path in ( - "evaluation.primary_metrics", - "evaluation.guardrail_metrics", - "budget.stop_conditions", - ): - value = value_at(document, path) - if ( - not isinstance(value, list) - or not value - or not all(isinstance(item, str) and item.strip() for item in value) - ): - errors.append(f"`{path}` must be a non-empty list of strings.") - if not isinstance(value_at(document, "method.generation_parameters"), dict): - errors.append("`method.generation_parameters` must be a mapping.") - thresholds = value_at(document, "evaluation.failure_thresholds") - if not isinstance(thresholds, dict) or not thresholds: - errors.append("`evaluation.failure_thresholds` must be a non-empty mapping.") - seed = value_at(document, "method.seed") - if not isinstance(seed, int) or isinstance(seed, bool): - errors.append("`method.seed` must be an integer.") - maximum_cost = value_at(document, "budget.maximum_cost_usd") - if ( - not isinstance(maximum_cost, (int, float)) - or isinstance(maximum_cost, bool) - or not math.isfinite(maximum_cost) - or maximum_cost < 0 - ): - errors.append("`budget.maximum_cost_usd` must be a finite non-negative number.") - if not isinstance(value_at(document, "artifacts.sensitive_data"), bool): - errors.append("`artifacts.sensitive_data` must be a boolean.") - return errors - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("manifest", type=Path) - arguments = parser.parse_args() - try: - document = yaml.safe_load(arguments.manifest.read_text(encoding="utf-8")) - except FileNotFoundError: - print( - f"Experiment manifest does not exist: {arguments.manifest}", file=sys.stderr - ) - return 2 - except yaml.YAMLError as error: - print(f"Experiment manifest is not valid YAML: {error}", file=sys.stderr) - return 2 - errors = validate(document) - if errors: - for validation_error in errors: - print(validation_error, file=sys.stderr) - return 1 - print(f"Experiment manifest is structurally valid: {arguments.manifest}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/model-lab-skills/skills/evaluate-language-model/SKILL.md b/plugins/model-lab-skills/skills/evaluate-language-model/SKILL.md index f321140f7..2b0824cdc 100644 --- a/plugins/model-lab-skills/skills/evaluate-language-model/SKILL.md +++ b/plugins/model-lab-skills/skills/evaluate-language-model/SKILL.md @@ -39,4 +39,4 @@ State the population, task, model artifact, prompt/template, decoding settings, - `assets/eval-cases.jsonl`: starter case schema. - `assets/evaluation-report.md`: comparison report template. - `references/evaluation-methods.md`: grader and uncertainty rules. -- `scripts/compare_eval_runs.py`: paired JSONL comparison. +- `scripts/compare-eval-runs.fsx`: paired JSONL comparison. diff --git a/plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare-eval-runs.fsx b/plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare-eval-runs.fsx new file mode 100644 index 000000000..027d551d0 --- /dev/null +++ b/plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare-eval-runs.fsx @@ -0,0 +1,65 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Collections.Generic +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let valueAfter flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let optionValues = [ valueAfter "--output" ] |> List.choose id |> Set.ofList +let positional = args |> Array.filter (fun value -> not (value.StartsWith("--")) && not (optionValues.Contains value)) +if positional.Length <> 2 then + eprintfn "Usage: compare-eval-runs.fsx <baseline.jsonl> <treatment.jsonl> [--allow-partial] [--output PATH]" + exit 2 + +let load path = + let values = Dictionary<string, float>() + File.ReadLines(path) + |> Seq.iteri (fun index line -> + if not (String.IsNullOrWhiteSpace line) then + use document = JsonDocument.Parse line + let root = document.RootElement + let mutable idElement = Unchecked.defaultof<JsonElement> + let mutable scoreElement = Unchecked.defaultof<JsonElement> + if not (root.TryGetProperty("id", &idElement)) || idElement.ValueKind <> JsonValueKind.String || String.IsNullOrWhiteSpace(idElement.GetString()) then + failwith $"{path}:{index + 1} requires a non-empty string `id`." + if not (root.TryGetProperty("score", &scoreElement)) || scoreElement.ValueKind <> JsonValueKind.Number then + failwith $"{path}:{index + 1} requires a finite numeric `score`." + let identifier = idElement.GetString() + let score = scoreElement.GetDouble() + if not (Double.IsFinite score) then failwith $"{path}:{index + 1} requires a finite numeric `score`." + if values.ContainsKey identifier then failwith $"{path}:{index + 1} repeats evaluation id `{identifier}`." + values.Add(identifier, score)) + if values.Count = 0 then failwith $"{path} contains no evaluation results." + values + +try + let baseline = load positional[0] + let treatment = load positional[1] + let baselineIds = baseline.Keys |> Set.ofSeq + let treatmentIds = treatment.Keys |> Set.ofSeq + let shared = Set.intersect baselineIds treatmentIds |> Set.toArray |> Array.sort + let partial = baselineIds <> treatmentIds + if shared.Length = 0 then failwith "Evaluation comparison found no shared case ids." + if partial && not (args |> Array.contains "--allow-partial") then failwith "Evaluation runs must contain identical case ids; pass --allow-partial only for a diagnostic comparison." + let cases = shared |> Array.map (fun id -> let delta = treatment[id] - baseline[id] in {| id = id; baseline = baseline[id]; treatment = treatment[id]; delta = delta |}) + let payload = + {| baseline_count = baseline.Count + treatment_count = treatment.Count + paired_count = shared.Length + partial_comparison = partial + baseline_only = Set.difference baselineIds treatmentIds |> Set.toArray |> Array.sort + treatment_only = Set.difference treatmentIds baselineIds |> Set.toArray |> Array.sort + mean_paired_delta = cases |> Array.averageBy _.delta + improved = cases |> Array.filter (fun item -> item.delta > 0.0) |> Array.length + unchanged = cases |> Array.filter (fun item -> item.delta = 0.0) |> Array.length + regressed = cases |> Array.filter (fun item -> item.delta < 0.0) |> Array.length + cases = cases |} + let rendered = JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true)) + "\n" + match valueAfter "--output" with + | Some path -> File.WriteAllText(path, rendered); printfn "Wrote paired evaluation comparison: %s" path + | None -> printf "%s" rendered +with error -> + eprintfn "Evaluation comparison could not load its inputs: %s" error.Message + exit 2 diff --git a/plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare_eval_runs.py b/plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare_eval_runs.py deleted file mode 100644 index e325365af..000000000 --- a/plugins/model-lab-skills/skills/evaluate-language-model/scripts/compare_eval_runs.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -"""Compare paired Model Lab JSONL evaluation results.""" - -from __future__ import annotations - -import argparse -import json -import math -import statistics -import sys -from pathlib import Path -from typing import Any - - -def load_results(path: Path) -> dict[str, dict[str, Any]]: - results: dict[str, dict[str, Any]] = {} - for line_number, line in enumerate( - path.read_text(encoding="utf-8").splitlines(), 1 - ): - if not line.strip(): - continue - try: - item = json.loads(line) - except json.JSONDecodeError as error: - raise ValueError( - f"{path}:{line_number} is not valid JSON: {error}" - ) from error - identifier = item.get("id") - score = item.get("score") - if not isinstance(identifier, str) or not identifier: - raise ValueError(f"{path}:{line_number} requires a non-empty string `id`.") - if identifier in results: - raise ValueError( - f"{path}:{line_number} repeats evaluation id `{identifier}`." - ) - if ( - not isinstance(score, (int, float)) - or isinstance(score, bool) - or not math.isfinite(score) - ): - raise ValueError(f"{path}:{line_number} requires a finite numeric `score`.") - results[identifier] = item - if not results: - raise ValueError(f"{path} contains no evaluation results.") - return results - - -def paired_ids( - baseline: dict[str, dict[str, Any]], - treatment: dict[str, dict[str, Any]], - allow_partial: bool, -) -> list[str]: - baseline_ids = set(baseline) - treatment_ids = set(treatment) - if baseline_ids != treatment_ids and not allow_partial: - baseline_only = sorted(baseline_ids - treatment_ids) - treatment_only = sorted(treatment_ids - baseline_ids) - raise ValueError( - "Evaluation runs must contain identical case ids for a paired comparison. " - f"Baseline-only ids: {baseline_only}; treatment-only ids: {treatment_only}. " - "Use --allow-partial only for an explicitly labeled diagnostic comparison." - ) - shared = sorted(baseline_ids & treatment_ids) - if not shared: - raise ValueError("Evaluation comparison found no shared case ids.") - return shared - - -def validate_output_path(output: Path | None, *inputs: Path) -> None: - if output is None: - return - resolved_output = output.resolve() - for input_path in inputs: - same_existing_file = output.exists() and output.samefile(input_path) - if resolved_output == input_path.resolve() or same_existing_file: - raise ValueError( - f"Evaluation comparison output would overwrite an input file: {resolved_output}" - ) - - -def write_output(path: Path, rendered: str) -> None: - try: - path.write_text(rendered, encoding="utf-8") - except OSError as error: - raise ValueError( - f"Evaluation comparison could not write output to {path}: {error}" - ) from error - - -def build_comparison( - baseline: dict[str, dict[str, Any]], - treatment: dict[str, dict[str, Any]], - allow_partial: bool = False, -) -> dict[str, Any]: - shared = paired_ids(baseline, treatment, allow_partial) - deltas = [ - float(treatment[key]["score"]) - float(baseline[key]["score"]) for key in shared - ] - return { - "baseline_count": len(baseline), - "treatment_count": len(treatment), - "paired_count": len(shared), - "partial_comparison": set(baseline) != set(treatment), - "baseline_only": sorted(set(baseline) - set(treatment)), - "treatment_only": sorted(set(treatment) - set(baseline)), - "mean_paired_delta": statistics.fmean(deltas), - "improved": sum(delta > 0 for delta in deltas), - "unchanged": sum(delta == 0 for delta in deltas), - "regressed": sum(delta < 0 for delta in deltas), - "cases": [ - { - "id": key, - "baseline": baseline[key]["score"], - "treatment": treatment[key]["score"], - "delta": delta, - } - for key, delta in zip(shared, deltas) - ], - } - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("baseline", type=Path) - parser.add_argument("treatment", type=Path) - parser.add_argument("--output", type=Path) - parser.add_argument( - "--allow-partial", - action="store_true", - help="Compare only shared ids and retain missing-id lists for diagnostic use.", - ) - arguments = parser.parse_args() - try: - validate_output_path(arguments.output, arguments.baseline, arguments.treatment) - baseline = load_results(arguments.baseline) - treatment = load_results(arguments.treatment) - payload = build_comparison(baseline, treatment, arguments.allow_partial) - except (OSError, ValueError) as error: - print( - f"Evaluation comparison could not load its inputs: {error}", file=sys.stderr - ) - return 2 - rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" - if arguments.output: - try: - write_output(arguments.output, rendered) - except ValueError as error: - print(error, file=sys.stderr) - return 2 - print(f"Wrote paired evaluation comparison: {arguments.output}") - else: - print(rendered, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/model-lab-skills/skills/evaluate-tool-calling-model/SKILL.md b/plugins/model-lab-skills/skills/evaluate-tool-calling-model/SKILL.md index 92587def4..c76648a0b 100644 --- a/plugins/model-lab-skills/skills/evaluate-tool-calling-model/SKILL.md +++ b/plugins/model-lab-skills/skills/evaluate-tool-calling-model/SKILL.md @@ -40,9 +40,6 @@ Evaluate these stages separately: whether a tool is needed, which tool is select This skill evaluates a model plus harness interface. Use `agent-engineering-skills` when the primary artifact is an agent skill or plugin package, and `agent-portability-skills` when the question is host compatibility rather than behavioral quality. -Use `python-skills:build-python-agent-service` when the primary work is a -Python implementation rather than measurement. - ## References Read `references/tool-evaluation-matrix.md` for minimum cases and metrics. diff --git a/plugins/network-protocol-skills/AGENTS.md b/plugins/network-protocol-skills/AGENTS.md index 20510f646..2a8271938 100644 --- a/plugins/network-protocol-skills/AGENTS.md +++ b/plugins/network-protocol-skills/AGENTS.md @@ -27,5 +27,5 @@ uv run python "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-creator/scripts/ Run the root Socket metadata validator after plugin metadata, marketplace wiring, or root docs change: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate ``` diff --git a/plugins/python-skills/AGENTS.md b/plugins/python-skills/AGENTS.md index ce0cbea42..d74a15efa 100644 --- a/plugins/python-skills/AGENTS.md +++ b/plugins/python-skills/AGENTS.md @@ -17,20 +17,13 @@ This file is the Python Skills child-repo override for work done from `socket`. - Do not reintroduce maintained per-skill `README.md` files unless Gale explicitly asks for that public-doc surface again. - Keep user-facing and maintainer-facing Python command examples expressed with `uv`. - Use repo-local files, checked-out dependency sources, and Dash MCP or Dash HTTP for installed Python, `uv`, pytest, Ruff, mypy, FastAPI, and FastMCP docsets before reaching for web docs. Use official project documentation when Dash/local coverage is missing, stale, or a public latest-release citation is needed. -- Use [`scripts/validate_repo_metadata.py`](./scripts/validate_repo_metadata.py) and [`tests/test_validate_repo_metadata.py`](./tests/test_validate_repo_metadata.py) as the mechanical source of truth for metadata rules. +- Do not add bootstrap, synchronization, project-creation, FastAPI, FastMCP, or agent-service skills. Keep this plugin focused on diagnostics, packaging, tooling, CI, upgrades, and testing for existing Python code. ## Validation -Run from the Socket repository root so the shared maintainer environment and -cache policy apply: +Run the essential Socket integration path from the repository root: ```bash -(cd plugins/python-skills && \ - uv run --project ../.. python -B scripts/validate_repo_metadata.py) -uv run python -B -m pytest plugins/python-skills/tests \ - -o cache_dir=.codex/.cache/pytest -uv run ruff check --cache-dir .codex/.cache/ruff/python-skills \ - plugins/python-skills -uv run mypy --cache-dir .codex/.cache/mypy/python-skills \ - plugins/python-skills +just repo-validate +just test ``` diff --git a/plugins/python-skills/scripts/__init__.py b/plugins/python-skills/scripts/__init__.py deleted file mode 100644 index 34d8f8896..000000000 --- a/plugins/python-skills/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Namespace package marker for repo-local maintainer tooling imports. diff --git a/plugins/python-skills/scripts/validate_repo_metadata.py b/plugins/python-skills/scripts/validate_repo_metadata.py deleted file mode 100755 index 00e611a8e..000000000 --- a/plugins/python-skills/scripts/validate_repo_metadata.py +++ /dev/null @@ -1,381 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// - -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass -from pathlib import Path -from urllib.parse import urlparse - -import yaml - -NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") - -PATH_REFERENCE_RE = re.compile( - r"(?:\[[^\]]+\]\()?((?:scripts|references|assets|agents)/[A-Za-z0-9._/\-]+)(?:\))?" -) -FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) -REQUIRED_FRONTMATTER_FIELDS = [ - "name", - "description", - "license", - "compatibility", - "metadata", - "allowed-tools", -] -REQUIRED_METADATA_KEYS = ["owner", "repo", "category"] -REQUIRED_INTERFACE_KEYS = ["display_name", "short_description", "brand_color", "default_prompt"] -PLUGIN_REQUIRED_FIELDS = ["name", "version", "description", "skills", "interface"] -PLUGIN_INTERFACE_REQUIRED_FIELDS = [ - "displayName", - "shortDescription", - "longDescription", - "developerName", - "category", - "capabilities", - "websiteURL", - "defaultPrompt", - "brandColor", -] -PLUGIN_DIR_NAME = "python-skills" - - -@dataclass -class Finding: - path: str - message: str - - -def parse_frontmatter(text: str, path: Path) -> dict[str, object]: - match = FRONTMATTER_RE.match(text) - if not match: - raise ValueError(f"missing frontmatter in {path}") - data = yaml.safe_load(match.group(1)) - if not isinstance(data, dict): - raise ValueError(f"invalid frontmatter in {path}") - return data - - -def load_yaml(path: Path) -> dict[str, object]: - data = yaml.safe_load(path.read_text()) - if not isinstance(data, dict): - raise ValueError(f"invalid YAML in {path}") - return data - - -def load_json(path: Path) -> dict[str, object]: - data = json.loads(path.read_text()) - if not isinstance(data, dict): - raise ValueError(f"invalid JSON object in {path}") - return data - - -def is_http_url(value: str) -> bool: - parsed = urlparse(value) - return parsed.scheme in {"http", "https"} and bool(parsed.netloc) - - -def validate_frontmatter(path: Path, frontmatter: dict[str, object], expected_name: str) -> list[Finding]: - findings: list[Finding] = [] - rel_path = str(path) - - for field in REQUIRED_FRONTMATTER_FIELDS: - if field not in frontmatter: - findings.append(Finding(rel_path, f"missing frontmatter field: {field}")) - - name = frontmatter.get("name") - if not isinstance(name, str) or not name.strip(): - findings.append(Finding(rel_path, "frontmatter name must be a non-empty string")) - else: - if name != expected_name: - findings.append(Finding(rel_path, "frontmatter name does not match directory name")) - if len(name) > 64 or not NAME_RE.fullmatch(name): - findings.append(Finding(rel_path, "frontmatter name must match the Agent Skills naming rules")) - - description = frontmatter.get("description") - if not isinstance(description, str) or not description.strip(): - findings.append(Finding(rel_path, "frontmatter description must be a non-empty string")) - elif len(description) > 1024: - findings.append(Finding(rel_path, "frontmatter description exceeds 1024 characters")) - - license_value = frontmatter.get("license") - if not isinstance(license_value, str) or not license_value.strip(): - findings.append(Finding(rel_path, "frontmatter license must be a non-empty string")) - - compatibility = frontmatter.get("compatibility") - if not isinstance(compatibility, str) or not compatibility.strip(): - findings.append(Finding(rel_path, "frontmatter compatibility must be a non-empty string")) - elif len(compatibility) > 500: - findings.append(Finding(rel_path, "frontmatter compatibility exceeds 500 characters")) - - metadata = frontmatter.get("metadata") - if not isinstance(metadata, dict): - findings.append(Finding(rel_path, "frontmatter metadata must be a mapping")) - else: - for key in REQUIRED_METADATA_KEYS: - value = metadata.get(key) - if not isinstance(value, str) or not value.strip(): - findings.append(Finding(rel_path, f"frontmatter metadata.{key} must be a non-empty string")) - for key, value in metadata.items(): - if not isinstance(key, str) or not isinstance(value, str): - findings.append(Finding(rel_path, "frontmatter metadata keys and values must be strings")) - break - - allowed_tools = frontmatter.get("allowed-tools") - if not isinstance(allowed_tools, str) or not allowed_tools.strip(): - findings.append(Finding(rel_path, "frontmatter allowed-tools must be a non-empty string")) - - return findings - - -def validate_openai_metadata(path: Path, metadata: dict[str, object]) -> list[Finding]: - findings: list[Finding] = [] - rel_path = str(path) - interface = metadata.get("interface") - if not isinstance(interface, dict): - return [Finding(rel_path, "missing interface mapping")] - - for key in REQUIRED_INTERFACE_KEYS: - value = interface.get(key) - if not isinstance(value, str) or not value.strip(): - findings.append(Finding(rel_path, f"missing or empty interface.{key}")) - - brand_color = interface.get("brand_color") - if isinstance(brand_color, str) and not HEX_COLOR_RE.fullmatch(brand_color): - findings.append(Finding(rel_path, "interface.brand_color must be a 6-digit hex color")) - - policy = metadata.get("policy") - if not isinstance(policy, dict): - findings.append(Finding(rel_path, "missing policy mapping")) - else: - allow_implicit = policy.get("allow_implicit_invocation") - if not isinstance(allow_implicit, bool): - findings.append(Finding(rel_path, "policy.allow_implicit_invocation must be a boolean")) - - dependencies = metadata.get("dependencies") - if dependencies is not None: - if not isinstance(dependencies, dict): - findings.append(Finding(rel_path, "dependencies must be a mapping when present")) - else: - tools = dependencies.get("tools") - if tools is not None: - if not isinstance(tools, list): - findings.append(Finding(rel_path, "dependencies.tools must be a list when present")) - else: - for idx, tool in enumerate(tools): - if not isinstance(tool, dict): - findings.append(Finding(rel_path, f"dependencies.tools[{idx}] must be a mapping")) - continue - for key in ("type", "value"): - value = tool.get(key) - if not isinstance(value, str) or not value.strip(): - findings.append( - Finding(rel_path, f"dependencies.tools[{idx}].{key} must be a non-empty string") - ) - return findings - - -def find_skill_dirs(repo_root: Path) -> list[Path]: - skills_root = repo_root / "skills" - if not skills_root.exists(): - return [] - return sorted( - path - for path in skills_root.iterdir() - if path.is_dir() and not path.name.startswith(".") and (path / "SKILL.md").exists() - ) - - -def validate_child_guidance(repo_root: Path) -> list[Finding]: - findings: list[Finding] = [] - agents = repo_root / "AGENTS.md" - if not agents.is_file(): - return [Finding("AGENTS.md", "missing child guidance file")] - - text = agents.read_text() - required_snippets = [ - "`python-skills` is a monorepo-owned Socket child", - "Root [`skills/`](./skills/) is the authored workflow surface.", - "The repo root is the Codex plugin root through [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json).", - "Do not reintroduce maintained per-skill `README.md` files", - "Keep user-facing and maintainer-facing Python command examples expressed with `uv`.", - ] - for snippet in required_snippets: - if snippet not in text: - findings.append(Finding("AGENTS.md", f"missing guidance snippet: {snippet}")) - return findings - - -def validate_skill_dir(repo_root: Path, skill_dir: Path) -> list[Finding]: - findings: list[Finding] = [] - skill_md = skill_dir / "SKILL.md" - skill_text = skill_md.read_text() - - try: - frontmatter = parse_frontmatter(skill_text, skill_md) - except ValueError as exc: - return [Finding(str(skill_md.relative_to(repo_root)), str(exc))] - - findings.extend(validate_frontmatter(skill_md.relative_to(repo_root), frontmatter, skill_dir.name)) - - openai_yaml = skill_dir / "agents" / "openai.yaml" - if not openai_yaml.exists(): - findings.append(Finding(str(skill_dir.relative_to(repo_root)), "missing agents/openai.yaml")) - else: - try: - metadata = load_yaml(openai_yaml) - findings.extend(validate_openai_metadata(openai_yaml.relative_to(repo_root), metadata)) - except ValueError as exc: - findings.append(Finding(str(openai_yaml.relative_to(repo_root)), str(exc))) - - for match in PATH_REFERENCE_RE.finditer(skill_text): - rel_path = match.group(1) - candidate = skill_dir / rel_path - if not candidate.exists(): - findings.append(Finding(str(skill_md.relative_to(repo_root)), f"referenced path does not exist: {rel_path}")) - - return findings - - -def validate_plugin_manifest( - repo_root: Path, - manifest_path: Path, - *, - expected_name: str, - require_skills_interface: bool, -) -> list[Finding]: - findings: list[Finding] = [] - rel_path = str(manifest_path.relative_to(repo_root)) - - if not manifest_path.exists(): - return [Finding(rel_path, "missing plugin manifest")] - - try: - manifest = load_json(manifest_path) - except (ValueError, json.JSONDecodeError) as exc: - return [Finding(rel_path, str(exc))] - - required_fields = PLUGIN_REQUIRED_FIELDS if require_skills_interface else ["name", "version", "description"] - for field in required_fields: - value = manifest.get(field) - if value is None or (isinstance(value, str) and not value.strip()): - findings.append(Finding(rel_path, f"missing required plugin field: {field}")) - - plugin_name = manifest.get("name") - if not isinstance(plugin_name, str) or not NAME_RE.fullmatch(plugin_name): - findings.append(Finding(rel_path, "plugin name must match Codex plugin naming rules")) - elif plugin_name != expected_name: - findings.append(Finding(rel_path, f"plugin name must match {expected_name}")) - - version = manifest.get("version") - if not isinstance(version, str) or not re.fullmatch(r"^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.\-]+)?$", version): - findings.append(Finding(rel_path, "plugin version must look like a semantic version")) - - description = manifest.get("description") - if not isinstance(description, str) or not description.strip(): - findings.append(Finding(rel_path, "plugin description must be a non-empty string")) - - for url_field in ("homepage", "repository"): - value = manifest.get(url_field) - if value is not None and (not isinstance(value, str) or not is_http_url(value)): - findings.append(Finding(rel_path, f"{url_field} must be an http or https URL when present")) - - license_value = manifest.get("license") - if license_value is not None and (not isinstance(license_value, str) or not license_value.strip()): - findings.append(Finding(rel_path, "license must be a non-empty string when present")) - - if require_skills_interface: - skills_path_value = manifest.get("skills") - if not isinstance(skills_path_value, str) or not skills_path_value.startswith("./"): - findings.append(Finding(rel_path, "skills must be a plugin-relative path starting with ./")) - else: - skills_path = (repo_root / skills_path_value.removeprefix("./")).resolve() - expected_skills_root = (repo_root / "skills").resolve() - if skills_path != expected_skills_root: - findings.append(Finding(rel_path, "skills path must resolve to the repository skills/ directory")) - if not skills_path.is_dir(): - findings.append(Finding(rel_path, "skills path does not resolve to an existing directory")) - - interface = manifest.get("interface") - if not isinstance(interface, dict): - findings.append(Finding(rel_path, "interface must be a mapping")) - else: - for field in PLUGIN_INTERFACE_REQUIRED_FIELDS: - value = interface.get(field) - if field == "capabilities": - if not isinstance(value, list) or not value or not all( - isinstance(item, str) and item.strip() for item in value - ): - findings.append(Finding(rel_path, "interface.capabilities must be a non-empty list of strings")) - continue - if field == "defaultPrompt": - if not isinstance(value, list) or not value or not all( - isinstance(item, str) and item.strip() for item in value - ): - findings.append(Finding(rel_path, "interface.defaultPrompt must be a non-empty list of strings")) - continue - if not isinstance(value, str) or not value.strip(): - findings.append(Finding(rel_path, f"missing or empty interface.{field}")) - - brand_color = interface.get("brandColor") - if isinstance(brand_color, str) and not HEX_COLOR_RE.fullmatch(brand_color): - findings.append(Finding(rel_path, "interface.brandColor must be a 6-digit hex color")) - - website_url = interface.get("websiteURL") - if isinstance(website_url, str) and not is_http_url(website_url): - findings.append(Finding(rel_path, "interface.websiteURL must be an http or https URL")) - - return findings - - -def run(repo_root: Path) -> list[Finding]: - findings: list[Finding] = [] - skill_dirs = find_skill_dirs(repo_root) - if not skill_dirs: - findings.append(Finding("skills", "no bundled skill directories found under skills/")) - findings.extend(validate_child_guidance(repo_root)) - findings.extend( - validate_plugin_manifest( - repo_root, - repo_root / ".codex-plugin" / "plugin.json", - expected_name=PLUGIN_DIR_NAME, - require_skills_interface=True, - ) - ) - for skill_dir in skill_dirs: - findings.extend(validate_skill_dir(repo_root, skill_dir)) - return findings - - -def main() -> int: - parser = argparse.ArgumentParser(description="Validate python-skills docs and metadata alignment.") - parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") - parser.add_argument("--repo-root", default=".", help="Repository root to validate.") - args = parser.parse_args() - - repo_root = Path(args.repo_root).resolve() - findings = run(repo_root) - - if args.json: - print(json.dumps([finding.__dict__ for finding in findings], indent=2)) - elif findings: - for finding in findings: - print(f"{finding.path}: {finding.message}") - else: - print("No findings.") - - return 1 if findings else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugins/python-skills/shared/bootstrap-contract.md b/plugins/python-skills/shared/bootstrap-contract.md deleted file mode 100644 index 06144aa92..000000000 --- a/plugins/python-skills/shared/bootstrap-contract.md +++ /dev/null @@ -1,59 +0,0 @@ -# Shared Python Bootstrap Contract - -Use this contract for every generated Python project, FastAPI service, and -FastMCP service. It owns the policy common to the three bootstrap entry points; -framework-specific overlays remain with their own skills. - -## Command And Dependency Policy - -- Use `uv` for project creation, dependency changes, locking, syncing, and - command execution. -- Keep runtime imports in `[project].dependencies`, optional user-facing - features in `[project.optional-dependencies]`, and maintainer tooling in - `[dependency-groups]`. -- Install and run `pytest`, Ruff, and mypy through `uv`; do not rely on a - globally installed Python tool. -- Use a single project for one package or service. Use a workspace only when - multiple members have a real local package relationship. - -## Configuration And Secret Policy - -- Commit only safe, non-secret defaults in `.env` when the selected scaffold - uses environment-backed settings. -- Keep machine-local or secret values in ignored `.env.local` or the target - deployment's secret store. -- Keep typed configuration in a dedicated settings module. Tests override - environment values or a settings dependency rather than modifying committed - defaults. - -## Validation And Cleanup - -Run the scaffold's emitted commands first, then use the narrowest configured -checks for the changed member or project: - -```bash -uv run pytest -uv run ruff check . -uv run mypy . -``` - -Run `uv run ruff format --check .` only when the generated or target project -enforces formatting. For a workspace, target the intended member with -`uv run --package <member> ...` when a full workspace sweep is unnecessary. - -Do not overwrite a non-empty target or existing `pyproject.toml` without the -entrypoint's explicit force flag. Initialize git only when requested by the -entrypoint defaults, and remove temporary scaffold output only after reporting -the validation result. - -## Handoff Matrix - -| Need | Owning skill | -| --- | --- | -| Generic package or service scaffold | `bootstrap-uv-python-workspace` | -| New FastAPI service | `bootstrap-python-service` | -| New FastMCP server | `bootstrap-python-mcp-service` | -| Existing FastAPI and FastMCP integration | `integrate-fastapi-fastmcp` | -| Existing-project implementation | `build-python-project` | -| Test setup or testing workflow | `python-testing-workflow` | -| Tooling, package, CI, or upgrade maintenance | The corresponding `python-*-workflow` skill | diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/SKILL.md b/plugins/python-skills/skills/bootstrap-python-mcp-service/SKILL.md deleted file mode 100644 index 473a8bb77..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/SKILL.md +++ /dev/null @@ -1,263 +0,0 @@ ---- -name: bootstrap-python-mcp-service -description: Bootstrap Python MCP server projects and workspaces on macOS using uv and FastMCP with consistent defaults. Use when creating a new MCP server from scratch, scaffolding a single uv MCP project, scaffolding a uv workspace with package/service members, customizing scaffold defaults through layered YAML profiles, initializing pytest+ruff+mypy defaults, creating README.md, initializing git, running initial validation checks, or starting from OpenAPI/FastAPI with MCP mapping guidance. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients on macOS with uv, git, and FastMCP-oriented Python workflows. Use a host-provided FastMCP documentation tool when available; otherwise use the official FastMCP documentation. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-bootstrap -allowed-tools: Bash(uv:*) Bash(git:*) Read ---- - -# Bootstrap Python MCP Service - -## Purpose - -Create FastMCP starter layouts using one direct shell entrypoint backed by the shared `bootstrap-uv-python-workspace` scaffolding scripts plus deterministic MCP overlay logic. - -Read [`shared/bootstrap-contract.md`](../../shared/bootstrap-contract.md) -before changing shared scaffold policy. It owns shared command, configuration, -validation, cleanup, and handoff rules. - -## When To Use - -- Use this skill for new FastMCP server scaffolds. -- Use this skill when the user wants OpenAPI or FastAPI-to-MCP mapping guidance alongside bootstrap. -- Recommend `bootstrap-python-service` when the user wants a FastAPI service but not an MCP server. - -## Single-Path Workflow - -1. Collect the required inputs: - - `name` - - `mode` - - `path` - - optional `python`, `members`, `profile_map`, `force`, `initial_commit`, `no_git_init` -2. Run the canonical entrypoint: - ```bash - scripts/init_fastmcp_service.sh --name <name> --mode <project|workspace> - ``` -3. Let the script delegate to the shared `bootstrap-uv-python-workspace` scaffolding layer, then apply the FastMCP overlay. -4. Apply the shared bootstrap contract for validation and configuration. -5. If the task starts from an existing API, optionally generate a mapping report with `uv run scripts/assess_api_for_mcp.py ...`. -6. Return the generated path plus the exact next-step commands emitted by the script. - -## Commands - -```bash -# Project mode (default) -scripts/init_fastmcp_service.sh --name my-mcp-server - -# Project mode with explicit options -scripts/init_fastmcp_service.sh --name my-mcp-server --mode project --python 3.13 --path /tmp/my-mcp-server - -# Workspace mode with defaults (core-lib package + api-service service) -scripts/init_fastmcp_service.sh --name platform --mode workspace - -# Workspace mode with explicit members and profile mapping -scripts/init_fastmcp_service.sh \ - --name platform \ - --mode workspace \ - --members "core-lib,tools-service,ops-service" \ - --profile-map "core-lib=package,tools-service=service,ops-service=service" - -# Allow non-empty target directory -scripts/init_fastmcp_service.sh --name my-mcp-server --force - -# Skip git initialization -scripts/init_fastmcp_service.sh --name my-mcp-server --no-git-init - -# Create initial commit -scripts/init_fastmcp_service.sh --name my-mcp-server --initial-commit - -# Generate MCP mapping guidance from OpenAPI -uv run scripts/assess_api_for_mcp.py --openapi ./openapi.yaml --out ./mcp_mapping_report.md - -# Generate MCP mapping guidance from existing FastAPI app -uv run scripts/assess_api_for_mcp.py --fastapi app.main:app --out ./mcp_mapping_report.md -``` - -## Inputs - -- `name`: required -- `mode`: `project` or `workspace`; defaults to `project` -- `path`: optional target directory; defaults to `./<name>` -- `python`: optional Python version; defaults to `3.13` -- `members`: optional workspace member CSV for workspace mode -- `profile_map`: optional workspace profile CSV for workspace mode -- `force`: optional flag allowing non-empty target directories -- `initial_commit`: optional flag creating an initial commit after a successful scaffold -- `no_git_init`: optional flag disabling git initialization - -## Outputs - -- `status` - - `success`: scaffold and built-in validation completed - - `blocked`: prerequisites or target-directory constraints prevented the run - - `failed`: the script started but validation or generation failed -- `path_type` - - `primary`: the canonical shell entrypoint completed -- `output` - - resolved project or workspace path - - emitted run commands - - emitted validation commands - - optional mapping-report path - -## Defaults - -- mode: `project` -- Python version: `3.13` -- workspace default members: `core-lib,api-service` -- workspace default profiles: first member `package`, remaining members `service` - -## Base UV/FastAPI Guidance - -The shared scaffold basis follows uv FastAPI integration style: - -```bash -uv add fastapi --extra standard -uv add pydantic-settings python-dotenv -uv run fastapi dev app/main.py -``` - -This skill then overlays FastMCP dependencies and server files for MCP service members. -Generated FastMCP scaffolds should keep safe defaults in `.env`, local or secret overrides in `.env.local`, and typed runtime configuration in `app/config.py`. - -## API Import Guidance - -When starting from OpenAPI or FastAPI, bootstrap first, then map endpoints to MCP primitives: - -1. Generate mapping report with `scripts/assess_api_for_mcp.py`. -2. Classify endpoints into `Resources`, `Tools`, and `Prompts`. -3. Recommend RouteMaps/Transforms only when they improve usability. -4. Keep bootstrap deterministic; defer heavy custom mapping unless requested. - -## FastMCP Documentation Lookup - -Use a host-provided `fastmcp_docs` MCP server when it is available. This plugin -does not package that server. Otherwise, use the official -[FastMCP documentation](https://gofastmcp.com/getting-started/welcome) and -confirm the installed FastMCP version before copying syntax-sensitive examples. - -Suggested queries: - -- `FastMCP quickstart server example` -- `FastMCP tools resources prompts best practices` -- `FastMCP RouteMap Transform` -- `FastMCP from OpenAPI` -- `FastMCP from FastAPI` - -## Guardrails - -- Apply the shared bootstrap-contract guardrails. -- Require at least one service profile member in workspace mode. - -## Fallbacks and Handoffs - -- The preferred path is always `scripts/init_fastmcp_service.sh`. -- Use the shared bootstrap-contract handoff matrix. - -## Automation Suitability - -- Codex App automation: Medium. Useful for recurring FastMCP scaffold checks and mapping-assessment checks. -- Codex CLI automation: High. Strong fit for CI-style scaffold validation. - -## Codex App Automation Prompt Template - -```markdown -Use $bootstrap-python-mcp-service. - -Scope boundaries: -- Work only inside <REPO_PATH>. -- Create or validate scaffold output only in <TARGET_PATH>. -- Restrict work to scaffold generation, optional mapping report generation, and verification. - -Task: -1. If <MODE:PROJECT|WORKSPACE> is PROJECT, run: - `scripts/init_fastmcp_service.sh --name <MCP_SERVICE_NAME> --mode project --path <TARGET_PATH> --python <PYTHON_VERSION> <FORCE_FLAG> <GIT_INIT_MODE>` -2. If <MODE:PROJECT|WORKSPACE> is WORKSPACE, run: - `scripts/init_fastmcp_service.sh --name <MCP_SERVICE_NAME> --mode workspace --path <TARGET_PATH> --python <PYTHON_VERSION> --members "<MEMBERS_CSV>" --profile-map "<PROFILE_MAP>" <FORCE_FLAG> <GIT_INIT_MODE>` -3. If <GENERATE_MAPPING_REPORT:TRUE|FALSE> is TRUE: - - If <MAPPING_INPUT_MODE:NONE|OPENAPI|FASTAPI_IMPORT> is OPENAPI, run: - `uv run scripts/assess_api_for_mcp.py --openapi <MAPPING_INPUT_PATH> --out <TARGET_PATH>/mcp_mapping_report.md` - - If <MAPPING_INPUT_MODE:NONE|OPENAPI|FASTAPI_IMPORT> is FASTAPI_IMPORT, run: - `uv run scripts/assess_api_for_mcp.py --fastapi <MAPPING_INPUT_PATH> --out <TARGET_PATH>/mcp_mapping_report.md` -4. Run verification checks in <TARGET_PATH>: - - `uv run pytest` - - `uv run ruff check .` - - `uv run mypy .` - -Output contract: -1. STATUS: PASS or FAIL -2. COMMANDS: exact commands executed -3. RESULTS: concise outcomes for scaffold and checks -4. If report generated: include report path -5. If FAIL: provide likely root cause and minimal remediation -``` - -## Codex CLI Automation Prompt Template - -```bash -codex exec --full-auto --sandbox workspace-write --cd "<REPO_PATH>" "<PROMPT_BODY>" -``` - -`<PROMPT_BODY>` template: - -```markdown -Use $bootstrap-python-mcp-service. -Scope is limited to scaffold generation in <TARGET_PATH>, optional mapping report generation, and verification checks. -Run only commands needed for this flow, then return STATUS, exact command transcript, concise results, and minimal remediation if failures occur. -``` - -## Customization Placeholders - -- `<REPO_PATH>` -- `<MCP_SERVICE_NAME>` -- `<MODE:PROJECT|WORKSPACE>` -- `<TARGET_PATH>` -- `<PYTHON_VERSION>` -- `<MEMBERS_CSV>` -- `<PROFILE_MAP>` -- `<FORCE_FLAG>` -- `<GIT_INIT_MODE>` -- `<MAPPING_INPUT_MODE:NONE|OPENAPI|FASTAPI_IMPORT>` -- `<MAPPING_INPUT_PATH>` -- `<GENERATE_MAPPING_REPORT:TRUE|FALSE>` - -## Interactive Customization Workflow - -1. Ask for mode, name, path, Python version, and git/force flags. -2. If workspace mode, also ask for members and profile map. -3. Return both: -- A YAML profile for durable reuse. -- The exact scaffold command to run. -4. Use this precedence order: -- CLI flags -- `--config` profile file -- `.codex/profiles/bootstrap-python-mcp-service/customization.yaml` -- `~/.config/gaelic-ghost/python-skills/bootstrap-python-mcp-service/customization.yaml` -- Script defaults -5. If users want temporary reset behavior: -- `--bypassing-all-profiles` -- `--bypassing-repo-profile` -- `--deleting-repo-profile` -6. If users provide no customization or profile files, keep existing script defaults unchanged. -7. See [`references/interactive-customization.md`](references/interactive-customization.md) for schema and examples. - -## References - -- `../../shared/bootstrap-contract.md` -- `references/mcp-mapping-guidelines.md` -- `references/fastmcp-docs-lookup.md` -- `references/customization.md` -- `references/interactive-customization.md` - -## Script Inventory - -- `scripts/init_fastmcp_service.sh` -- `scripts/assess_api_for_mcp.py` -- Delegates to the shared workspace bootstrap scripts shipped by `bootstrap-uv-python-workspace`. - -## Assets - -- `assets/README.md.tmpl` diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/agents/openai.yaml b/plugins/python-skills/skills/bootstrap-python-mcp-service/agents/openai.yaml deleted file mode 100644 index 20962280c..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Bootstrap Python MCP Service" - short_description: "Bootstrap uv FastMCP projects and workspaces." - brand_color: "#1D4ED8" - default_prompt: "Use $bootstrap-python-mcp-service to create a uv FastMCP project or workspace, generate committed .env defaults plus ignored .env.local overrides, add pydantic-settings configuration, run the canonical shell entrypoint, and optionally generate an OpenAPI or FastAPI mapping report with uv run." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/assets/README.md.tmpl b/plugins/python-skills/skills/bootstrap-python-mcp-service/assets/README.md.tmpl deleted file mode 100644 index 596a3c848..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/assets/README.md.tmpl +++ /dev/null @@ -1,43 +0,0 @@ -# __SERVICE_NAME__ - -Starter Python MCP server scaffolded with `uv` and `FastMCP`. - -## Requirements - -- macOS -- `uv` -- Python 3.11+ - -## Install dependencies - -```bash -uv sync -``` - -## Configuration - -- `.env` is committed and intended for safe, non-secret defaults. -- `.env.local` is ignored and intended for local or secret overrides. -- `app/config.py` uses `pydantic-settings` to load `.env` and then `.env.local`. - -## Run locally - -```bash -uv run python app/server.py -``` - -## Run tests - -```bash -uv run pytest -uv run ruff check . -uv run mypy . -``` - -## Project layout - -- `app/server.py`: FastMCP server entrypoint -- `app/config.py`: typed settings loaded from `.env` and `.env.local` -- `app/tools.py`: reusable tool logic -- `tests/test_tools.py`: baseline tool and settings checks -- `pyproject.toml`: project metadata and dependencies diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/assets/profiles/init_fastmcp_service.config.yaml b/plugins/python-skills/skills/bootstrap-python-mcp-service/assets/profiles/init_fastmcp_service.config.yaml deleted file mode 100644 index 7ebf99fe4..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/assets/profiles/init_fastmcp_service.config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# bootstrap-python-mcp-service config profile -# Use with: scripts/init_fastmcp_service.sh --config <this-file> - -name: "my-mcp-service" -mode: "project" -path: "./my-mcp-service" -python: "3.13" - -# Workspace-only fields: -members: "core-lib,api-service" -profile_map: "core-lib=package,api-service=service" - -force: false -initial_commit: false -no_git_init: false diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/customization.md b/plugins/python-skills/skills/bootstrap-python-mcp-service/references/customization.md deleted file mode 100644 index 6523945f6..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# Customization Guide - -Use this reference when you need to change the defaults shipped by `bootstrap-python-mcp-service`. - -## High-Impact Knobs - -- Python version default -- workspace member defaults and profile-map behavior -- generated FastMCP overlay files and dependencies -- mapping-report strictness in `scripts/assess_api_for_mcp.py` -- quality command stack (`pytest`, `ruff`, `mypy`) - -## Audit Checklist After Changes - -- `SKILL.md` examples match script help text -- project and workspace output both reflect the documented FastMCP overlay -- mapping-report commands use the current `uv run python ...` form -- `agents/openai.yaml` still describes the shipped scope accurately diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/fastmcp-docs-lookup.md b/plugins/python-skills/skills/bootstrap-python-mcp-service/references/fastmcp-docs-lookup.md deleted file mode 100644 index 3fdbaa03c..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/fastmcp-docs-lookup.md +++ /dev/null @@ -1,37 +0,0 @@ -# FastMCP Docs Lookup Patterns - -Use these search prompts with a host-provided `fastmcp_docs` MCP server when -one is available. The Python Skills plugin does not package that server. When -it is unavailable, search the official [FastMCP documentation](https://gofastmcp.com/getting-started/welcome) -instead, then compare the result against the installed FastMCP version before -using syntax-sensitive examples. - -## General bootstrapping - -- `FastMCP quickstart server example` -- `FastMCP project structure best practices` - -## MCP primitive design - -- `FastMCP tools resources prompts best practices` -- `FastMCP tool schema guidance` - -## Conversion and integration - -- `FastMCP from OpenAPI` -- `FastMCP from FastAPI` -- `FastMCP RouteMap` -- `FastMCP Transform` - -## Validation and deployment - -- `FastMCP inspect command` -- `FastMCP deployment options` -- `FastMCP global settings .env` -- `FastMCP environment variable interpolation` - -## How to apply results - -- Prefer official examples for syntax-sensitive code. -- When docs conflict with local assumptions, follow docs and note the update. -- Keep bootstrap defaults stable; treat advanced mapping as a second pass. diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/interactive-customization.md b/plugins/python-skills/skills/bootstrap-python-mcp-service/references/interactive-customization.md deleted file mode 100644 index 739c36647..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/interactive-customization.md +++ /dev/null @@ -1,41 +0,0 @@ -# Interactive Customization - -## Checklist - -1. Confirm `mode` (`project` or `workspace`). -2. Gather `name`, `path`, and `python`. -3. If workspace mode, gather `members` and optional `profile_map`. -4. Confirm `force`, `initial_commit`, and `no_git_init`. -5. Return both YAML profile and exact command. - -## Schema - -- `name` (string, required) -- `mode` (string: `project|workspace`, default `project`) -- `path` (string, default `./<name>`) -- `python` (string, default `3.13`) -- `members` (string CSV, workspace only) -- `profile_map` (string mapping CSV, workspace only) -- `force` (bool, default `false`) -- `initial_commit` (bool, default `false`) -- `no_git_init` (bool, default `false`) - -## Source Precedence - -1. CLI flags -2. `--config` file -3. Repo profile: `.codex/profiles/bootstrap-python-mcp-service/customization.yaml` -4. Global profile: `~/.config/gaelic-ghost/python-skills/bootstrap-python-mcp-service/customization.yaml` -5. Script defaults - -## Reset and Cleanup - -- `--bypassing-all-profiles`: ignore global and repo profile for this run. -- `--bypassing-repo-profile`: ignore only repo profile for this run. -- `--deleting-repo-profile`: delete repo profile before running. - -## Troubleshooting - -- Unknown key in YAML: script exits with an error naming the key. -- Invalid mode/flag combinations: script guardrails still apply. -- Missing explicit config file with `--config`: script exits with an error. diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/mcp-mapping-guidelines.md b/plugins/python-skills/skills/bootstrap-python-mcp-service/references/mcp-mapping-guidelines.md deleted file mode 100644 index a15d5fb61..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/references/mcp-mapping-guidelines.md +++ /dev/null @@ -1,48 +0,0 @@ -# MCP Mapping Guidelines - -Use this file when converting an existing HTTP API surface to MCP capabilities. - -## Primitive selection heuristics - -- `Resource`: Prefer for read-oriented, stable, side-effect-free access patterns. -- `Tool`: Prefer for mutations, workflow actions, asynchronous jobs, or operations with side effects. -- `Prompt`: Prefer for reusable operator workflows, request templates, or guided usage patterns. - -## Naming conventions - -- Use concise, action-oriented names for tools. -- Use domain nouns for resources. -- Remove transport-specific details (for example `/api/v1/`) from exposed MCP names when possible. - -## RouteMap heuristics - -Use custom RouteMaps when one or more are true: - -- Endpoint naming is transport-centric rather than user-centric. -- URL version prefixes leak into capability names. -- Endpoint depth or path nesting hurts discoverability. -- Multiple endpoints represent one conceptual capability and should be grouped. - -## Transform heuristics - -Use Transforms when one or more are true: - -- Request bodies are deeply nested wrappers around a few meaningful fields. -- Response envelopes are inconsistent across similar endpoints. -- Pagination or metadata structures differ and should be normalized. -- You need to hide transport-only fields from MCP clients. - -## Workspace mapping considerations - -When bootstrapping MCP workspaces, establish service boundaries before detailed mapping: - -- Map MCP Tools/Resources to service members by domain ownership. -- Keep shared models/utilities in package members and avoid duplicating mapping logic across services. -- Start with per-service primitive naming, then normalize cross-service naming during RouteMap review. -- Produce one mapping report per service member for large workspaces. - -## Bootstrap policy - -- Keep initial bootstrap minimal and deterministic. -- Emit a concrete RouteMap/Transform recommendation report. -- Defer implementation of heavy custom mapping unless explicitly requested. diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/assess_api_for_mcp.py b/plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/assess_api_for_mcp.py deleted file mode 100755 index f96473ac1..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/assess_api_for_mcp.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Assess OpenAPI/FastAPI endpoints for MCP mapping guidance.""" - -from __future__ import annotations - -import argparse -import importlib -import json -import re -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"} -ACTION_HINTS = { - "search", - "sync", - "refresh", - "execute", - "run", - "submit", - "approve", - "reject", - "create", - "update", - "delete", - "import", - "export", - "generate", - "send", -} - - -@dataclass -class Endpoint: - method: str - path: str - operation_id: str - summary: str - - -def load_openapi(path: Path) -> dict[str, Any]: - text = path.read_text(encoding="utf-8") - suffix = path.suffix.lower() - if suffix == ".json": - return json.loads(text) - - try: - import yaml # type: ignore - except ImportError as exc: # pragma: no cover - raise SystemExit( - "YAML OpenAPI parsing requires PyYAML. Run this script in a uv project with " - "PyYAML available, or add it with: uv add pyyaml" - ) from exc - - return yaml.safe_load(text) - - -def endpoints_from_openapi(spec: dict[str, Any]) -> list[Endpoint]: - endpoints: list[Endpoint] = [] - for path, operations in (spec.get("paths") or {}).items(): - if not isinstance(operations, dict): - continue - for method, operation in operations.items(): - if method.lower() not in HTTP_METHODS: - continue - if not isinstance(operation, dict): - operation = {} - endpoints.append( - Endpoint( - method=method.upper(), - path=str(path), - operation_id=str(operation.get("operationId") or ""), - summary=str(operation.get("summary") or operation.get("description") or ""), - ) - ) - return endpoints - - -def endpoints_from_fastapi(import_path: str) -> list[Endpoint]: - if ":" not in import_path: - raise SystemExit("--fastapi must be in format module:app") - - module_name, app_name = import_path.split(":", 1) - module = importlib.import_module(module_name) - app = getattr(module, app_name, None) - if app is None: - raise SystemExit(f"Could not find '{app_name}' in module '{module_name}'") - - endpoints: list[Endpoint] = [] - for route in getattr(app, "routes", []): - path = getattr(route, "path", None) - methods = getattr(route, "methods", None) - if not path or not methods: - continue - - for method in sorted(methods): - upper = str(method).upper() - if upper in {"HEAD", "OPTIONS"}: - continue - endpoints.append( - Endpoint( - method=upper, - path=str(path), - operation_id=str(getattr(route, "name", "") or ""), - summary=str(getattr(route, "summary", "") or ""), - ) - ) - return endpoints - - -def classify(endpoint: Endpoint) -> tuple[str, str]: - path = endpoint.path.lower() - opid = endpoint.operation_id.lower() - method = endpoint.method.upper() - - has_action_hint = any(f"/{hint}" in path for hint in ACTION_HINTS) or any( - hint in opid for hint in ACTION_HINTS - ) - - if method in {"POST", "PUT", "PATCH", "DELETE"} or has_action_hint: - return ( - "Tool", - "State-changing or action-oriented endpoint; expose as an MCP tool.", - ) - - if method == "GET": - if re.search(r"\{[^}]+\}", path): - return ( - "Resource", - "Read endpoint with path parameters; model as a resource fetch.", - ) - return ( - "Resource", - "Read endpoint; model as a resource list/query where practical.", - ) - - return ("Tool", "Non-standard method; default to tool with explicit input schema.") - - -def route_map_suggestion(endpoint: Endpoint) -> str | None: - if re.search(r"^/api/v\d+", endpoint.path): - return "Strip versioned prefix (e.g., /api/v1) in RouteMap naming." - if endpoint.path.count("/") >= 4: - return "Use RouteMap alias to shorten deeply nested paths for MCP ergonomics." - return None - - -def transform_suggestion(endpoint: Endpoint) -> str | None: - if endpoint.method.upper() == "GET" and endpoint.path.endswith("s"): - return "Consider response transform to normalize list envelopes and pagination fields." - if endpoint.method.upper() in {"POST", "PUT", "PATCH"}: - return "Consider request transform to flatten nested payload wrappers into tool args." - return None - - -def build_findings(endpoints: list[Endpoint]) -> list[str]: - findings: list[str] = [] - if not endpoints: - findings.append("No endpoints discovered. Verify source path/import and try again.") - return findings - - mutation_count = sum(e.method in {"POST", "PUT", "PATCH", "DELETE"} for e in endpoints) - if mutation_count / len(endpoints) > 0.6: - findings.append( - "API is mutation-heavy. Prioritize tool design with clear side-effect descriptions and confirmations." - ) - - if any(e.path.startswith("/admin") or "/internal" in e.path for e in endpoints): - findings.append( - "Sensitive/internal routes detected. Apply strict auth boundaries before exposing to MCP clients." - ) - - if any("/search" in e.path or "query" in e.operation_id.lower() for e in endpoints): - findings.append( - "Search/query patterns detected. Prefer read-oriented resources when side effects are absent." - ) - - return findings - - -def render_report(source_label: str, endpoints: list[Endpoint]) -> str: - lines: list[str] = [] - lines.append("# MCP Mapping Report") - lines.append("") - lines.append(f"Source: `{source_label}`") - lines.append(f"Endpoints analyzed: **{len(endpoints)}**") - lines.append("") - lines.append("## Proposed Endpoint Mapping") - lines.append("") - lines.append("| Method | Path | Suggested MCP Primitive | Rationale |") - lines.append("|---|---|---|---|") - - route_map_notes: list[str] = [] - transform_notes: list[str] = [] - - for endpoint in endpoints: - primitive, rationale = classify(endpoint) - lines.append( - f"| {endpoint.method} | `{endpoint.path}` | {primitive} | {rationale} |" - ) - - route_note = route_map_suggestion(endpoint) - if route_note: - route_map_notes.append(f"- `{endpoint.method} {endpoint.path}`: {route_note}") - - transform_note = transform_suggestion(endpoint) - if transform_note: - transform_notes.append(f"- `{endpoint.method} {endpoint.path}`: {transform_note}") - - lines.append("") - lines.append("## MCP Best-Practice Findings") - lines.append("") - findings = build_findings(endpoints) - for finding in findings: - lines.append(f"- {finding}") - - lines.append("") - lines.append("## Suggested RouteMap Strategy") - lines.append("") - if route_map_notes: - lines.extend(route_map_notes) - else: - lines.append("- Default route naming appears acceptable; custom RouteMaps can be deferred.") - - lines.append("") - lines.append("## Suggested Transform Strategy") - lines.append("") - if transform_notes: - lines.extend(transform_notes) - else: - lines.append("- No immediate transform requirements detected; start with native schemas.") - - lines.append("") - lines.append("## Recommended Bootstrap Follow-up") - lines.append("") - lines.append("1. Keep bootstrap mapping simple and ship a minimal MCP surface first.") - lines.append("2. Add RouteMaps for naming clarity after first client feedback.") - lines.append("3. Add Transforms where payload shape harms usability or consistency.") - lines.append("4. Validate exposed tools/resources with representative prompt flows.") - lines.append("") - - return "\n".join(lines) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - source = parser.add_mutually_exclusive_group(required=True) - source.add_argument("--openapi", help="Path to OpenAPI file (.json/.yaml/.yml)") - source.add_argument("--fastapi", help="FastAPI import in form module:app") - parser.add_argument("--out", default="mcp_mapping_report.md", help="Output markdown path") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - - if args.openapi: - source_path = Path(args.openapi) - spec = load_openapi(source_path) - endpoints = endpoints_from_openapi(spec) - source_label = str(source_path) - else: - endpoints = endpoints_from_fastapi(args.fastapi) - source_label = args.fastapi - - report = render_report(source_label, endpoints) - out_path = Path(args.out) - out_path.write_text(report, encoding="utf-8") - print(f"Wrote MCP mapping report: {out_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/init_fastmcp_service.sh b/plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/init_fastmcp_service.sh deleted file mode 100755 index a4b7931d6..000000000 --- a/plugins/python-skills/skills/bootstrap-python-mcp-service/scripts/init_fastmcp_service.sh +++ /dev/null @@ -1,553 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -usage() { - cat <<USAGE -Usage: - $(basename "$0") --name <service-name> [options] - -Options: - --name <name> Service/project/workspace name (required) - --mode <project|workspace> Bootstrap mode (default: project) - --path <target-path> Target directory (default: ./<name>) - --python <version> Python version (default: 3.13) - --members "a,b,c" Workspace members (workspace mode only) - --profile-map "a=package,b=service" - Workspace profile assignments (workspace mode only) - --config <path> Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - --force Allow non-empty target directory - --initial-commit Create an initial git commit after scaffold - --no-git-init Skip git initialization - -h, --help Show help -USAGE -} - -fail() { - echo "[ERROR] $*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || fail "Missing required command '$1'. Install it and re-run the FastMCP scaffold." -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -bool_to_int() { - local value - value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" - case "$value" in - 1|true|yes|on) printf '1\n' ;; - 0|false|no|off) printf '0\n' ;; - *) fail "invalid boolean value '$1'" ;; - esac -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - name) NAME="$value" ;; - mode) MODE="$value" ;; - path) TARGET_PATH="$value" ;; - python) PYTHON_VERSION="$value" ;; - members) MEMBERS="$value" ;; - profile_map) PROFILE_MAP="$value" ;; - force) FORCE="$(bool_to_int "$value")" ;; - initial_commit) INITIAL_COMMIT="$(bool_to_int "$value")" ;; - no_git_init) NO_GIT_INIT="$(bool_to_int "$value")" ;; - *) fail "unknown config key '$key'" ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "invalid config line at $path:$lineno" - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "empty config key at $path:$lineno" - apply_config_value "$key" "$value" - done < "$path" -} - -abs_path() { - local input="$1" - if [[ -d "$input" ]]; then - (cd "$input" && pwd) - else - local parent - parent="$(dirname "$input")" - local base - base="$(basename "$input")" - mkdir -p "$parent" - (cd "$parent" && printf '%s/%s\n' "$(pwd)" "$base") - fi -} - -profile_for_member() { - local member="$1" - local default_profile="$2" - local map="$3" - - if [[ -z "$map" ]]; then - printf '%s\n' "$default_profile" - return - fi - - local old_ifs="$IFS" - IFS=',' - for entry in ${(s:,:)map}; do - local key="${entry%%=*}" - local value="${entry#*=}" - if [[ "$key" == "$member" ]]; then - IFS="$old_ifs" - printf '%s\n' "$value" - return - fi - done - IFS="$old_ifs" - - printf '%s\n' "$default_profile" -} - -overlay_fastmcp_member() { - local member_path="$1" - local member_name="$2" - local module_name - module_name="$(printf '%s' "${member_name//-/_}" | tr -c '[:alnum:]_' '_')" - - [[ -d "$member_path" ]] || fail "member path not found: $member_path" - - ( - cd "$member_path" - - uv remove fastapi >/dev/null 2>&1 || true - uv add fastmcp pydantic-settings python-dotenv - - rm -f app/main.py app/config.py main.py tests/test_service.py tests/test_tools.py tests/test_*_service.py(N) - mkdir -p app tests - touch app/__init__.py - - cat > app/config.py <<PY -from functools import lru_cache - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - name: str = "${member_name}" - environment: str = "development" - log_level: str = "INFO" - - model_config = SettingsConfigDict( - env_prefix="MCP_", - env_file=(".env", ".env.local"), - env_file_encoding="utf-8", - ) - - -@lru_cache -def get_settings() -> Settings: - return Settings() -PY - - cat > .env <<PY -MCP_NAME="${member_name}" -MCP_ENVIRONMENT="development" -MCP_LOG_LEVEL="INFO" -PY - - cat > .env.local <<'EOF_ENV_LOCAL' -# Local overrides for developer-specific or secret values. -# This file is ignored by git on purpose. -EOF_ENV_LOCAL - - touch .gitignore - if ! grep -Fqx ".env.local" .gitignore; then - printf '%s\n' ".env.local" >> .gitignore - fi - - cat > app/tools.py <<'PY' -from app.config import Settings - - -def health_payload(settings: Settings) -> dict[str, str]: - return { - "status": "ok", - "service": settings.name, - "environment": settings.environment, - "log_level": settings.log_level, - } -PY - - cat > app/server.py <<PY -from fastmcp import FastMCP - -from app.config import get_settings -from app.tools import health_payload - -settings = get_settings() -mcp = FastMCP(settings.name) - - -@mcp.tool -def health() -> dict[str, str]: - """Return a lightweight health payload for smoke testing.""" - return health_payload(settings) - - -if __name__ == "__main__": - mcp.run(log_level=settings.log_level.lower()) -PY - - cat > "tests/test_${module_name}_tools.py" <<'PY' -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from app.config import get_settings -from app.tools import health_payload - - -def test_health_payload() -> None: - payload = health_payload(get_settings()) - assert payload["status"] == "ok" - assert payload["service"] - assert payload["environment"] == "development" -PY - ) -} - -render_project_readme() { - local service_name="$1" - local output_path="$2" - - local template - template="$SCRIPT_DIR/../assets/README.md.tmpl" - [[ -f "$template" ]] || fail "README template not found at '$template'" - - sed "s/__SERVICE_NAME__/$service_name/g" "$template" > "$output_path" -} - -NAME="" -MODE="project" -TARGET_PATH="" -PYTHON_VERSION="3.13" -MEMBERS="" -PROFILE_MAP="" -FORCE=0 -INITIAL_COMMIT=0 -NO_GIT_INIT=0 - -SKILL_NAME="bootstrap-python-mcp-service" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -ORIGINAL_ARGS=("$@") - -while [[ $# -gt 0 ]]; do - case "$1" in - --config) - CONFIG_PATH="${2:-}" - [[ -n "$CONFIG_PATH" ]] || fail "--config requires a value" - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - --name|--mode|--path|--python|--members|--profile-map) - [[ $# -ge 2 ]] || fail "$1 requires a value" - shift 2 - ;; - --force|--initial-commit|--no-git-init) - shift - ;; - *) - shift - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -set -- "${ORIGINAL_ARGS[@]}" -while [[ $# -gt 0 ]]; do - case "$1" in - --name) - NAME="${2:-}" - shift 2 - ;; - --mode) - MODE="${2:-}" - shift 2 - ;; - --path) - TARGET_PATH="${2:-}" - shift 2 - ;; - --python) - PYTHON_VERSION="${2:-}" - shift 2 - ;; - --members) - MEMBERS="${2:-}" - shift 2 - ;; - --profile-map) - PROFILE_MAP="${2:-}" - shift 2 - ;; - --force) - FORCE=1 - shift - ;; - --initial-commit) - INITIAL_COMMIT=1 - shift - ;; - --no-git-init) - NO_GIT_INIT=1 - shift - ;; - --config) - shift 2 - ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - fail "unknown argument '$1'" - ;; - esac -done - -[[ -n "$NAME" ]] || { - usage >&2 - fail "--name is required" -} -[[ "$MODE" == "project" || "$MODE" == "workspace" ]] || fail "--mode must be 'project' or 'workspace'" -[[ "$NO_GIT_INIT" -eq 1 && "$INITIAL_COMMIT" -eq 1 ]] && fail "--initial-commit requires git initialization" - -if [[ -z "$TARGET_PATH" ]]; then - TARGET_PATH="./$NAME" -fi -TARGET_PATH="$(abs_path "$TARGET_PATH")" - -SHARED_PROJECT_SCRIPT="$SCRIPT_DIR/../../bootstrap-uv-python-workspace/scripts/init_uv_python_project.sh" -SHARED_WORKSPACE_SCRIPT="$SCRIPT_DIR/../../bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh" - -[[ -x "$SHARED_PROJECT_SCRIPT" ]] || fail "shared script not found or not executable: $SHARED_PROJECT_SCRIPT" -[[ -x "$SHARED_WORKSPACE_SCRIPT" ]] || fail "shared script not found or not executable: $SHARED_WORKSPACE_SCRIPT" - -require_cmd uv -if [[ "$NO_GIT_INIT" -eq 0 || "$INITIAL_COMMIT" -eq 1 ]]; then - require_cmd git -fi - -if [[ "$MODE" == "project" ]]; then - [[ -z "$MEMBERS" ]] || fail "--members is only valid with --mode workspace" - [[ -z "$PROFILE_MAP" ]] || fail "--profile-map is only valid with --mode workspace" - - cmd=( - "$SHARED_PROJECT_SCRIPT" - --name "$NAME" - --profile service - --path "$TARGET_PATH" - --python "$PYTHON_VERSION" - --bypassing-all-profiles - ) - [[ "$FORCE" -eq 1 ]] && cmd+=(--force) - [[ "$NO_GIT_INIT" -eq 1 ]] && cmd+=(--no-git-init) - - "${cmd[@]}" - - overlay_fastmcp_member "$TARGET_PATH" "$NAME" - render_project_readme "$NAME" "$TARGET_PATH/README.md" - - ( - cd "$TARGET_PATH" - uv lock - uv sync - uv run pytest - uv run ruff check . - uv run mypy . - - if [[ "$NO_GIT_INIT" -eq 0 ]]; then - if [[ ! -d .git ]]; then - git init - fi - git add . - if [[ "$INITIAL_COMMIT" -eq 1 ]]; then - git commit -m "Initial scaffold from bootstrap-python-mcp-service" - fi - fi - ) - - echo "Bootstrap complete: $TARGET_PATH" - echo "Run: cd $TARGET_PATH && uv run python app/server.py" - echo "Checks: cd $TARGET_PATH && uv run pytest && uv run ruff check . && uv run mypy ." - echo "Config: keep committed defaults in $TARGET_PATH/.env and local or secret overrides in $TARGET_PATH/.env.local" - exit 0 -fi - -cmd=( - "$SHARED_WORKSPACE_SCRIPT" - --name "$NAME" - --path "$TARGET_PATH" - --python "$PYTHON_VERSION" - --bypassing-all-profiles -) -[[ -n "$MEMBERS" ]] && cmd+=(--members "$MEMBERS") -[[ -n "$PROFILE_MAP" ]] && cmd+=(--profile-map "$PROFILE_MAP") -[[ "$FORCE" -eq 1 ]] && cmd+=(--force) -[[ "$NO_GIT_INIT" -eq 1 ]] && cmd+=(--no-git-init) - -"${cmd[@]}" - -members_csv="$MEMBERS" -if [[ -z "$members_csv" ]]; then - members_csv="core-lib,api-service" -fi - -typeset -a WORKSPACE_MEMBERS=() -typeset -a SERVICE_MEMBERS=() - -old_ifs="$IFS" -IFS=',' -for raw in ${(s:,:)members_csv}; do - member="$(printf '%s' "$raw" | xargs)" - [[ -n "$member" ]] || continue - WORKSPACE_MEMBERS+=("$member") -done -IFS="$old_ifs" - -[[ "${#WORKSPACE_MEMBERS[@]}" -gt 0 ]] || fail "no valid members provided" - -idx=1 -for member in "${WORKSPACE_MEMBERS[@]}"; do - default_profile="service" - if [[ "$idx" -eq 1 ]]; then - default_profile="package" - fi - - profile="$(profile_for_member "$member" "$default_profile" "$PROFILE_MAP")" - [[ "$profile" == "package" || "$profile" == "service" ]] || fail "invalid profile '$profile' for member '$member'" - - if [[ "$profile" == "service" ]]; then - SERVICE_MEMBERS+=("$member") - fi - idx=$((idx + 1)) -done - -[[ "${#SERVICE_MEMBERS[@]}" -gt 0 ]] || fail "workspace mode requires at least one service profile member" - -for svc in "${SERVICE_MEMBERS[@]}"; do - overlay_fastmcp_member "$TARGET_PATH/packages/$svc" "$svc" -done - -( - cd "$TARGET_PATH" - uv lock - uv sync --all-packages - uv run --all-packages pytest - - for member in "${WORKSPACE_MEMBERS[@]}"; do - ( - cd "packages/$member" - uv run ruff check . - uv run mypy . - ) - done - - if [[ "$NO_GIT_INIT" -eq 0 ]]; then - if [[ ! -d .git ]]; then - git init - fi - git add . - if [[ "$INITIAL_COMMIT" -eq 1 ]]; then - git commit -m "Initial workspace scaffold from bootstrap-python-mcp-service" - fi - fi -) - -echo "Workspace bootstrap complete: $TARGET_PATH" -echo "Run example: cd $TARGET_PATH/packages/<service-member> && uv run python app/server.py" -echo "Checks: cd $TARGET_PATH && uv run --all-packages pytest; (cd packages/<member> && uv run ruff check . && uv run mypy .)" -echo "Config: each workspace member now includes a committed .env plus an ignored .env.local override file." diff --git a/plugins/python-skills/skills/bootstrap-python-service/SKILL.md b/plugins/python-skills/skills/bootstrap-python-service/SKILL.md deleted file mode 100644 index 25bdd70d4..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/SKILL.md +++ /dev/null @@ -1,233 +0,0 @@ ---- -name: bootstrap-python-service -description: Bootstrap Python FastAPI services on macOS using uv with consistent project and workspace scaffolds. Use when creating a new backend/API service from scratch, scaffolding a single uv service project, scaffolding a uv workspace with package/service members, customizing scaffold defaults through layered YAML profiles, initializing pytest+ruff+mypy defaults, creating README.md, initializing git, and running initial validation commands. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients on macOS with uv, git, FastAPI-oriented Python workflows, and shell access for the bundled scripts. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-bootstrap -allowed-tools: Bash(uv:*) Bash(git:*) Read ---- - -# Bootstrap Python Service - -## Purpose - -Create production-oriented FastAPI starter layouts using one direct shell entrypoint backed by the shared `bootstrap-uv-python-workspace` scaffolding scripts. - -Read [`shared/bootstrap-contract.md`](../../shared/bootstrap-contract.md) -before changing shared scaffold policy. It owns the shared command, -configuration, validation, cleanup, and handoff rules. - -## When To Use - -- Use this skill for new FastAPI service scaffolds. -- Use this skill when the user wants either a single service project or a workspace with service members. -- Hand off to `bootstrap-uv-python-workspace` only when the task is generic `uv` scaffolding without FastAPI-specific expectations. - -## Single-Path Workflow - -1. Collect the required inputs: - - `name` - - `mode` - - `path` - - optional `python`, `members`, `profile_map`, `force`, `initial_commit`, `no_git_init` -2. Run the canonical entrypoint: - ```bash - scripts/init_python_service.sh --name <name> --mode <project|workspace> - ``` -3. Let the script delegate to the shared `bootstrap-uv-python-workspace` scaffolding layer. -4. Apply the shared bootstrap contract for validation and configuration. -5. Return the generated path plus the exact next-step run and check commands emitted by the script. - -## Commands - -```bash -# Project mode (default) -scripts/init_python_service.sh --name my-service - -# Project mode with explicit options -scripts/init_python_service.sh --name my-service --mode project --python 3.13 --path /tmp/my-service - -# Workspace mode with defaults (core-lib package + api-service service) -scripts/init_python_service.sh --name platform --mode workspace - -# Workspace mode with explicit members and profile mapping -scripts/init_python_service.sh \ - --name platform \ - --mode workspace \ - --members "core-lib,billing-service,orders-service" \ - --profile-map "core-lib=package,billing-service=service,orders-service=service" - -# Allow non-empty target directory -scripts/init_python_service.sh --name my-service --force - -# Skip git initialization -scripts/init_python_service.sh --name my-service --no-git-init - -# Create initial commit -scripts/init_python_service.sh --name my-service --initial-commit -``` - -## Inputs - -- `name`: required -- `mode`: `project` or `workspace`; defaults to `project` -- `path`: optional target directory; defaults to `./<name>` -- `python`: optional Python version; defaults to `3.13` -- `members`: optional workspace member CSV for workspace mode -- `profile_map`: optional workspace profile CSV for workspace mode -- `force`: optional flag allowing non-empty target directories -- `initial_commit`: optional flag creating an initial commit after a successful scaffold -- `no_git_init`: optional flag disabling git initialization - -## Outputs - -- `status` - - `success`: scaffold and built-in validation completed - - `blocked`: prerequisites or target-directory constraints prevented the run - - `failed`: the script started but validation or generation failed -- `path_type` - - `primary`: the canonical shell entrypoint completed -- `output` - - resolved project or workspace path - - emitted run commands - - emitted validation commands - -## Defaults - -- mode: `project` -- Python version: `3.13` -- workspace default members: `core-lib,api-service` -- workspace default profiles: first member `package`, remaining members `service` - -## Guardrails - -- Apply the shared bootstrap-contract guardrails. - -## FastAPI Guidance - -Use uv FastAPI integration style as primary guidance: - -```bash -uv add fastapi --extra standard -uv add pydantic-settings python-dotenv -uv run fastapi dev app/main.py -# optional production-style local run -uv run fastapi run app/main.py -``` - -Generated FastAPI scaffolds should use the shared configuration policy and the -documented FastAPI settings pattern with cached settings loading. - -## Fallbacks and Handoffs - -- The preferred path is always `scripts/init_python_service.sh`. -- Use the shared bootstrap-contract handoff matrix. - -## Automation Suitability - -- Codex App automation: Medium. Useful for recurring FastAPI scaffold smoke checks and regression checks. -- Codex CLI automation: High. Strong fit for CI or scheduled scaffolder reliability checks. - -## Codex App Automation Prompt Template - -```markdown -Use $bootstrap-python-service. - -Scope boundaries: -- Work only inside <REPO_PATH>. -- Create or validate scaffold output only in <TARGET_PATH>. -- Limit activity to scaffolding and verification; no unrelated refactors. - -Task: -1. If <MODE:PROJECT|WORKSPACE> is PROJECT, run: - `scripts/init_python_service.sh --name <SERVICE_NAME> --mode project --path <TARGET_PATH> --python <PYTHON_VERSION> <FORCE_FLAG> <GIT_INIT_MODE>` -2. If <MODE:PROJECT|WORKSPACE> is WORKSPACE, run: - `scripts/init_python_service.sh --name <SERVICE_NAME> --mode workspace --path <TARGET_PATH> --python <PYTHON_VERSION> --members "<MEMBERS_CSV>" --profile-map "<PROFILE_MAP>" <FORCE_FLAG> <GIT_INIT_MODE>` -3. Validate generated checks: - - `uv run pytest` - - `uv run ruff check .` - - `uv run mypy .` -4. If mode is PROJECT, also validate generated run commands: - - `uv run fastapi dev app/main.py` - - `uv run fastapi run app/main.py` - -Output contract: -1. STATUS: PASS or FAIL -2. GENERATED_PATH: final output path -3. COMMANDS: exact commands executed -4. RESULTS: concise check outputs -5. If FAIL: short root-cause summary and minimal remediation steps -``` - -## Codex CLI Automation Prompt Template - -```bash -codex exec --full-auto --sandbox workspace-write --cd "<REPO_PATH>" "<PROMPT_BODY>" -``` - -Optional machine-readable variant: - -```bash -codex exec --json --full-auto --sandbox workspace-write --cd "<REPO_PATH>" "<PROMPT_BODY>" -``` - -`<PROMPT_BODY>` template: - -```markdown -Use $bootstrap-python-service. -Scope is scaffolding plus verification only in <TARGET_PATH> under <REPO_PATH>. -Run the scaffold command for <MODE:PROJECT|WORKSPACE>, then run pytest, ruff, and mypy. -If project mode, confirm FastAPI dev/run commands are valid. -Return STATUS, generated path, exact command transcript, and minimal remediation on failure. -``` - -## Customization Placeholders - -- `<REPO_PATH>` -- `<SERVICE_NAME>` -- `<MODE:PROJECT|WORKSPACE>` -- `<TARGET_PATH>` -- `<PYTHON_VERSION>` -- `<MEMBERS_CSV>` -- `<PROFILE_MAP>` -- `<FORCE_FLAG>` -- `<GIT_INIT_MODE>` - -## Interactive Customization Workflow - -1. Ask for mode, name, path, Python version, and git/force flags. -2. If workspace mode, also ask for members and profile map. -3. Return both: -- A YAML profile for durable reuse. -- The exact scaffold command to run. -4. Use this precedence order: -- CLI flags -- `--config` profile file -- `.codex/profiles/bootstrap-python-service/customization.yaml` -- `~/.config/gaelic-ghost/python-skills/bootstrap-python-service/customization.yaml` -- Script defaults -5. If users want temporary reset behavior: -- `--bypassing-all-profiles` -- `--bypassing-repo-profile` -- `--deleting-repo-profile` -6. If users provide no customization or profile files, keep existing script defaults unchanged. -7. See [`references/interactive-customization.md`](references/interactive-customization.md) for schema and examples. - -## References - -- `../../shared/bootstrap-contract.md` -- `references/conventions.md` -- `references/customization.md` -- `references/interactive-customization.md` - -## Script Inventory - -- `scripts/init_python_service.sh` -- Delegates to the shared workspace bootstrap scripts shipped by `bootstrap-uv-python-workspace`. - -## Assets - -- `assets/README.md.tmpl` diff --git a/plugins/python-skills/skills/bootstrap-python-service/agents/openai.yaml b/plugins/python-skills/skills/bootstrap-python-service/agents/openai.yaml deleted file mode 100644 index ed21a28a0..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Bootstrap Python Service" - short_description: "Bootstrap uv FastAPI projects and workspaces." - brand_color: "#0F766E" - default_prompt: "Use $bootstrap-python-service to create a uv FastAPI project or workspace, generate committed .env defaults plus ignored .env.local overrides, add pydantic-settings configuration, run the canonical shell entrypoint, and return the emitted run and validation commands." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/bootstrap-python-service/assets/README.md.tmpl b/plugins/python-skills/skills/bootstrap-python-service/assets/README.md.tmpl deleted file mode 100644 index edfb8c0da..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/assets/README.md.tmpl +++ /dev/null @@ -1,44 +0,0 @@ -# __SERVICE_NAME__ - -Starter Python service scaffolded with `uv` and `FastAPI`. - -## Requirements - -- macOS -- `uv` -- Python 3.11+ - -## Install dependencies - -```bash -uv sync -``` - -## Configuration - -- `.env` is committed and intended for safe, non-secret defaults. -- `.env.local` is ignored and intended for local or secret overrides. -- `app/config.py` uses `pydantic-settings` to load `.env` and then `.env.local`. - -## Run locally - -```bash -uv run fastapi dev app/main.py -``` - -Open http://127.0.0.1:8000/docs for Swagger UI. - -## Run tests - -```bash -uv run pytest -uv run ruff check . -uv run mypy . -``` - -## Project layout - -- `app/main.py`: FastAPI app with health endpoint -- `app/config.py`: typed settings loaded from `.env` and `.env.local` -- `tests/test_service.py`: baseline app and settings checks -- `pyproject.toml`: project metadata and dependencies diff --git a/plugins/python-skills/skills/bootstrap-python-service/assets/profiles/init_python_service.config.yaml b/plugins/python-skills/skills/bootstrap-python-service/assets/profiles/init_python_service.config.yaml deleted file mode 100644 index fce8274dc..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/assets/profiles/init_python_service.config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# bootstrap-python-service config profile -# Use with: scripts/init_python_service.sh --config <this-file> - -name: "my-service" -mode: "project" -path: "./my-service" -python: "3.13" - -# Workspace-only fields: -members: "core-lib,api-service" -profile_map: "core-lib=package,api-service=service" - -force: false -initial_commit: false -no_git_init: false diff --git a/plugins/python-skills/skills/bootstrap-python-service/references/conventions.md b/plugins/python-skills/skills/bootstrap-python-service/references/conventions.md deleted file mode 100644 index 941dbed2a..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/references/conventions.md +++ /dev/null @@ -1,46 +0,0 @@ -# Conventions - -## Platform and tooling - -- Target macOS development workflows. -- Use `uv` for initialization, dependency management, lock/sync, and command execution. -- Use `git` for repository initialization unless explicitly disabled. - -## Modes - -- `project`: scaffold one FastAPI service. -- `workspace`: scaffold a uv workspace with package/service members. - -## FastAPI defaults - -- Use uv FastAPI integration style: - -```bash -uv add fastapi --extra standard -uv add pydantic-settings python-dotenv -uv run fastapi dev app/main.py -``` - -- Keep optional production-style local command: - -```bash -uv run fastapi run app/main.py -``` - -## Quality defaults - -- Always include `pytest`, `ruff`, and `mypy`. -- Verify with: - -```bash -uv run pytest -uv run ruff check . -uv run mypy . -``` - -- In workspace mode, run `uv run --all-packages pytest` plus per-member lint/type checks. - -## Project structure defaults - -- Service profile: `app/main.py`, `app/config.py`, committed `.env`, ignored `.env.local`, `tests/test_service.py`, `pyproject.toml`. -- Workspace root: `[tool.uv.workspace]` with members under `packages/`. diff --git a/plugins/python-skills/skills/bootstrap-python-service/references/customization.md b/plugins/python-skills/skills/bootstrap-python-service/references/customization.md deleted file mode 100644 index 2d1b91dcd..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/references/customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# Customization Guide - -Use this reference when you need to change the defaults shipped by `bootstrap-python-service`. - -## High-Impact Knobs - -- Python version default -- FastAPI app layout and generated test shape -- workspace member defaults and profile-map examples -- quality command stack (`pytest`, `ruff`, `mypy`) -- guardrail strictness around `--force`, `--no-git-init`, and `--initial-commit` - -## Audit Checklist After Changes - -- `SKILL.md` examples match script help text -- generated scaffold layout matches the documented paths -- generated next-step commands actually run -- repo-level docs still describe the active public surface correctly diff --git a/plugins/python-skills/skills/bootstrap-python-service/references/interactive-customization.md b/plugins/python-skills/skills/bootstrap-python-service/references/interactive-customization.md deleted file mode 100644 index 3bdb110a0..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/references/interactive-customization.md +++ /dev/null @@ -1,41 +0,0 @@ -# Interactive Customization - -## Checklist - -1. Confirm `mode` (`project` or `workspace`). -2. Gather `name`, `path`, and `python`. -3. If workspace mode, gather `members` and optional `profile_map`. -4. Confirm `force`, `initial_commit`, and `no_git_init`. -5. Return both YAML profile and exact command. - -## Schema - -- `name` (string, required) -- `mode` (string: `project|workspace`, default `project`) -- `path` (string, default `./<name>`) -- `python` (string, default `3.13`) -- `members` (string CSV, workspace only) -- `profile_map` (string mapping CSV, workspace only) -- `force` (bool, default `false`) -- `initial_commit` (bool, default `false`) -- `no_git_init` (bool, default `false`) - -## Source Precedence - -1. CLI flags -2. `--config` file -3. Repo profile: `.codex/profiles/bootstrap-python-service/customization.yaml` -4. Global profile: `~/.config/gaelic-ghost/python-skills/bootstrap-python-service/customization.yaml` -5. Script defaults - -## Reset and Cleanup - -- `--bypassing-all-profiles`: ignore global and repo profile for this run. -- `--bypassing-repo-profile`: ignore only repo profile for this run. -- `--deleting-repo-profile`: delete repo profile before running. - -## Troubleshooting - -- Unknown key in YAML: script exits with an error naming the key. -- Invalid mode/flag combinations: script guardrails still apply. -- Missing explicit config file with `--config`: script exits with an error. diff --git a/plugins/python-skills/skills/bootstrap-python-service/scripts/init_python_service.sh b/plugins/python-skills/skills/bootstrap-python-service/scripts/init_python_service.sh deleted file mode 100755 index e67ab28fe..000000000 --- a/plugins/python-skills/skills/bootstrap-python-service/scripts/init_python_service.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -usage() { - cat <<USAGE -Usage: - $(basename "$0") --name <service-name> [options] - -Options: - --name <name> Service/project/workspace name (required) - --mode <project|workspace> Bootstrap mode (default: project) - --path <target-path> Target directory (default: ./<name>) - --python <version> Python version (default: 3.13) - --members "a,b,c" Workspace members (workspace mode only) - --profile-map "a=package,b=service" - Workspace profile assignments (workspace mode only) - --config <path> Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - --force Allow non-empty target directory - --initial-commit Create an initial git commit after scaffold - --no-git-init Skip git initialization - -h, --help Show help -USAGE -} - -fail() { - echo "[ERROR] $*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || fail "Missing required command '$1'. Install it and re-run the FastAPI scaffold." -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -bool_to_int() { - local value - value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" - case "$value" in - 1|true|yes|on) printf '1\n' ;; - 0|false|no|off) printf '0\n' ;; - *) fail "invalid boolean value '$1'" ;; - esac -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - name) NAME="$value" ;; - mode) MODE="$value" ;; - path) TARGET_PATH="$value" ;; - python) PYTHON_VERSION="$value" ;; - members) MEMBERS="$value" ;; - profile_map) PROFILE_MAP="$value" ;; - force) FORCE="$(bool_to_int "$value")" ;; - initial_commit) INITIAL_COMMIT="$(bool_to_int "$value")" ;; - no_git_init) NO_GIT_INIT="$(bool_to_int "$value")" ;; - *) fail "unknown config key '$key'" ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "invalid config line at $path:$lineno" - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "empty config key at $path:$lineno" - apply_config_value "$key" "$value" - done < "$path" -} - -NAME="" -MODE="project" -TARGET_PATH="" -PYTHON_VERSION="3.13" -MEMBERS="" -PROFILE_MAP="" -FORCE=0 -INITIAL_COMMIT=0 -NO_GIT_INIT=0 - -SKILL_NAME="bootstrap-python-service" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -ORIGINAL_ARGS=("$@") - -while [[ $# -gt 0 ]]; do - case "$1" in - --config) - CONFIG_PATH="${2:-}" - [[ -n "$CONFIG_PATH" ]] || fail "--config requires a value" - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - --name|--mode|--path|--python|--members|--profile-map) - [[ $# -ge 2 ]] || fail "$1 requires a value" - shift 2 - ;; - --force|--initial-commit|--no-git-init) - shift - ;; - *) - shift - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -set -- "${ORIGINAL_ARGS[@]}" -while [[ $# -gt 0 ]]; do - case "$1" in - --name) - NAME="${2:-}" - shift 2 - ;; - --mode) - MODE="${2:-}" - shift 2 - ;; - --path) - TARGET_PATH="${2:-}" - shift 2 - ;; - --python) - PYTHON_VERSION="${2:-}" - shift 2 - ;; - --members) - MEMBERS="${2:-}" - shift 2 - ;; - --profile-map) - PROFILE_MAP="${2:-}" - shift 2 - ;; - --force) - FORCE=1 - shift - ;; - --initial-commit) - INITIAL_COMMIT=1 - shift - ;; - --no-git-init) - NO_GIT_INIT=1 - shift - ;; - --config) - shift 2 - ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - fail "unknown argument '$1'" - ;; - esac -done - -[[ -n "$NAME" ]] || { - usage >&2 - fail "--name is required" -} -[[ "$MODE" == "project" || "$MODE" == "workspace" ]] || fail "--mode must be 'project' or 'workspace'" -[[ "$NO_GIT_INIT" -eq 1 && "$INITIAL_COMMIT" -eq 1 ]] && fail "--initial-commit requires git initialization" - -if [[ -z "$TARGET_PATH" ]]; then - TARGET_PATH="./$NAME" -fi - -SHARED_PROJECT_SCRIPT="$SCRIPT_DIR/../../bootstrap-uv-python-workspace/scripts/init_uv_python_project.sh" -SHARED_WORKSPACE_SCRIPT="$SCRIPT_DIR/../../bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh" - -[[ -x "$SHARED_PROJECT_SCRIPT" ]] || fail "shared script not found or not executable: $SHARED_PROJECT_SCRIPT" -[[ -x "$SHARED_WORKSPACE_SCRIPT" ]] || fail "shared script not found or not executable: $SHARED_WORKSPACE_SCRIPT" - -require_cmd uv -if [[ "$NO_GIT_INIT" -eq 0 || "$INITIAL_COMMIT" -eq 1 ]]; then - require_cmd git -fi - -if [[ "$MODE" == "project" ]]; then - [[ -z "$MEMBERS" ]] || fail "--members is only valid with --mode workspace" - [[ -z "$PROFILE_MAP" ]] || fail "--profile-map is only valid with --mode workspace" - - cmd=( - "$SHARED_PROJECT_SCRIPT" - --name "$NAME" - --profile service - --path "$TARGET_PATH" - --python "$PYTHON_VERSION" - --bypassing-all-profiles - ) - - [[ "$FORCE" -eq 1 ]] && cmd+=(--force) - [[ "$INITIAL_COMMIT" -eq 1 ]] && cmd+=(--initial-commit) - [[ "$NO_GIT_INIT" -eq 1 ]] && cmd+=(--no-git-init) - - "${cmd[@]}" - - echo "Bootstrap complete: $TARGET_PATH" - echo "Run (dev): cd $TARGET_PATH && uv run fastapi dev app/main.py" - echo "Run (prod-style): cd $TARGET_PATH && uv run fastapi run app/main.py" - echo "Checks: cd $TARGET_PATH && uv run pytest && uv run ruff check . && uv run mypy ." - echo "Config: keep committed defaults in $TARGET_PATH/.env and local or secret overrides in $TARGET_PATH/.env.local" - exit 0 -fi - -cmd=( - "$SHARED_WORKSPACE_SCRIPT" - --name "$NAME" - --path "$TARGET_PATH" - --python "$PYTHON_VERSION" - --bypassing-all-profiles -) - -[[ -n "$MEMBERS" ]] && cmd+=(--members "$MEMBERS") -[[ -n "$PROFILE_MAP" ]] && cmd+=(--profile-map "$PROFILE_MAP") -[[ "$FORCE" -eq 1 ]] && cmd+=(--force) -[[ "$INITIAL_COMMIT" -eq 1 ]] && cmd+=(--initial-commit) -[[ "$NO_GIT_INIT" -eq 1 ]] && cmd+=(--no-git-init) - -"${cmd[@]}" - -echo "Workspace bootstrap complete: $TARGET_PATH" -echo "Dev run example: cd $TARGET_PATH/packages/<service-member> && uv run fastapi dev app/main.py" -echo "Checks: cd $TARGET_PATH && uv run --all-packages pytest; (cd packages/<member> && uv run ruff check . && uv run mypy .)" -echo "Config: each workspace member now includes a committed .env plus an ignored .env.local override file." diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/SKILL.md b/plugins/python-skills/skills/bootstrap-uv-python-workspace/SKILL.md deleted file mode 100644 index 7bd54cbb4..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/SKILL.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -name: bootstrap-uv-python-workspace -description: Bootstrap new Python projects and multi-package workspaces with uv on macOS using deterministic scripts and consistent defaults. Use when creating a new uv Python project, scaffolding a uv monorepo/workspace, setting up package or service profiles, customizing scaffold defaults through layered YAML profiles, initializing dev tooling (pytest, ruff, mypy), creating README scaffolds, or initializing git with an optional first commit. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients on macOS with uv, git, Python project scaffolding workflows, and shell access for the bundled scripts. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-bootstrap -allowed-tools: Bash(uv:*) Bash(git:*) Read ---- - -# Bootstrap UV Python Workspace - -## Purpose - -Create repeatable `uv`-based scaffolds for both single projects and workspaces. -Use this skill as the shared scaffolding basis for other Python bootstrap skills that need consistent `uv` project and workspace defaults. - -Read [`shared/bootstrap-contract.md`](../../shared/bootstrap-contract.md) -before changing defaults or validating generated output. It owns shared command, -configuration, validation, cleanup, and handoff policy. - -## When To Use - -- Use this skill for generic `uv` project or workspace creation. -- Use this skill when the user needs package or service scaffolding without the higher-level FastAPI or FastMCP overlays. -- Expect downstream higher-level Python bootstrap skills to delegate here rather than duplicate its defaults. - -## Primary Workflow - -1. Choose the canonical entrypoint: - - single project: `scripts/init_uv_python_project.sh` - - workspace: `scripts/init_uv_python_workspace.sh` -2. Select the profile or profile map: - - `package` - - `service` -3. Run the selected script with explicit `--name` and optional `--path`, `--python`, `--force`, `--initial-commit`, and `--no-git-init`. -4. Apply the shared bootstrap contract for validation and configuration. -5. Return the generated path plus the exact next-step commands emitted by the script. - -## Commands - -```bash -# Package project -scripts/init_uv_python_project.sh --name my-lib --profile package - -# Service project -scripts/init_uv_python_project.sh --name my-service --profile service --python 3.13 - -# Workspace with defaults (core-lib package + api-service service) -scripts/init_uv_python_workspace.sh --name my-workspace - -# Workspace with explicit members and profile mapping -scripts/init_uv_python_workspace.sh \ - --name platform \ - --members "core-lib,billing-service,orders-service" \ - --profile-map "core-lib=package,billing-service=service,orders-service=service" - -# Allow non-empty target directory -scripts/init_uv_python_project.sh --name my-lib --force - -# Skip git initialization -scripts/init_uv_python_workspace.sh --name platform --no-git-init - -# Create initial commit after successful scaffold -scripts/init_uv_python_project.sh --name my-service --profile service --initial-commit -``` - -## Defaults - -- Python version: `3.13` (override with `--python`). -- Git initialization: enabled by default (disable via `--no-git-init`). -- Workspace defaults: -- Members: `core-lib,api-service` -- Profiles: first member `package`, remaining members `service` -- Local linking: services depend on the first package member using uv workspace sources. - -## Outputs - -- `status` - - `success`: scaffold and built-in validation completed - - `blocked`: prerequisites or target-directory constraints prevented the run - - `failed`: the script started but validation or generation failed -- `path_type` - - `primary`: one of the two canonical shell entrypoints completed -- `output` - - resolved project or workspace path - - emitted validation commands - - generated run examples - -## Guardrails - -- Refuse non-empty target directories unless `--force` is set. -- Apply the shared bootstrap-contract guardrails. - -## Fallbacks and Handoffs - -- Preferred paths are `scripts/init_uv_python_project.sh` and `scripts/init_uv_python_workspace.sh`. -- Use the shared bootstrap-contract handoff matrix. - -## Automation Suitability - -- Codex App automation: Medium. Best for scheduled scaffold health checks, not day-to-day product delivery. -- Codex CLI automation: High. Strong fit for CI or scheduled scaffold validation. - -## Codex App Automation Prompt Template - -```markdown -Use $bootstrap-uv-python-workspace. - -Scope boundaries: -- Work only inside <REPO_PATH>. -- Create temporary scaffolds only under <SCRATCH_ROOT>/<NAME>-<STAMP>. -- Do not modify unrelated files outside the temporary scaffold path. - -Task: -1. If <MODE:PROJECT|WORKSPACE> is PROJECT, run: - `scripts/init_uv_python_project.sh --name <NAME> --profile <PROFILE:PACKAGE|SERVICE> --python <PYTHON_VERSION> --path <SCRATCH_ROOT>/<NAME>-<STAMP> <FORCE_FLAG> <GIT_INIT_MODE>` -2. If <MODE:PROJECT|WORKSPACE> is WORKSPACE, run: - `scripts/init_uv_python_workspace.sh --name <NAME> --python <PYTHON_VERSION> --path <SCRATCH_ROOT>/<NAME>-<STAMP> --members "<MEMBERS_CSV>" --profile-map "<PROFILE_MAP>" <FORCE_FLAG> <GIT_INIT_MODE>` -3. Run validation checks in the scaffold root: - - `uv run pytest` - - `uv run ruff check .` - - `uv run mypy .` -4. If <KEEP_OR_CLEANUP_ARTIFACTS:KEEP|CLEANUP> is CLEANUP, remove the scaffold directory after reporting results. - -Output contract: -1. STATUS: PASS or FAIL -2. COMMANDS: exact commands executed, in order -3. RESULTS: concise check outcomes -4. If FAIL: include a short stderr summary and minimal fix recommendation -5. If PASS with no findings: include "safe to archive" -``` - -## Codex CLI Automation Prompt Template - -```bash -codex exec --full-auto --sandbox workspace-write --cd "<REPO_PATH>" "<PROMPT_BODY>" -``` - -`<PROMPT_BODY>` template: - -```markdown -Use $bootstrap-uv-python-workspace. -Stay strictly within <REPO_PATH>. Create temporary artifacts only under <SCRATCH_ROOT>/<NAME>-<STAMP>. -Run scaffold generation for <MODE:PROJECT|WORKSPACE>, then run: -- `uv run pytest` -- `uv run ruff check .` -- `uv run mypy .` -Return STATUS, exact commands, and concise results only. If failures occur, provide only the minimal remediation needed. -``` - -## Customization Placeholders - -- `<REPO_PATH>` -- `<SCRATCH_ROOT>` -- `<NAME>` -- `<STAMP>` -- `<MODE:PROJECT|WORKSPACE>` -- `<PROFILE:PACKAGE|SERVICE>` -- `<MEMBERS_CSV>` -- `<PROFILE_MAP>` -- `<PYTHON_VERSION>` -- `<FORCE_FLAG>` -- `<GIT_INIT_MODE>` -- `<KEEP_OR_CLEANUP_ARTIFACTS:KEEP|CLEANUP>` - -## Interactive Customization Workflow - -1. Ask whether users want project or workspace script execution. -2. Gather name, path, Python version, and git/force flags. -3. If project script, gather profile (`package` or `service`). -4. If workspace script, gather members and optional profile map. -5. Return both: -- A YAML profile for durable reuse. -- The exact scaffold command to run. -6. Use this precedence order: -- CLI flags -- `--config` profile file -- `.codex/profiles/bootstrap-uv-python-workspace/customization.yaml` -- `~/.config/gaelic-ghost/python-skills/bootstrap-uv-python-workspace/customization.yaml` -- Script defaults -7. If users want temporary reset behavior: -- `--bypassing-all-profiles` -- `--bypassing-repo-profile` -- `--deleting-repo-profile` -8. If users provide no customization or profile files, keep existing script defaults unchanged. -9. See [`references/interactive-customization.md`](references/interactive-customization.md) for schema and examples. - -## References - -- `../../shared/bootstrap-contract.md` -- `references/uv-command-recipes.md` -- `references/customization.md` - -## Script Inventory - -- `scripts/init_uv_python_project.sh` -- `scripts/init_uv_python_workspace.sh` - -## Assets - -- `assets/README.md.tmpl` diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/agents/openai.yaml b/plugins/python-skills/skills/bootstrap-uv-python-workspace/agents/openai.yaml deleted file mode 100644 index 9a466d94c..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Bootstrap UV Python Workspace" - short_description: "Bootstrap uv Python projects and workspaces." - brand_color: "#7C3AED" - default_prompt: "Use $bootstrap-uv-python-workspace to create a uv project or workspace with package or service profiles, generate committed .env defaults plus ignored .env.local overrides, add pydantic-settings configuration, run the canonical shell entrypoint, and return the emitted validation commands." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/README.md.tmpl b/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/README.md.tmpl deleted file mode 100644 index 47dfa9c06..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/README.md.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -# __NAME__ - -## What This Is - -This __TYPE__ was scaffolded with the `bootstrap-uv-python-workspace` skill. - -## Quick Start - -```bash -uv sync -__RUN_COMMANDS__ -``` - -## Configuration - -- Keep committed, non-secret defaults in `.env`. -- Put machine-local or secret overrides in `.env.local`. -- Generated projects use `pydantic-settings` and load `.env` first, then `.env.local`. - -## Quality Checks - -```bash -__TEST_COMMANDS__ -``` - -## Notes - -__NOTES__ diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_project.config.yaml b/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_project.config.yaml deleted file mode 100644 index 1e092a07f..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_project.config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# bootstrap-uv-python-workspace project config profile -# Use with: scripts/init_uv_python_project.sh --config <this-file> - -name: "my-project" -path: "./my-project" -profile: "package" -python: "3.13" -force: false -initial_commit: false -no_git_init: false diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_workspace.config.yaml b/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_workspace.config.yaml deleted file mode 100644 index 0cf42c41d..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/assets/profiles/init_uv_python_workspace.config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# bootstrap-uv-python-workspace workspace config profile -# Use with: scripts/init_uv_python_workspace.sh --config <this-file> - -name: "my-workspace" -path: "./my-workspace" -members: "core-lib,api-service" -profile_map: "core-lib=package,api-service=service" -python: "3.13" -force: false -initial_commit: false -no_git_init: false diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/customization.md b/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/customization.md deleted file mode 100644 index 56af35d09..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# Customization Guide - -Use this reference when you need to change the defaults shipped by `bootstrap-uv-python-workspace`. - -## High-Impact Knobs - -- Python version default -- package versus service profile behavior -- default workspace members and first-package linking behavior -- generated README template content -- quality command stack (`pytest`, `ruff`, `mypy`) - -## Audit Checklist After Changes - -- single-project and workspace examples match the scripts -- generated layout and README content reflect the documented defaults -- service-member linking still matches the narrative in `SKILL.md` -- downstream skills that delegate here remain aligned diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/interactive-customization.md b/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/interactive-customization.md deleted file mode 100644 index 8f451aa6a..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/interactive-customization.md +++ /dev/null @@ -1,40 +0,0 @@ -# Interactive Customization - -## Checklist - -1. Choose script mode: -- project script: `init_uv_python_project.sh` -- workspace script: `init_uv_python_workspace.sh` -2. Gather `name`, `path`, and `python`. -3. For project script, gather `profile`. -4. For workspace script, gather `members` and optional `profile_map`. -5. Confirm `force`, `initial_commit`, and `no_git_init`. -6. Return both YAML profile and exact command. - -## Schema - -Project script keys: -- `name`, `path`, `profile`, `python`, `force`, `initial_commit`, `no_git_init` - -Workspace script keys: -- `name`, `path`, `members`, `profile_map`, `python`, `force`, `initial_commit`, `no_git_init` - -## Source Precedence - -1. CLI flags -2. `--config` file -3. Repo profile: `.codex/profiles/bootstrap-uv-python-workspace/customization.yaml` -4. Global profile: `~/.config/gaelic-ghost/python-skills/bootstrap-uv-python-workspace/customization.yaml` -5. Script defaults - -## Reset and Cleanup - -- `--bypassing-all-profiles`: ignore global and repo profile for this run. -- `--bypassing-repo-profile`: ignore only repo profile for this run. -- `--deleting-repo-profile`: delete repo profile before running. - -## Troubleshooting - -- Unknown key in YAML: script exits with an error naming the key. -- Invalid profile values: must be `package` or `service`. -- Missing explicit config file with `--config`: script exits with an error. diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/uv-command-recipes.md b/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/uv-command-recipes.md deleted file mode 100644 index 8f9f86e78..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/references/uv-command-recipes.md +++ /dev/null @@ -1,78 +0,0 @@ -# UV Command Recipes - -Use these recipes when adapting or troubleshooting the bootstrap scripts. - -## Project Init - -```bash -uv init --package --lib --name my-lib --python 3.13 --vcs none ./my-lib -uv init --app --name my-service --python 3.13 --vcs none ./my-service -``` - -## Add Dependencies - -```bash -uv add fastapi --extra standard -uv add pydantic-settings python-dotenv -uv add --group dev pytest ruff mypy -``` - -## Lock and Sync - -```bash -uv lock -uv sync -``` - -## Run Commands - -```bash -uv run fastapi dev app/main.py -uv run pytest -uv run ruff check . -uv run mypy . -``` - -## Workspace Root Setup - -Add this to the workspace root `pyproject.toml`: - -```toml -[tool.uv.workspace] -members = ["packages/*"] -``` - -## Workspace Member Bootstrap - -```bash -uv init --package --lib --name core-lib --python 3.13 --vcs none ./packages/core-lib -uv init --app --name api-service --python 3.13 --vcs none ./packages/api-service -``` - -## Workspace Member Dependencies - -```bash -uv add --package core-lib --group dev pytest ruff mypy -uv add --package api-service --group dev pytest ruff mypy -uv add --package core-lib pydantic-settings python-dotenv -uv add --package api-service pydantic-settings python-dotenv -uv add --package api-service fastapi --extra standard -``` - -## Workspace Local Linking - -Create a dependency from one workspace member to another: - -```bash -uv add --package api-service core-lib -``` - -This writes a `tool.uv.sources` entry with `workspace = true` in the dependent member. - -## Workspace Lock, Sync, and Verification - -```bash -uv lock -uv sync --all-packages -uv run --all-packages pytest -``` diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_project.sh b/plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_project.sh deleted file mode 100755 index 5fe01d7eb..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_project.sh +++ /dev/null @@ -1,497 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -usage() { - cat <<'USAGE' -Bootstrap a uv Python project. - -Usage: - init_uv_python_project.sh --name <project-name> [options] - -Required: - --name <name> Project name - -Options: - --path <path> Target directory (default: ./<name>) - --profile <package|service> Scaffold profile (default: package) - --python <version> Python version (default: 3.13) - --config <path> Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - --force Allow non-empty target directory - --initial-commit Create initial git commit on success - --no-git-init Skip git init (default is enabled) - -h, --help Show help -USAGE -} - -fail() { - echo "[ERROR] $*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || fail "Missing required command '$1'. Install it and re-run the scaffold." -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -bool_to_int() { - local value - value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" - case "$value" in - 1|true|yes|on) printf '1\n' ;; - 0|false|no|off) printf '0\n' ;; - *) fail "Invalid boolean value '$1' in customization data." ;; - esac -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - name) NAME="$value" ;; - path) TARGET="$value" ;; - profile) PROFILE="$value" ;; - python) PYTHON_VERSION="$value" ;; - force) FORCE="$(bool_to_int "$value")" ;; - initial_commit) INITIAL_COMMIT="$(bool_to_int "$value")" ;; - no_git_init) - if [[ "$(bool_to_int "$value")" -eq 1 ]]; then - GIT_INIT=0 - else - GIT_INIT=1 - fi - ;; - *) fail "Unknown config key '$key' in customization file." ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "Config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "Invalid config line at $path:$lineno. Expected 'key: value'." - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "Empty config key at $path:$lineno." - apply_config_value "$key" "$value" - done < "$path" -} - -abs_path() { - local input="$1" - if [[ -d "$input" ]]; then - (cd "$input" && pwd) - else - local parent="$(dirname "$input")" - local base="$(basename "$input")" - mkdir -p "$parent" - (cd "$parent" && printf '%s/%s\n' "$(pwd)" "$base") - fi -} - -normalize_module_name() { - local raw="${1//-/_}" - printf '%s' "$raw" | tr -c '[:alnum:]_' '_' -} - -target_from_python() { - local v="$1" - local major="$(printf '%s' "$v" | cut -d. -f1)" - local minor="$(printf '%s' "$v" | cut -d. -f2)" - printf 'py%s%s\n' "$major" "$minor" -} - -append_tooling_config() { - local pyproject="$1" - local py_version="$2" - - if grep -q '^\[tool\.ruff\]' "$pyproject"; then - return - fi - - cat >>"$pyproject" <<EOF_CFG - -[tool.ruff] -line-length = 100 -target-version = "$(target_from_python "$py_version")" - -[tool.ruff.lint] -select = ["E", "F", "UP", "B"] - -[tool.pytest.ini_options] -addopts = "-q" -testpaths = ["tests"] - -[tool.mypy] -python_version = "$py_version" -warn_unused_configs = true -check_untyped_defs = true -no_implicit_optional = true -EOF_CFG -} - -ensure_gitignore_entry() { - local file_path="$1" - local entry="$2" - - touch "$file_path" - if ! grep -Fqx "$entry" "$file_path"; then - printf '%s\n' "$entry" >>"$file_path" - fi -} - -write_env_files() { - local project_root="$1" - local app_name="$2" - - cat >"$project_root/.env" <<EOF_ENV -APP_NAME="$app_name" -APP_ENVIRONMENT="development" -EOF_ENV - - cat >"$project_root/.env.local" <<'EOF_ENV_LOCAL' -# Local overrides for developer-specific or secret values. -# This file is ignored by git on purpose. -EOF_ENV_LOCAL - - ensure_gitignore_entry "$project_root/.gitignore" ".env.local" -} - -write_package_settings() { - local module_dir="$1" - local module_name="$2" - - cat >"$module_dir/config.py" <<EOF_CFG -from functools import lru_cache - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - name: str = "$module_name" - environment: str = "development" - - model_config = SettingsConfigDict( - env_prefix="APP_", - env_file=(".env", ".env.local"), - env_file_encoding="utf-8", - ) - - -@lru_cache -def get_settings() -> Settings: - return Settings() -EOF_CFG -} - -write_service_settings() { - local app_dir="$1" - local app_name="$2" - - cat >"$app_dir/config.py" <<EOF_CFG -from functools import lru_cache - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - name: str = "$app_name" - environment: str = "development" - - model_config = SettingsConfigDict( - env_prefix="APP_", - env_file=(".env", ".env.local"), - env_file_encoding="utf-8", - ) - - -@lru_cache -def get_settings() -> Settings: - return Settings() -EOF_CFG -} - -render_readme() { - local template="$1" - local out="$2" - local name="$3" - local type="$4" - local run_cmds="$5" - local test_cmds="$6" - local notes="$7" - - sed \ - -e "s|__NAME__|$name|g" \ - -e "s|__TYPE__|$type|g" \ - -e "s|__RUN_COMMANDS__|$run_cmds|g" \ - -e "s|__TEST_COMMANDS__|$test_cmds|g" \ - -e "s|__NOTES__|$notes|g" \ - "$template" >"$out" -} - -NAME="" -TARGET="" -PROFILE="package" -PYTHON_VERSION="3.13" -FORCE=0 -INITIAL_COMMIT=0 -GIT_INIT=1 - -SKILL_NAME="bootstrap-uv-python-workspace" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -ORIGINAL_ARGS=("$@") - -while [[ "$#" -gt 0 ]]; do - case "$1" in - --config) - CONFIG_PATH="${2:-}" - [[ -n "$CONFIG_PATH" ]] || fail "--config requires a value" - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - --name|--path|--profile|--python) - [[ "$#" -ge 2 ]] || fail "$1 requires a value" - shift 2 - ;; - --force|--initial-commit|--no-git-init) - shift - ;; - *) - shift - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -set -- "${ORIGINAL_ARGS[@]}" -while [[ "$#" -gt 0 ]]; do - case "$1" in - --name) NAME="$2"; shift 2 ;; - --path) TARGET="$2"; shift 2 ;; - --profile) PROFILE="$2"; shift 2 ;; - --python) PYTHON_VERSION="$2"; shift 2 ;; - --force) FORCE=1; shift ;; - --initial-commit) INITIAL_COMMIT=1; shift ;; - --no-git-init) GIT_INIT=0; shift ;; - --config) shift 2 ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) shift ;; - -h|--help) usage; exit 0 ;; - *) fail "Unknown argument: $1" ;; - esac -done - -[[ -n "$NAME" ]] || fail "--name is required." -[[ "$PROFILE" == "package" || "$PROFILE" == "service" ]] || fail "--profile must be 'package' or 'service'." -[[ "$GIT_INIT" -eq 0 && "$INITIAL_COMMIT" -eq 1 ]] && fail "--initial-commit requires git initialization." - -if [[ -z "$TARGET" ]]; then - TARGET="./$NAME" -fi -TARGET="$(abs_path "$TARGET")" - -require_cmd uv -if [[ "$GIT_INIT" -eq 1 || "$INITIAL_COMMIT" -eq 1 ]]; then - require_cmd git -fi - -if [[ -e "$TARGET" ]]; then - if [[ -n "$(ls -A "$TARGET" 2>/dev/null || true)" && "$FORCE" -ne 1 ]]; then - fail "Target directory '$TARGET' is not empty. Re-run with --force if you want to scaffold into a populated path." - fi - if [[ -f "$TARGET/pyproject.toml" && "$FORCE" -eq 1 ]]; then - fail "Refusing to overwrite existing '$TARGET/pyproject.toml' even with --force." - fi -else - mkdir -p "$TARGET" -fi - -README_TEMPLATE="$SCRIPT_DIR/../assets/README.md.tmpl" -[[ -f "$README_TEMPLATE" ]] || fail "Missing README template at '$README_TEMPLATE'." - -if [[ "$PROFILE" == "package" ]]; then - uv init --package --lib --name "$NAME" --python "$PYTHON_VERSION" --vcs none "$TARGET" -else - uv init --app --name "$NAME" --python "$PYTHON_VERSION" --vcs none "$TARGET" -fi - -cd "$TARGET" -MODULE_NAME="$(normalize_module_name "$NAME")" - -write_env_files "$TARGET" "$NAME" - -if [[ "$PROFILE" == "service" ]]; then - uv add fastapi --extra standard - uv add pydantic-settings python-dotenv - mkdir -p app tests - touch app/__init__.py - write_service_settings "app" "$NAME" - - cat > app/main.py <<'PY' -from typing import Annotated - -from fastapi import Depends, FastAPI - -from app.config import Settings, get_settings - -app = FastAPI(title="Service API") - - -@app.get("/health") -def health(settings: Annotated[Settings, Depends(get_settings)]) -> dict[str, str]: - return { - "status": "ok", - "service": settings.name, - "environment": settings.environment, - } -PY - - cat > tests/test_service.py <<'PY' -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from app.config import get_settings -from app.main import app - - -def test_app_exists() -> None: - assert app is not None - - -def test_settings_load_defaults() -> None: - settings = get_settings() - assert settings.name - assert settings.environment == "development" -PY - - RUN_COMMANDS="uv run fastapi dev app/main.py" - NOTES="This profile ships a FastAPI app at app/main.py plus typed settings in app/config.py. Keep non-secret defaults in .env and local or secret overrides in .env.local." -else - uv add pydantic-settings python-dotenv - mkdir -p tests - write_package_settings "src/$MODULE_NAME" "$NAME" - - cat > tests/test_import.py <<PY -from ${MODULE_NAME} import __name__ as imported_name -from ${MODULE_NAME}.config import get_settings - - -def test_package_import() -> None: - assert imported_name == "${MODULE_NAME}" - - -def test_settings_load_defaults() -> None: - settings = get_settings() - assert settings.name == "${NAME}" - assert settings.environment == "development" -PY - - RUN_COMMANDS="uv run python -c \"from ${MODULE_NAME}.config import get_settings; print(get_settings().name)\"" - NOTES="This profile uses src layout and uv_build for packaging, and it now ships a minimal typed settings layer in src/${MODULE_NAME}/config.py backed by .env and .env.local." -fi - -uv add --group dev pytest ruff mypy -append_tooling_config "pyproject.toml" "$PYTHON_VERSION" - -render_readme \ - "$README_TEMPLATE" \ - "README.md" \ - "$NAME" \ - "$PROFILE project" \ - "$RUN_COMMANDS" \ - "uv run pytest; uv run ruff check .; uv run mypy ." \ - "$NOTES" - -uv lock -uv sync -uv run pytest -uv run ruff check . -uv run mypy . - -if [[ "$GIT_INIT" -eq 1 ]]; then - if [[ ! -d .git ]]; then - git init - fi - git add . - if [[ "$INITIAL_COMMIT" -eq 1 ]]; then - git commit -m "Initial scaffold from bootstrap-uv-python-workspace" - fi -fi - -echo "[OK] Project scaffold complete: $TARGET" diff --git a/plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh b/plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh deleted file mode 100755 index 67dc5020b..000000000 --- a/plugins/python-skills/skills/bootstrap-uv-python-workspace/scripts/init_uv_python_workspace.sh +++ /dev/null @@ -1,570 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -usage() { - cat <<'USAGE' -Bootstrap a uv Python workspace. - -Usage: - init_uv_python_workspace.sh --name <workspace-name> [options] - -Required: - --name <name> Workspace name - -Options: - --path <path> Target directory (default: ./<name>) - --members "a,b,c" Workspace member names (default: core-lib,api-service) - --profile-map "a=package,b=service" - Member profile assignments - --python <version> Python version (default: 3.13) - --config <path> Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - --force Allow non-empty target directory - --initial-commit Create initial git commit on success - --no-git-init Skip git init (default is enabled) - -h, --help Show help -USAGE -} - -fail() { - echo "[ERROR] $*" >&2 - exit 1 -} - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || fail "Missing required command '$1'. Install it and re-run the workspace scaffold." -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -bool_to_int() { - local value - value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" - case "$value" in - 1|true|yes|on) printf '1\n' ;; - 0|false|no|off) printf '0\n' ;; - *) fail "Invalid boolean value '$1' in customization data." ;; - esac -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - name) NAME="$value" ;; - path) TARGET="$value" ;; - members) MEMBERS_CSV="$value" ;; - profile_map) PROFILE_MAP="$value" ;; - python) PYTHON_VERSION="$value" ;; - force) FORCE="$(bool_to_int "$value")" ;; - initial_commit) INITIAL_COMMIT="$(bool_to_int "$value")" ;; - no_git_init) - if [[ "$(bool_to_int "$value")" -eq 1 ]]; then - GIT_INIT=0 - else - GIT_INIT=1 - fi - ;; - *) fail "Unknown config key '$key' in customization file." ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "Config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "Invalid config line at $path:$lineno. Expected 'key: value'." - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "Empty config key at $path:$lineno." - apply_config_value "$key" "$value" - done < "$path" -} - -abs_path() { - local input="$1" - if [[ -d "$input" ]]; then - (cd "$input" && pwd) - else - local parent="$(dirname "$input")" - local base="$(basename "$input")" - mkdir -p "$parent" - (cd "$parent" && printf '%s/%s\n' "$(pwd)" "$base") - fi -} - -normalize_module_name() { - local raw="${1//-/_}" - printf '%s' "$raw" | tr -c '[:alnum:]_' '_' -} - -target_from_python() { - local v="$1" - local major="$(printf '%s' "$v" | cut -d. -f1)" - local minor="$(printf '%s' "$v" | cut -d. -f2)" - printf 'py%s%s\n' "$major" "$minor" -} - -append_tooling_config() { - local pyproject="$1" - local py_version="$2" - - if grep -q '^\[tool\.ruff\]' "$pyproject"; then - return - fi - - cat >>"$pyproject" <<EOF_CFG - -[tool.ruff] -line-length = 100 -target-version = "$(target_from_python "$py_version")" - -[tool.ruff.lint] -select = ["E", "F", "UP", "B"] - -[tool.pytest.ini_options] -addopts = "-q" -testpaths = ["tests"] - -[tool.mypy] -python_version = "$py_version" -warn_unused_configs = true -check_untyped_defs = true -no_implicit_optional = true -EOF_CFG -} - -ensure_gitignore_entry() { - local file_path="$1" - local entry="$2" - - touch "$file_path" - if ! grep -Fqx "$entry" "$file_path"; then - printf '%s\n' "$entry" >>"$file_path" - fi -} - -write_env_files() { - local member_root="$1" - local app_name="$2" - - cat >"$member_root/.env" <<EOF_ENV -APP_NAME="$app_name" -APP_ENVIRONMENT="development" -EOF_ENV - - cat >"$member_root/.env.local" <<'EOF_ENV_LOCAL' -# Local overrides for developer-specific or secret values. -# This file is ignored by git on purpose. -EOF_ENV_LOCAL - - ensure_gitignore_entry "$member_root/.gitignore" ".env.local" -} - -write_package_settings() { - local module_dir="$1" - local member_name="$2" - - cat >"$module_dir/config.py" <<EOF_CFG -from functools import lru_cache - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - name: str = "$member_name" - environment: str = "development" - - model_config = SettingsConfigDict( - env_prefix="APP_", - env_file=(".env", ".env.local"), - env_file_encoding="utf-8", - ) - - -@lru_cache -def get_settings() -> Settings: - return Settings() -EOF_CFG -} - -write_service_settings() { - local app_dir="$1" - local member_name="$2" - - cat >"$app_dir/config.py" <<EOF_CFG -from functools import lru_cache - -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - name: str = "$member_name" - environment: str = "development" - - model_config = SettingsConfigDict( - env_prefix="APP_", - env_file=(".env", ".env.local"), - env_file_encoding="utf-8", - ) - - -@lru_cache -def get_settings() -> Settings: - return Settings() -EOF_CFG -} - -render_readme() { - local template="$1" - local out="$2" - local name="$3" - local run_cmds="$4" - local test_cmds="$5" - local notes="$6" - - sed \ - -e "s|__NAME__|$name|g" \ - -e "s|__TYPE__|workspace|g" \ - -e "s|__RUN_COMMANDS__|$run_cmds|g" \ - -e "s|__TEST_COMMANDS__|$test_cmds|g" \ - -e "s|__NOTES__|$notes|g" \ - "$template" >"$out" -} - -profile_for_member() { - local member="$1" - local default_profile="$2" - local map="$3" - - if [[ -z "$map" ]]; then - printf '%s\n' "$default_profile" - return - fi - - local old_ifs="$IFS" - IFS=',' - for entry in ${(s:,:)map}; do - local key="${entry%%=*}" - local value="${entry#*=}" - if [[ "$key" == "$member" ]]; then - IFS="$old_ifs" - printf '%s\n' "$value" - return - fi - done - IFS="$old_ifs" - - printf '%s\n' "$default_profile" -} - -NAME="" -TARGET="" -MEMBERS_CSV="core-lib,api-service" -PROFILE_MAP="" -PYTHON_VERSION="3.13" -FORCE=0 -INITIAL_COMMIT=0 -GIT_INIT=1 - -SKILL_NAME="bootstrap-uv-python-workspace" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -ORIGINAL_ARGS=("$@") - -while [[ "$#" -gt 0 ]]; do - case "$1" in - --config) - CONFIG_PATH="${2:-}" - [[ -n "$CONFIG_PATH" ]] || fail "--config requires a value" - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - --name|--path|--members|--profile-map|--python) - [[ "$#" -ge 2 ]] || fail "$1 requires a value" - shift 2 - ;; - --force|--initial-commit|--no-git-init) - shift - ;; - *) - shift - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -set -- "${ORIGINAL_ARGS[@]}" -while [[ "$#" -gt 0 ]]; do - case "$1" in - --name) NAME="$2"; shift 2 ;; - --path) TARGET="$2"; shift 2 ;; - --members) MEMBERS_CSV="$2"; shift 2 ;; - --profile-map) PROFILE_MAP="$2"; shift 2 ;; - --python) PYTHON_VERSION="$2"; shift 2 ;; - --force) FORCE=1; shift ;; - --initial-commit) INITIAL_COMMIT=1; shift ;; - --no-git-init) GIT_INIT=0; shift ;; - --config) shift 2 ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) shift ;; - -h|--help) usage; exit 0 ;; - *) fail "Unknown argument: $1" ;; - esac -done - -[[ -n "$NAME" ]] || fail "--name is required." -[[ "$GIT_INIT" -eq 0 && "$INITIAL_COMMIT" -eq 1 ]] && fail "--initial-commit requires git initialization." - -if [[ -z "$TARGET" ]]; then - TARGET="./$NAME" -fi -TARGET="$(abs_path "$TARGET")" - -require_cmd uv -if [[ "$GIT_INIT" -eq 1 || "$INITIAL_COMMIT" -eq 1 ]]; then - require_cmd git -fi - -if [[ -e "$TARGET" ]]; then - if [[ -n "$(ls -A "$TARGET" 2>/dev/null || true)" && "$FORCE" -ne 1 ]]; then - fail "Target directory '$TARGET' is not empty. Re-run with --force if you want to scaffold into a populated path." - fi - if [[ -f "$TARGET/pyproject.toml" && "$FORCE" -eq 1 ]]; then - fail "Refusing to overwrite existing '$TARGET/pyproject.toml' even with --force." - fi -else - mkdir -p "$TARGET" -fi - -README_TEMPLATE="$SCRIPT_DIR/../assets/README.md.tmpl" -[[ -f "$README_TEMPLATE" ]] || fail "Missing README template at '$README_TEMPLATE'." - -mkdir -p "$TARGET" -cd "$TARGET" - -cat > pyproject.toml <<'EOF_WORKSPACE' -[tool.uv.workspace] -members = ["packages/*"] -EOF_WORKSPACE - -ensure_gitignore_entry ".gitignore" ".venv" - -typeset -a MEMBERS=() -typeset -a PACKAGE_MEMBERS=() -typeset -a SERVICE_MEMBERS=() - -old_ifs="$IFS" -IFS=',' -for raw in ${(s:,:)MEMBERS_CSV}; do - member="$(trim "$raw")" - [[ -n "$member" ]] || continue - MEMBERS+=("$member") -done -IFS="$old_ifs" - -[[ "${#MEMBERS[@]}" -gt 0 ]] || fail "No valid workspace members were provided." - -mkdir -p packages - -idx=1 -for member in "${MEMBERS[@]}"; do - default_profile="service" - if [[ "$idx" -eq 1 ]]; then - default_profile="package" - fi - - profile="$(profile_for_member "$member" "$default_profile" "$PROFILE_MAP")" - [[ "$profile" == "package" || "$profile" == "service" ]] || fail "Invalid profile '$profile' for member '$member'." - - member_path="packages/$member" - module_name="$(normalize_module_name "$member")" - if [[ "$profile" == "package" ]]; then - uv init --package --lib --name "$member" --python "$PYTHON_VERSION" --vcs none "$member_path" - PACKAGE_MEMBERS+=("$member") - else - uv init --app --name "$member" --python "$PYTHON_VERSION" --vcs none "$member_path" - SERVICE_MEMBERS+=("$member") - fi - - uv add --package "$member" --group dev pytest ruff mypy - uv add --package "$member" pydantic-settings python-dotenv - append_tooling_config "$member_path/pyproject.toml" "$PYTHON_VERSION" - write_env_files "$member_path" "$member" - - if [[ "$profile" == "service" ]]; then - uv add --package "$member" fastapi --extra standard - mkdir -p "$member_path/app" "$member_path/tests" - touch "$member_path/app/__init__.py" - write_service_settings "$member_path/app" "$member" - - cat > "$member_path/app/main.py" <<'PY' -from typing import Annotated - -from fastapi import Depends, FastAPI - -from app.config import Settings, get_settings - -app = FastAPI(title="Workspace Service") - - -@app.get("/health") -def health(settings: Annotated[Settings, Depends(get_settings)]) -> dict[str, str]: - return { - "status": "ok", - "service": settings.name, - "environment": settings.environment, - } -PY - - cat > "$member_path/tests/test_${module_name}_service.py" <<'PY' -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from app.config import get_settings -from app.main import app - - -def test_app_exists() -> None: - assert app is not None - - -def test_settings_load_defaults() -> None: - settings = get_settings() - assert settings.name - assert settings.environment == "development" -PY - else - mkdir -p "$member_path/tests" - write_package_settings "$member_path/src/$module_name" "$member" - - cat > "$member_path/tests/test_${module_name}_import.py" <<PY -from ${module_name} import __name__ as imported_name -from ${module_name}.config import get_settings - - -def test_package_import() -> None: - assert imported_name == "${module_name}" - - -def test_settings_load_defaults() -> None: - settings = get_settings() - assert settings.name == "${member}" - assert settings.environment == "development" -PY - fi - idx=$((idx + 1)) -done - -if [[ "${#PACKAGE_MEMBERS[@]}" -gt 0 && "${#SERVICE_MEMBERS[@]}" -gt 0 ]]; then - shared_pkg="${PACKAGE_MEMBERS[1]}" - for svc in "${SERVICE_MEMBERS[@]}"; do - uv add --package "$svc" "$shared_pkg" - done -fi - -uv lock -uv sync --all-packages -uv run --all-packages pytest - -for member in "${MEMBERS[@]}"; do - ( - cd "packages/$member" - uv run ruff check . - uv run mypy . - ) -done - -render_readme \ - "$README_TEMPLATE" \ - "README.md" \ - "$NAME" \ - "uv run --all-packages pytest" \ - "uv run --all-packages pytest; (cd packages/<member> && uv run ruff check . && uv run mypy .)" \ - "Members are created under packages/. Every member ships a committed .env, an ignored .env.local, and typed settings via pydantic-settings. If both package and service profiles exist, services depend on the first package member via workspace sources." - -if [[ "$GIT_INIT" -eq 1 ]]; then - if [[ ! -d .git ]]; then - git init - fi - git add . - if [[ "$INITIAL_COMMIT" -eq 1 ]]; then - git commit -m "Initial workspace scaffold from bootstrap-uv-python-workspace" - fi -fi - -echo "[OK] Workspace scaffold complete: $TARGET" diff --git a/plugins/python-skills/skills/build-python-agent-service/SKILL.md b/plugins/python-skills/skills/build-python-agent-service/SKILL.md deleted file mode 100644 index 5f8b575ee..000000000 --- a/plugins/python-skills/skills/build-python-agent-service/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: build-python-agent-service -description: Build a local-first Python agent service with typed tools, exact model capability checks, evaluation fixtures, and safe promotion gates. Use for OpenAI Agents SDK, LangGraph, LlamaIndex, Pydantic AI, Google ADK Python, AutoGen, or CrewAI. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients building uv-managed Python agent services on macOS with local or remote model endpoints, typed tool contracts, and explicit validation. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-agent-service -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# Build Python Agent Service - -## Purpose - -Build one bounded Python agent application without treating the model server, -agent framework, tool executor, and durable state as one inseparable stack. -Start from the smallest useful read-only agent and promote only after the exact -model, tools, evaluation fixtures, and side-effect boundary have been proved. - -## When To Use - -- Use for a new or existing uv-managed Python agent service. -- Use when the framework is OpenAI Agents SDK, LangGraph, LlamaIndex, Pydantic - AI, Google ADK Python, AutoGen, or CrewAI. -- Use after `design-agent-automation-workflow` has established that a - code-owned Python agent service is the right surface. -- Do not use for a visual integration workflow; hand off n8n work to the - owning integration project after the planning skill selects it. -- Do not use for model benchmarking itself; hand off local model capability and - tool-loop measurement to `model-lab-skills:evaluate-tool-calling-model`. - -## Source Check - -Before selecting or updating a framework, inspect the repository and use -official current documentation for the exact framework and model adapter: - -- OpenAI Agents SDK: <https://developers.openai.com/api/docs/guides/agents> -- LangGraph: <https://docs.langchain.com/oss/python/langgraph/overview> -- LangChain Ollama: <https://docs.langchain.com/oss/python/integrations/chat/ollama/> -- LlamaIndex agents: <https://docs.llamaindex.ai/en/latest/understanding/agent/structured_output/> -- Pydantic AI: <https://pydantic.dev/docs/ai/overview/> -- Pydantic AI Ollama: <https://pydantic.dev/docs/ai/models/ollama/> -- Google ADK: <https://adk.dev/> -- AutoGen models: <https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/models.html> -- CrewAI: <https://docs.crewai.com/> -- uv: <https://docs.astral.sh/uv/> - -State which source changed the implementation decision. Do not rely on a -framework's claimed OpenAI-compatible endpoint as proof that a local model -supports tool calls or structured output correctly. - -## Implementation Sequence - -1. Inspect the current project shape, its `pyproject.toml`, existing model - client, tools, state store, tests, and deployment configuration. Prefer an - existing service/package boundary over adding a second agent host. -2. Write the non-agent baseline: trigger, inputs, expected typed output, - deterministic alternative, no-op behavior, and the reason planning/tool use - is necessary. -3. Select one framework for the real workflow: - - OpenAI Agents SDK for an application-owned agent loop with tools, - handoffs, guardrails, and traces. - - LangGraph when persisted transitions, pause/resume, retries, or explicit - routing are first-class behavior. - - LlamaIndex when ingestion, retrieval, citations, and RAG quality are the - core product problem. - - Pydantic AI for a compact typed Python service with validated tool and - result models. - - Google ADK Python when Google/Gemini, A2A/MCP, or ADK's runtime model is a - concrete product requirement. - - AutoGen or CrewAI only when a measured multi-agent design beats a - single-agent baseline on the same fixed task set. -4. Declare model endpoint, exact model name and revision/tag, authentication, - requested capabilities, context/latency limits, and model lifecycle. Keep - local server configuration out of committed secrets and machine-local paths. -5. Run a capability gate against the exact endpoint and model before attaching - write-capable tools: valid tool-call JSON, schema-conforming structured - output, no-call behavior, malformed-call recovery, maximum-step stop, and a - read-only task set resembling the intended application. -6. Implement one agent with typed input/output and narrow read-only tools. - Tool functions must validate their own authorization, inputs, timeout, and - result shape; model output cannot grant a capability. -7. Add durable state only when the user-visible workflow needs a restart-safe - session, checkpoint, task queue, or approval resume point. Name the store, - retention, migration, replay, and recovery contract. -8. Add the smallest test set: fake-tool unit cases, structured-output cases, - model-adapter integration smoke tests, denied-write cases, and regression - fixtures. Run live write tests only in an explicit disposable or draft mode. -9. Promote from report/draft to external writes only through - `auto-with-escalation`: name the exact recipient, target, action, evidence, - rollback/no-op behavior, and human approval point. - -## Framework Boundaries - -Do not add a framework wrapper merely to make framework names interchangeable. -Keep application domain behavior independent from the selected framework where -that boundary has a real caller: typed domain input/output, tool interfaces, -and persistence adapter. Let framework-specific orchestration stay at the -application edge. - -Do not introduce LangGraph persistence, vector retrieval, multi-agent teams, -or a background queue unless the selected workflow requires its concrete -behavior. A single request/response tool loop should remain a small service or -CLI. - -## Validation - -At minimum, run the repository's configured quality checks. In a standard uv -project that means: - -```bash -uv sync --dev -uv run pytest -uv run ruff check . -uv run mypy . -``` - -Report separately: - -1. fake-tool contract results; -2. exact local/remote model capability-gate results; -3. structured result validity; -4. attempted versus executed side effects; -5. state/resume behavior, when state exists; -6. the exact approval or no-op result for write-capable tools. - -## Output Shape - -Return: - -1. `Framework`: selected framework and the concrete requirement it serves. -2. `Model contract`: server, exact model, capabilities proven, and limitations. -3. `Tool boundary`: tool schemas, permissions, and denied-action behavior. -4. `State`: absent or explicit persistence/recovery contract. -5. `Evaluation`: fixture, fake-tool, and live-integration evidence. -6. `Promotion gate`: exact condition for an external write. -7. `Validation`: commands run and results. - -## Guardrails - -- Do not install several frameworks for a comparison unless the experiment is - explicitly requested and has one fixed evaluation set. -- Do not run an unattended local background service, scheduler, or external - write workflow without an explicit user request and a recovery plan. -- Do not store model API keys, local endpoint credentials, or private prompt - data in source control, fixtures, traces, or error output. -- Do not call a local model private merely because it runs on macOS; document - every connected tool, remote endpoint, trace sink, and data store. -- Do not claim a model supports tools, structured output, or a context size - until the exact server/model combination passes the capability gate. diff --git a/plugins/python-skills/skills/build-python-agent-service/agents/openai.yaml b/plugins/python-skills/skills/build-python-agent-service/agents/openai.yaml deleted file mode 100644 index 720eea0b7..000000000 --- a/plugins/python-skills/skills/build-python-agent-service/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Build Python Agent Service" - short_description: "Build a tested local-first Python agent service." - brand_color: "#4F46E5" - default_prompt: "Use $build-python-agent-service to select one Python agent framework for this bounded workflow, separate its inference server and exact model from its tool/state boundaries, prove read-only tool calling and structured output first, and name the exact approval gate before any external write." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/build-python-project/SKILL.md b/plugins/python-skills/skills/build-python-project/SKILL.md deleted file mode 100644 index ef7dbf444..000000000 --- a/plugins/python-skills/skills/build-python-project/SKILL.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -name: build-python-project -description: Build or modify idiomatic Python projects using uv, explicit package layout, typed configuration, focused tests, Ruff, mypy, and repo-local validation without overriding established conventions. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients implementing Python changes in uv-managed projects, packages, CLIs, FastAPI services, FastMCP servers, and workspaces. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-implementation -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# Build Python Project - -## Purpose - -Implement or modify a Python project in the repository's own shape. - -The practical goal is clear package boundaries, readable data flow, typed configuration where the repo uses it, tests around changed behavior, and validation through the repo's `uv` commands. - -## When To Use - -- Use this skill when adding or changing Python source in an existing project. -- Use this skill when the project shape is already known or was chosen with `choose-python-project-shape`. -- Use this skill when implementation touches reusable package code, CLI code, FastAPI adapters, FastMCP adapters, or shared domain logic. -- Use a more specific workflow when the task is only test setup, packaging, diagnostics, tooling, or FastAPI/FastMCP integration. - -## Source Check - -Use repo-local Python files, checked-out dependency sources, Dash MCP or Dash HTTP for installed Python docsets, and then official project documentation when Dash/local coverage is missing or stale when implementation depends on package, tool, or framework behavior: - -- [uv documentation](https://docs.astral.sh/uv/) -- [Python packaging user guide](https://packaging.python.org/) -- [FastAPI documentation](https://fastapi.tiangolo.com/) -- [FastMCP documentation](https://gofastmcp.com/getting-started/welcome) -- [pytest documentation](https://docs.pytest.org/en/stable/) -- [Ruff documentation](https://docs.astral.sh/ruff/) -- [mypy documentation](https://mypy.readthedocs.io/en/stable/) - -## Implementation Workflow - -1. Inspect project shape: - ```bash - rg --files -g 'pyproject.toml' -g 'uv.lock' -g '*.py' -g 'tests/**/*.py' -g '.python-version' - ``` -2. Read the relevant `pyproject.toml` sections: - - `[project]` - - `[tool.uv]` - - `[tool.uv.workspace]` - - `[dependency-groups]` - - `[tool.pytest.ini_options]` - - `[tool.ruff]` - - `[tool.mypy]` -3. Identify the behavior being changed and who calls it next. -4. Keep reusable logic separate from framework adapters when the behavior is not inherently tied to FastAPI, FastMCP, or a CLI parser. -5. Prefer explicit inputs and outputs for transformations. -6. Keep environment reads, network calls, file IO, process exits, and framework globals at the edge of the workflow. -7. Add or update tests around the changed behavior. -8. Run the narrowest useful validation first, then broaden before a checkpoint when risk warrants it. - -## Package And Module Shape - -Respect the existing layout first. - -For `src/` layouts: - -- import through the installed package name -- avoid test-only import hacks -- keep package-private modules clearly internal by name or documentation - -For flat layouts: - -- avoid introducing a second package root casually -- use local conventions for imports and tests -- consider a package layout only when packaging or import correctness is already part of the task - -For workspaces: - -- use `uv run --package <name>` when commands need a specific member -- keep local package dependencies expressed through workspace sources -- avoid copying shared code between members - -## Configuration - -When generated or existing projects use `pydantic-settings`, keep committed `.env` files limited to safe defaults and keep `.env.local` or real secret stores for machine-local and secret values. - -For tests, override environment variables or settings dependencies rather than mutating committed `.env` files. - -## Validation - -Choose commands based on the changed surface: - -```bash -uv run pytest -uv run ruff check . -uv run mypy . -``` - -Use package targeting for workspaces: - -```bash -uv run --package <package-name> pytest -uv run --package <package-name> mypy . -``` - -Run `uv sync --dev` first when dependency resolution, lockfiles, or Python versions changed. - -## Output Shape - -Return: - -1. `Changed behavior`: what user-visible or package-visible behavior changed. -2. `Files`: key files changed. -3. `Tests`: tests added or updated. -4. `Validation`: exact commands run and results. -5. `Residual risk`: anything not covered. - -## Guardrails - -- Do not add a new framework, queue, service object, repository layer, or dependency without naming the concrete need it solves. -- Do not mix broad formatting sweeps into behavior changes. -- Do not hide import errors with `sys.path` edits unless the repo already uses that pattern and the reason is documented. -- Do not add machine-local paths to `pyproject.toml`, lockfiles, CI, or docs. -- Do not silently skip tests when the changed behavior is testable. diff --git a/plugins/python-skills/skills/build-python-project/agents/openai.yaml b/plugins/python-skills/skills/build-python-project/agents/openai.yaml deleted file mode 100644 index 528d1462b..000000000 --- a/plugins/python-skills/skills/build-python-project/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Build Python Project" - short_description: "Implement Python changes with uv, tests, package layout, Ruff, and mypy." - brand_color: "#0F766E" - default_prompt: "Use $build-python-project to inspect the existing Python project shape, implement the requested behavior in the repo's own style, add focused tests, and validate with the narrowest useful uv commands." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/choose-python-project-shape/SKILL.md b/plugins/python-skills/skills/choose-python-project-shape/SKILL.md deleted file mode 100644 index e0181d085..000000000 --- a/plugins/python-skills/skills/choose-python-project-shape/SKILL.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -name: choose-python-project-shape -description: Choose the right Python project shape before implementation, including uv project versus workspace layout, package, CLI, FastAPI, FastMCP, testing, packaging, tooling, and validation boundaries. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients working with Python, uv-managed projects, FastAPI, FastMCP, pytest, Ruff, mypy, and Python packaging workflows. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-planning -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# Choose Python Project Shape - -## Purpose - -Pick the smallest correct Python project shape before code changes begin. - -The practical decision is what kind of Python surface the user needs, whether it should be a single `uv` project or workspace, which package or service owns the behavior, which validation commands should prove the work, and where package, API, MCP, test, or tooling boundaries should sit. - -## When To Use - -- Use this skill when the user wants a new Python project but has not chosen package, app, service, MCP server, workspace, or tooling shape. -- Use this skill before scaffolding with `bootstrap-uv-python-workspace`, `bootstrap-python-service`, or `bootstrap-python-mcp-service`. -- Use this skill when an existing repository has Python files and the next change could cross package, service, test, or tooling boundaries. -- Use this skill when the user asks whether Python, FastAPI, FastMCP, a CLI, or a package is the right shape for the work. - -## Source Check - -Use repo-local Python files, checked-out dependency sources, Dash MCP or Dash HTTP for installed Python docsets, and then official project documentation when Dash/local coverage is missing or stale. Check one of those source-specific paths before making claims about Python packaging, `uv`, FastAPI, FastMCP, tests, linting, or typing: - -- [uv documentation](https://docs.astral.sh/uv/) -- [Python packaging user guide](https://packaging.python.org/) -- [Writing `pyproject.toml`](https://packaging.python.org/guides/writing-pyproject-toml/) -- [FastAPI documentation](https://fastapi.tiangolo.com/) -- [FastMCP documentation](https://gofastmcp.com/getting-started/welcome) -- [pytest documentation](https://docs.pytest.org/en/stable/) -- [Ruff documentation](https://docs.astral.sh/ruff/) -- [mypy documentation](https://mypy.readthedocs.io/en/stable/) - -Translate any documentation rule into the concrete repository decision it changes. - -## Classification Workflow - -1. Inspect the repository shape: - ```bash - rg --files -g 'pyproject.toml' -g 'uv.lock' -g 'requirements*.txt' -g 'setup.py' -g 'setup.cfg' -g 'tox.ini' -g 'noxfile.py' -g '.python-version' -g '.github/workflows/*.yml' -g '.github/workflows/*.yaml' - ``` -2. Identify the user-visible job: - - reusable package - - command-line app - - FastAPI service - - FastMCP server - - combined FastAPI and FastMCP app - - local-first Python agent service - - test or tooling setup - - package maintenance - - CI maintenance - - dependency or Python-version upgrade - - Python member inside a mixed-language repository -3. Choose the project layout: - - single `uv` project for one small package, CLI, service, or MCP server - - `uv` workspace when multiple packages or services need shared local dependencies - - package plus tests when the repo exposes reusable logic - - service plus shared package when API or MCP adapters should stay thin around reusable behavior - - tooling-only change when the code shape already fits -4. Choose the validation path: - - `uv sync --dev` when dependency resolution matters - - `uv run pytest` for behavior - - `uv run ruff check .` for lint - - `uv run ruff format --check .` only when formatting is enforced - - `uv run mypy .` when type checking is configured - - package build checks only when package metadata or release surfaces changed -5. Choose the next skill: - - scaffold: `bootstrap-uv-python-workspace`, `bootstrap-python-service`, or `bootstrap-python-mcp-service` - - implementation: `build-python-project` - - test work: `python-testing-workflow` - - FastAPI/FastMCP integration: `integrate-fastapi-fastmcp` - - local-first agent service: `build-python-agent-service` - - diagnosis: `diagnose-python-project` - - package validation: `python-package-workflow` - - tooling alignment: `python-tooling-style-workflow` - -## Recommendations - -Prefer `uv` as the command and dependency surface. - -Prefer `pyproject.toml` as the project metadata and tool configuration home unless the repository already uses dedicated config files for a clear reason. - -Use a `src/` layout when creating reusable packages or packages that need import behavior to match installed use. Preserve an existing flat layout unless the requested work already requires a package-structure cleanup. - -Keep FastAPI and FastMCP adapter code thin around shared logic when the behavior should be reusable outside the web or MCP transport. - -Use a workspace only when multiple packages or services need a real local package relationship. Do not add a workspace for a single small project that can stay simpler. - -## Output Shape - -Return: - -1. `Shape`: selected package, CLI, service, MCP server, workspace, tooling, package, CI, or upgrade shape. -2. `Why`: concrete user-visible reason. -3. `Layout`: expected files or package members. -4. `Next skill`: the skill that should handle implementation. -5. `Validation`: exact `uv` commands to prove the next change. -6. `Docs`: docs or repo guidance that should change. - -## Guardrails - -- Do not scaffold before the project shape is clear. -- Do not create a workspace just to look organized. -- Do not introduce machine-local dependency paths into shared project files. -- Do not publish packages, open releases, or change CI secrets from this planning skill. -- Do not replace established repo conventions unless the current shape blocks the requested work. diff --git a/plugins/python-skills/skills/choose-python-project-shape/agents/openai.yaml b/plugins/python-skills/skills/choose-python-project-shape/agents/openai.yaml deleted file mode 100644 index 023920f42..000000000 --- a/plugins/python-skills/skills/choose-python-project-shape/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Choose Python Project Shape" - short_description: "Choose Python package, service, MCP, workspace, test, tooling, CI, or upgrade shape." - brand_color: "#2563EB" - default_prompt: "Use $choose-python-project-shape to inspect this repository, choose the smallest correct Python project shape, recommend the next Python skill, and return exact uv validation commands before implementation starts." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/fastapi-service-workflow/SKILL.md b/plugins/python-skills/skills/fastapi-service-workflow/SKILL.md deleted file mode 100644 index 1210141da..000000000 --- a/plugins/python-skills/skills/fastapi-service-workflow/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: fastapi-service-workflow -description: Maintain existing uv-managed FastAPI services, including route and dependency composition, typed settings, lifespan, async and integration testing, OpenAPI review, deployment-readiness handoff, and service-specific diagnostics. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients maintaining FastAPI services on macOS with uv, typed configuration, async Python, and the repository's existing deployment tools. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-fastapi -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# FastAPI Service Workflow - -## Purpose - -Maintain an existing FastAPI service without turning routing, application -lifecycle, domain logic, deployment, and MCP integration into one layer. Keep -HTTP adapters thin around typed domain behavior and make startup, shutdown, -configuration, and public API changes explicit. - -## Workflow - -1. Inspect `pyproject.toml`, app entrypoint, routers, dependencies, settings, - lifespan, tests, OpenAPI output, CI, and deployment configuration. -2. Classify the requested change as route composition, request/response model, - dependency, settings, lifecycle, async boundary, public OpenAPI contract, or - deployment-readiness work. -3. Keep route handlers focused on HTTP translation. Put reusable behavior in - domain modules or existing service boundaries rather than duplicating it - across routes, CLI commands, or MCP tools. -4. Keep settings typed and injectable. Store safe defaults separately from - machine-local or deployment secrets; use dependency overrides in tests. -5. Use one lifespan contract for resources such as pools, clients, queues, and - background workers. Combine lifespans deliberately when mounting another - ASGI application instead of silently replacing startup or shutdown work. -6. Review the OpenAPI effect of public routes, models, status codes, operation - IDs, security requirements, and deprecations. Treat incompatible changes as - an API compatibility decision. -7. Run focused HTTP and async tests, then the repository's configured checks: - ```bash - uv run pytest - uv run ruff check . - uv run mypy . - ``` -8. Report deployment readiness separately: configuration source, migrations, - health endpoint, logs, timeouts, workers, and external dependencies. Do not - deploy unless the user asks for that operation. - -## Testing And Diagnostics - -Use dependency overrides for paid, privileged, or nondeterministic services and -clear them after each test. Use an async client for async behavior, and make -lifespan execution explicit when tests depend on startup resources. - -Diagnose service failures in this order: import or app factory, settings, -lifespan, route/dependency resolution, response validation, async boundary, -then external integration. Hand generic lockfile, package, CI, or tool failures -to their existing Python workflows. - -## Handoffs - -- New service scaffolding: `bootstrap-python-service`. -- Generic implementation and package structure: `build-python-project`. -- FastAPI plus FastMCP in one codebase: `integrate-fastapi-fastmcp`. -- MCP service maintenance: `fastmcp-service-workflow`. -- Package, CI, testing, tooling, and upgrade work: their corresponding Python - workflows. - -## Output Shape - -Return the service boundary changed, HTTP/OpenAPI impact, settings and -lifespan effect, tests and commands run, deployment-readiness evidence, and -residual risk. - -## Guardrails - -- Do not add a repository, manager, or service wrapper when a route can call an - existing typed domain boundary directly. -- Do not run a service, migration, external write, or deployment merely to - validate static guidance without user approval. -- Do not change public OpenAPI behavior silently. - -## References - -- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/) -- [FastAPI settings](https://fastapi.tiangolo.com/advanced/settings/) -- [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) -- [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/) diff --git a/plugins/python-skills/skills/fastapi-service-workflow/agents/openai.yaml b/plugins/python-skills/skills/fastapi-service-workflow/agents/openai.yaml deleted file mode 100644 index 7abdbf506..000000000 --- a/plugins/python-skills/skills/fastapi-service-workflow/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "FastAPI Service Workflow" - short_description: "Maintain FastAPI routes, settings, lifespan, OpenAPI, and service tests." - brand_color: "#0F766E" - default_prompt: "Use $fastapi-service-workflow to inspect this existing FastAPI service, preserve its typed settings and lifespan contract, implement the requested route or dependency change, review the OpenAPI impact, and run focused uv validation." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/fastmcp-service-workflow/SKILL.md b/plugins/python-skills/skills/fastmcp-service-workflow/SKILL.md deleted file mode 100644 index 7b6e91416..000000000 --- a/plugins/python-skills/skills/fastmcp-service-workflow/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: fastmcp-service-workflow -description: Maintain existing uv-managed FastMCP servers, including transport and lifespan behavior, tool/resource/prompt curation, authorization and input boundaries, client integration tests, generated-surface review, and upgrade diagnostics. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients maintaining FastMCP servers on macOS with uv, the installed FastMCP version, and the repository's existing transport and deployment tools. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-fastmcp -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# FastMCP Service Workflow - -## Purpose - -Maintain a curated FastMCP server as an application surface, not a mechanical -mirror of HTTP routes. Keep tool, resource, and prompt design user-oriented; -make transport, authentication, authorization, lifespan, and side-effect -boundaries explicit. - -## Workflow - -1. Inspect the installed FastMCP version, `pyproject.toml`, server entrypoint, - component definitions, transport, lifespan, auth configuration, tests, and - deployment configuration before changing behavior. -2. Classify each public capability: - - a tool for an action or bounded computation; - - a resource or template for read-oriented data; - - a prompt for a reusable message workflow. -3. Keep implementation logic in existing typed domain boundaries. Do not expose - transport-centric route names, broad autogenerated APIs, secrets, or raw - infrastructure controls as MCP capabilities. -4. Choose transport deliberately. STDIO clients own the server process and its - environment; HTTP is the normal production transport. Make every required - configuration value explicit for the selected transport. -5. Define authorization at the component or server boundary when the HTTP - deployment needs it. Do not infer identity or permissions from a model's - request, and do not claim HTTP OAuth checks protect STDIO transport. -6. Treat `FastMCP.from_fastapi(...)` and OpenAPI imports as review inputs. - Curate names, parameter shapes, errors, and capability boundaries before - keeping generated output as a public server surface. -7. Test through an in-memory FastMCP client first, then add transport and auth - integration tests only where those are part of the deployment contract. -8. Run the repository's configured checks and report any untested transport, - authorization, or external-write boundary separately. - -## Version And Documentation Discipline - -Use the installed FastMCP version and its release notes for implementation -decisions. The public FastMCP documentation tracks `main` and can describe -unreleased behavior. Use a host-provided `fastmcp_docs` tool only when one is -already configured; this plugin does not package it. - -## Handoffs - -- New MCP scaffold: `bootstrap-python-mcp-service`. -- FastAPI/FastMCP coexistence or mounting: `integrate-fastapi-fastmcp`. -- FastAPI service maintenance: `fastapi-service-workflow`. -- Generic testing, package, CI, tooling, and upgrade work: the corresponding - Python workflows. - -## Output Shape - -Return the component and transport boundary changed, installed FastMCP version, -authorization effect, test commands and results, deployment-readiness effect, -and residual risk. - -## Guardrails - -- Do not add a generated route mirror as a long-term MCP API without curation. -- Do not expose write-capable tools without explicit authorization, input, - timeout, idempotency, and approval behavior. -- Do not run a production transport, deploy a server, or execute live writes - only to validate the skill. - -## References - -- [FastMCP client testing](https://gofastmcp.com/servers/testing) -- [FastMCP transports](https://gofastmcp.com/clients/transports) -- [FastMCP authorization](https://gofastmcp.com/servers/authorization) -- [FastMCP CLI](https://gofastmcp.com/cli/overview) diff --git a/plugins/python-skills/skills/fastmcp-service-workflow/agents/openai.yaml b/plugins/python-skills/skills/fastmcp-service-workflow/agents/openai.yaml deleted file mode 100644 index da01051c8..000000000 --- a/plugins/python-skills/skills/fastmcp-service-workflow/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "FastMCP Service Workflow" - short_description: "Maintain FastMCP components, transports, authorization, and client tests." - brand_color: "#1D4ED8" - default_prompt: "Use $fastmcp-service-workflow to inspect this existing FastMCP server and installed version, curate the requested tool, resource, or prompt change, preserve transport and authorization boundaries, and validate it with a focused uv client test." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/integrate-fastapi-fastmcp/SKILL.md b/plugins/python-skills/skills/integrate-fastapi-fastmcp/SKILL.md deleted file mode 100644 index 83eafe0ba..000000000 --- a/plugins/python-skills/skills/integrate-fastapi-fastmcp/SKILL.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -name: integrate-fastapi-fastmcp -description: Integrate FastAPI and FastMCP applications in existing or evolving uv-managed Python projects. Use when adding a FastMCP server to an existing FastAPI app, folding an existing FastMCP server into a FastAPI project, serving both REST and MCP interfaces from one codebase, or graduating an auto-generated FastAPI-to-FastMCP server into a curated FastMCP application. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients on macOS with uv-managed Python projects, FastAPI and FastMCP application code, and shell access for uv commands. Use a host-provided FastMCP documentation tool when available; otherwise use the official FastMCP documentation. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-integration -allowed-tools: Bash(uv:*) Read ---- - -# Integrate FastAPI and FastMCP - -## Purpose - -Guide integration and restructuring work when a project needs both a conventional FastAPI surface and an MCP surface, without treating FastAPI-to-FastMCP auto-conversion as the final architecture by default. - -## When To Use - -- Use this skill when an existing FastAPI app needs a mounted or generated FastMCP server. -- Use this skill when an existing FastMCP project needs to live inside a FastAPI application or alongside one in the same `uv` project or workspace. -- Use this skill when a FastAPI-derived FastMCP server needs to be promoted from prototype output into a maintained, curated FastMCP application. -- Hand off to `bootstrap-python-service` or `bootstrap-python-mcp-service` only when the main task is creating fresh scaffolding rather than integrating existing code. - -## Core Guidance - -- Treat FastAPI-to-FastMCP generation as a bootstrap or discovery step, not proof that the generated server is already the right long-term MCP surface. -- Keep one source of truth for business logic and typed configuration, then expose it through both HTTP and MCP layers where that actually helps. -- Preserve `uv` as the package and command surface: - - use `uv add ...` for dependency changes - - use `uv run ...` for local execution - - use workspace-aware commands when the repo already uses `[tool.uv.workspace]` -- Keep safe defaults in committed `.env`, local or secret overrides in `.env.local`, and typed settings in a shared config module. - -## Integration Decision Guide - -1. Start by identifying the actual goal: - - add MCP to an existing API - - add FastAPI hosting around an existing MCP server - - serve both API and MCP from one process - - replace auto-generated MCP pieces with curated tools and resources -2. Choose the lightest integration pattern that satisfies that goal: - - mount an MCP ASGI app into FastAPI when the MCP server already exists or is intentionally separate - - generate an MCP server from FastAPI when bootstrapping from an API surface - - build a combined app when one process should serve both interfaces from the same codebase -3. Before keeping an auto-generated server, review whether the generated tool names, parameters, and endpoint coverage are actually LLM-friendly. -4. If the generated server is too broad or awkward, keep the shared domain logic and replace the MCP surface with explicit curated FastMCP components. - -## Recommended Patterns - -### Pattern A: Mount an existing MCP server into FastAPI - -Use this when FastMCP is already curated or intentionally distinct from the REST API. - -```python -from fastapi import FastAPI -from fastmcp import FastMCP - -mcp = FastMCP("Analytics Tools") - -@mcp.tool -def analyze_pricing(category: str) -> dict: - ... - -mcp_app = mcp.http_app(path="/mcp") -app = FastAPI(lifespan=mcp_app.lifespan) -app.mount("/analytics", mcp_app) -``` - -Guardrails: - -- Always pass the MCP lifespan into FastAPI. -- If FastAPI already has its own lifespan, combine both lifespans instead of replacing one with the other. -- Avoid top-level `CORSMiddleware` on a combined app when the mounted FastMCP server uses OAuth flows; prefer separate sub-apps if custom CORS is needed. - -### Pattern B: Generate FastMCP from an existing FastAPI app - -Use this when the API already exists and you need a quick MCP bootstrap surface. - -```python -from fastmcp import FastMCP - -mcp = FastMCP.from_fastapi(app=app, name="Project MCP") -``` - -Then immediately review: - -- operation IDs and resulting MCP names -- whether GET endpoints should remain tools or become resources/resource templates -- whether authentication headers or client config must be supplied through `httpx_client_kwargs` - -Do not stop after generation if the resulting surface is verbose, repetitive, or mirrors REST too literally. - -### Pattern C: Serve both API and MCP from one FastAPI process - -Use this when one deployment should expose both REST and MCP interfaces. - -```python -from fastapi import FastAPI -from fastmcp import FastMCP - -mcp = FastMCP.from_fastapi(app=app, name="Project MCP") -mcp_app = mcp.http_app(path="/mcp") - -combined_app = FastAPI( - title="Project API with MCP", - routes=[*mcp_app.routes, *app.routes], - lifespan=mcp_app.lifespan, -) -``` - -This is a good intermediate architecture when the MCP surface is still close to the API, but it still needs the same curation review as Pattern B. - -### Pattern D: Promote an auto-generated FastMCP server into a curated MCP app - -Use this when `FastMCP.from_fastapi(...)` got you started but the resulting server needs stronger MCP ergonomics. - -Promotion steps: - -1. Keep the shared FastAPI domain logic, models, and config modules. -2. Retain only the generated pieces that still provide good MCP ergonomics. -3. Add explicit FastMCP tools, resources, and prompts for the high-value tasks LLM clients actually need. -4. Use route maps only when they improve the MCP shape clearly. -5. Give FastAPI routes explicit `operation_id` values anywhere generated names would be poor MCP names. -6. Add client-based MCP tests so the MCP surface is verified directly instead of only through REST tests. - -## UV Workflow Expectations - -- In a single-project repo: - - add dependencies with `uv add` - - run local app flows with `uv run fastapi dev` or the project’s chosen entrypoint - - run checks with `uv run pytest`, `uv run ruff check .`, and `uv run mypy .` when configured -- In a workspace repo: - - add dependencies to the right member with `uv add --package <member> ...` - - run targeted commands with `uv run --package <member> ...` - - keep shared libraries and service members separated when that improves ownership and import clarity - -When a combined FastAPI/FastMCP setup starts demanding separate service members, call that out explicitly as an architectural pivot rather than silently introducing another package boundary. - -## Validation Checklist - -1. Confirm the chosen integration pattern matches the intended deployment shape. -2. Verify configuration loading still comes from one typed settings path. -3. Run HTTP-side checks. -4. Run MCP-side checks with a FastMCP client or equivalent integration test. -5. Confirm lifespan and startup/shutdown behavior are correct. -6. If FastAPI generated the MCP surface, review names, parameter shapes, and auth behavior before accepting the result. - -## References - -- `references/integration-patterns.md` -- `references/official-docs.md` - -Use a host-provided `fastmcp_docs` MCP server only when the host already has -one configured. This plugin does not package that server; otherwise use the -official FastMCP documentation and verify the installed FastMCP version before -adopting version-sensitive integration code. diff --git a/plugins/python-skills/skills/integrate-fastapi-fastmcp/agents/openai.yaml b/plugins/python-skills/skills/integrate-fastapi-fastmcp/agents/openai.yaml deleted file mode 100644 index f101e9623..000000000 --- a/plugins/python-skills/skills/integrate-fastapi-fastmcp/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Integrate FastAPI and FastMCP" - short_description: "Combine FastAPI and FastMCP in uv projects." - brand_color: "#0E7490" - default_prompt: "Use $integrate-fastapi-fastmcp to choose the right FastAPI and FastMCP integration pattern for this uv-managed project, keep typed settings and shared domain logic coherent, and promote any auto-generated FastAPI-to-FastMCP surface into a curated MCP design when needed." - -policy: - allow_implicit_invocation: true diff --git a/plugins/python-skills/skills/integrate-fastapi-fastmcp/references/integration-patterns.md b/plugins/python-skills/skills/integrate-fastapi-fastmcp/references/integration-patterns.md deleted file mode 100644 index 87e703e4f..000000000 --- a/plugins/python-skills/skills/integrate-fastapi-fastmcp/references/integration-patterns.md +++ /dev/null @@ -1,47 +0,0 @@ -# Integration Patterns - -Use this reference after the skill triggers and the repo shape is known. - -## Pattern Selection - -- Existing FastAPI app, new MCP surface needed: - - start with `FastMCP.from_fastapi(app=app)` if you need a quick bootstrap - - move to explicit FastMCP tools/resources if the generated interface is too REST-shaped -- Existing curated FastMCP app, FastAPI host needed: - - build `mcp_app = mcp.http_app(path="/mcp")` - - mount it into FastAPI and wire lifespan correctly -- One process should serve both REST and MCP: - - combine routes deliberately or mount the MCP sub-app - - choose the approach that keeps routing, auth, and middleware behavior easiest to reason about - -## Lifespan Rules - -- Always carry FastMCP lifespan into the FastAPI app when mounting or combining. -- If the FastAPI app already owns startup and shutdown work, create a combined lifespan context instead of replacing one side. - -## Naming Rules - -- FastAPI `operation_id` values become MCP component names during conversion. -- Add explicit `operation_id` values before or during the promotion pass whenever generated names would be noisy or unstable. - -## Promotion Heuristics - -Replace generated MCP pieces with explicit FastMCP components when: - -- a tool name mirrors REST path syntax instead of the task the user wants -- a single endpoint exposes too many parameters for an LLM-friendly tool -- related REST endpoints should really collapse into one higher-value MCP tool -- read-heavy GET routes should become resources or resource templates - -## Shared Configuration Rules - -- Prefer one typed settings module shared by both FastAPI and FastMCP layers. -- Keep `.env` committed for non-secret defaults and `.env.local` ignored for machine-local overrides. -- In FastAPI, prefer a cached settings dependency so the settings object is created once and remains easy to override in tests. - -## UV Workspace Rules - -- Use `uv add --package <member>` when the repo is already workspace-based. -- Keep service-specific dependencies scoped to the owning member instead of flattening everything into the root. -- Only split FastAPI and FastMCP into separate workspace members when that unlocks a real ownership or deployment need. - diff --git a/plugins/python-skills/skills/integrate-fastapi-fastmcp/references/official-docs.md b/plugins/python-skills/skills/integrate-fastapi-fastmcp/references/official-docs.md deleted file mode 100644 index c9deb4c88..000000000 --- a/plugins/python-skills/skills/integrate-fastapi-fastmcp/references/official-docs.md +++ /dev/null @@ -1,47 +0,0 @@ -# Official Docs - -Use these as the primary sources for current behavior and examples. - -## FastMCP - -- FastAPI integration guide: - - https://gofastmcp.com/v2/integrations/fastapi -- HTTP deployment and FastAPI mounting details: - - https://gofastmcp.com/v2/deployment/http#fastapi-integration - -FastMCP guidance this skill relies on: - -- `FastMCP.from_fastapi(app=app)` is good for bootstrapping and prototyping, not automatically the best final MCP surface. -- Mounting requires carrying the MCP lifespan into FastAPI. -- If FastAPI already has a lifespan, both contexts should be combined explicitly. -- FastAPI `operation_id` values become MCP component names. - -## FastAPI - -- Settings and environment variables: - - https://fastapi.tiangolo.com/advanced/settings/ -- Bigger applications and multi-file structure: - - https://fastapi.tiangolo.com/tutorial/bigger-applications/ -- Advanced path operation configuration: - - https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/ - -FastAPI guidance this skill relies on: - -- Typed settings should live in a shared config module and can be provided through dependencies. -- `@lru_cache` is the documented pattern for creating settings once while keeping tests override-friendly. -- Explicit `operation_id` values are available when default route naming would produce poor OpenAPI names. - -## uv - -- FastAPI integration guide: - - https://docs.astral.sh/uv/guides/integration/fastapi/ -- Working on projects: - - https://docs.astral.sh/uv/guides/projects/ -- Workspaces: - - https://docs.astral.sh/uv/concepts/projects/workspaces/ - -uv guidance this skill relies on: - -- `uv init --app` plus `uv add fastapi --extra standard` is the documented FastAPI project path. -- `uv run` creates and uses the project environment, lockfile, and dependency context automatically. -- Use workspace-aware dependency and run commands once the repo uses `[tool.uv.workspace]`. diff --git a/plugins/python-skills/skills/python-testing-workflow/SKILL.md b/plugins/python-skills/skills/python-testing-workflow/SKILL.md index 9b0857470..69b1949d6 100644 --- a/plugins/python-skills/skills/python-testing-workflow/SKILL.md +++ b/plugins/python-skills/skills/python-testing-workflow/SKILL.md @@ -2,7 +2,7 @@ name: python-testing-workflow description: Set up, run, and improve Python tests in uv projects and workspaces. Use for pytest configuration, focused and package-targeted runs, fixtures, parametrization, async and integration tests, coverage, CI parity, or failure triage. license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients on macOS with uv-managed Python projects, pytest, and shell access for the bundled setup and execution scripts. +compatibility: Designed for Codex and compatible Agent Skills clients with uv-managed Python projects and pytest. metadata: owner: gaelic-ghost repo: python-skills @@ -16,8 +16,7 @@ allowed-tools: Bash(uv:*) Read Make Python tests describe behavior, run through `uv`, and give a focused failure signal. Preserve the repository's existing test framework and markers; -use the bundled scripts only for pytest setup or repeatable package-targeted -runs. +use the repository's own checked-in commands for setup and execution. ## Workflow @@ -47,22 +46,6 @@ runs. validation commands. Add coverage only when the user or repository has a concrete coverage threshold or reporting need. -## Setup And Execution Scripts - -For a new pytest setup or a repeatable workspace command, use the existing -scripts: - -```bash -scripts/bootstrap_pytest_uv.sh --workspace-root <repo> -scripts/bootstrap_pytest_uv.sh --workspace-root <repo> --package <member-name> -scripts/run_pytest_uv.sh --workspace-root <repo> --package <member-name> -scripts/run_pytest_uv.sh --workspace-root <repo> --path tests/integration -- -m integration -``` - -Use `--with-cov` only when the requested test contract needs `pytest-cov`. -Profiles use the `python-testing-workflow` name and should remain optional; -ordinary repositories should work from their checked-in `pyproject.toml` alone. - ## FastAPI And FastMCP Boundaries For FastAPI, override external dependencies with @@ -117,8 +100,6 @@ Return: - `references/pytest-workflow.md` - `references/uv-workspace-testing.md` -- `references/customization.md` -- `references/interactive-customization.md` - [pytest documentation](https://docs.pytest.org/en/stable/) - [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/) - [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) diff --git a/plugins/python-skills/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml b/plugins/python-skills/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml deleted file mode 100644 index 318287547..000000000 --- a/plugins/python-skills/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# python-testing-workflow bootstrap config profile -# Use with: scripts/bootstrap_pytest_uv.sh --config <this-file> - -workspace_root: "." -package: "" -with_cov: false -dry_run: false diff --git a/plugins/python-skills/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml b/plugins/python-skills/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml deleted file mode 100644 index 8d57f2f01..000000000 --- a/plugins/python-skills/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# python-testing-workflow run config profile -# Use with: scripts/run_pytest_uv.sh --config <this-file> - -workspace_root: "." -package: "" -path: "" diff --git a/plugins/python-skills/skills/python-testing-workflow/references/customization.md b/plugins/python-skills/skills/python-testing-workflow/references/customization.md deleted file mode 100644 index 44fe163dc..000000000 --- a/plugins/python-skills/skills/python-testing-workflow/references/customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# Customization Guide - -Use this reference when you need to change the defaults shipped by `python-testing-workflow`. - -## High-Impact Knobs - -- baseline `tool.pytest.ini_options` content -- coverage behavior and optional dependency installation -- package-targeted run expectations for workspaces -- test path and marker conventions -- CI-oriented command examples - -## Audit Checklist After Changes - -- dry-run and real bootstrap flows still match the docs -- root-project and `--package` command examples still work -- the troubleshooting order still reflects the real intended workflow -- repo-level validation still passes after doc and metadata updates diff --git a/plugins/python-skills/skills/python-testing-workflow/references/interactive-customization.md b/plugins/python-skills/skills/python-testing-workflow/references/interactive-customization.md deleted file mode 100644 index 4d5c2c23e..000000000 --- a/plugins/python-skills/skills/python-testing-workflow/references/interactive-customization.md +++ /dev/null @@ -1,44 +0,0 @@ -# Interactive Customization - -## Checklist - -1. Choose script mode: -- bootstrap script: `bootstrap_pytest_uv.sh` -- run script: `run_pytest_uv.sh` -2. Gather `workspace_root` and optional `package`. -3. For bootstrap mode, confirm `with_cov` and `dry_run`. -4. For run mode, gather optional `path` and optional pytest passthrough args. -5. Return both YAML profile and exact command. - -## Schema - -Bootstrap script keys: -- `workspace_root` (string, default current directory) -- `package` (string, optional) -- `with_cov` (bool, default `false`) -- `dry_run` (bool, default `false`) - -Run script keys: -- `workspace_root` (string, default current directory) -- `package` (string, optional) -- `path` (string, optional) - -## Source Precedence - -1. CLI flags -2. `--config` file -3. Repo profile: `.codex/profiles/python-testing-workflow/customization.yaml` -4. Global profile: `~/.config/gaelic-ghost/python-skills/python-testing-workflow/customization.yaml` -5. Script defaults - -## Reset and Cleanup - -- `--bypassing-all-profiles`: ignore global and repo profile for this run. -- `--bypassing-repo-profile`: ignore only repo profile for this run. -- `--deleting-repo-profile`: delete repo profile before running. - -## Troubleshooting - -- Unknown key in YAML: script exits with an error naming the key. -- Missing explicit config file with `--config`: script exits with an error. -- Ensure `--` is used for pytest passthrough args in run mode. diff --git a/plugins/python-skills/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh b/plugins/python-skills/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh deleted file mode 100755 index 11abfb633..000000000 --- a/plugins/python-skills/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -WORKSPACE_ROOT="$(pwd)" -PACKAGE_NAME="" -WITH_COV=0 -DRY_RUN=0 - -SKILL_NAME="python-testing-workflow" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -usage() { - cat <<'USAGE' -Usage: bootstrap_pytest_uv.sh [--workspace-root PATH] [--package NAME] [--with-cov] [--dry-run] [--config PATH] - -Options: - --workspace-root PATH Repository root containing pyproject.toml (default: cwd) - --package NAME Workspace member package name for package-scoped install - --with-cov Also install pytest-cov and add coverage defaults when creating config - --dry-run Print planned commands and file changes without mutating files - --config PATH Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - -h, --help Show this help -USAGE -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: required command not found: $1" >&2 - exit 1 - fi -} - -fail() { - echo "error: $*" >&2 - exit 1 -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -bool_to_int() { - local value - value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" - case "$value" in - 1|true|yes|on) printf '1\n' ;; - 0|false|no|off) printf '0\n' ;; - *) fail "invalid boolean value '$1'" ;; - esac -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - workspace_root) WORKSPACE_ROOT="$value" ;; - package) PACKAGE_NAME="$value" ;; - with_cov) WITH_COV="$(bool_to_int "$value")" ;; - dry_run) DRY_RUN="$(bool_to_int "$value")" ;; - *) fail "unknown config key '$key'" ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "invalid config line at $path:$lineno" - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "empty config key at $path:$lineno" - apply_config_value "$key" "$value" - done < "$path" -} - -run_cmd() { - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "[dry-run] $*" - else - "$@" - fi -} - -append_pytest_config_if_missing() { - local pyproject_path="$1" - local addopts_value="-ra" - - if [[ "$WITH_COV" -eq 1 ]]; then - addopts_value="-ra --cov --cov-report=term-missing" - fi - - if rg -n "^\[tool\.pytest\.ini_options\]" "$pyproject_path" >/dev/null 2>&1; then - echo "info: [tool.pytest.ini_options] already exists in $pyproject_path; leaving config unchanged" - return 0 - fi - - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "[dry-run] append baseline [tool.pytest.ini_options] to $pyproject_path" - return 0 - fi - - cat >>"$pyproject_path" <<EOF_CFG - -[tool.pytest.ini_options] -addopts = "$addopts_value" -testpaths = ["tests"] -python_files = ["test_*.py", "*_test.py"] -EOF_CFG - - echo "info: added [tool.pytest.ini_options] to $pyproject_path" -} - -ORIGINAL_ARGS=("$@") - -while [[ $# -gt 0 ]]; do - case "$1" in - --config) - CONFIG_PATH="${2:-}" - [[ -n "$CONFIG_PATH" ]] || fail "--config requires a value" - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - --workspace-root|--package) - [[ $# -ge 2 ]] || fail "$1 requires a value" - shift 2 - ;; - --with-cov|--dry-run) - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -set -- "${ORIGINAL_ARGS[@]}" -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace-root) - WORKSPACE_ROOT="$2" - shift 2 - ;; - --package) - PACKAGE_NAME="$2" - shift 2 - ;; - --with-cov) - WITH_COV=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --config) - shift 2 - ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -require_cmd uv -require_cmd rg - -if [[ ! -d "$WORKSPACE_ROOT" ]]; then - echo "error: workspace root does not exist: $WORKSPACE_ROOT" >&2 - exit 1 -fi - -PYPROJECT_PATH="$WORKSPACE_ROOT/pyproject.toml" -if [[ ! -f "$PYPROJECT_PATH" ]]; then - echo "error: missing pyproject.toml at $PYPROJECT_PATH" >&2 - exit 1 -fi - -cd "$WORKSPACE_ROOT" - -typeset -a deps -if [[ "$WITH_COV" -eq 1 ]]; then - deps=(pytest pytest-cov) -else - deps=(pytest) -fi - -if [[ -n "$PACKAGE_NAME" ]]; then - run_cmd uv add --package "$PACKAGE_NAME" --dev "${deps[@]}" -else - run_cmd uv add --dev "${deps[@]}" -fi - -append_pytest_config_if_missing "$PYPROJECT_PATH" - -echo "info: bootstrap complete" diff --git a/plugins/python-skills/skills/python-testing-workflow/scripts/run_pytest_uv.sh b/plugins/python-skills/skills/python-testing-workflow/scripts/run_pytest_uv.sh deleted file mode 100755 index 255e247db..000000000 --- a/plugins/python-skills/skills/python-testing-workflow/scripts/run_pytest_uv.sh +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -WORKSPACE_ROOT="$(pwd)" -PACKAGE_NAME="" -TEST_PATH="" - -SKILL_NAME="python-testing-workflow" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -usage() { - cat <<'USAGE' -Usage: run_pytest_uv.sh [--workspace-root PATH] [--package NAME] [--path TEST_PATH] [--config PATH] [-- <pytest args>] - -Options: - --workspace-root PATH Repository root containing pyproject.toml (default: cwd) - --package NAME Workspace member package name for package-scoped run - --path TEST_PATH Optional test path selector (e.g., tests/unit) - --config PATH Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - -- Pass remaining args directly to pytest - -h, --help Show this help -USAGE -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: required command not found: $1" >&2 - exit 1 - fi -} - -fail() { - echo "error: $*" >&2 - exit 1 -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - workspace_root) WORKSPACE_ROOT="$value" ;; - package) PACKAGE_NAME="$value" ;; - path) TEST_PATH="$value" ;; - *) fail "unknown config key '$key'" ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "invalid config line at $path:$lineno" - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "empty config key at $path:$lineno" - apply_config_value "$key" "$value" - done < "$path" -} - -ORIGINAL_ARGS=("$@") - -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace-root|--package|--path|--config) - [[ $# -ge 2 ]] || fail "$1 requires a value" - if [[ "$1" == "--config" ]]; then - CONFIG_PATH="$2" - fi - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - --) - break - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -EXTRA_ARGS=() -set -- "${ORIGINAL_ARGS[@]}" -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace-root) - WORKSPACE_ROOT="$2" - shift 2 - ;; - --package) - PACKAGE_NAME="$2" - shift 2 - ;; - --path) - TEST_PATH="$2" - shift 2 - ;; - --config) - shift 2 - ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) - shift - ;; - --) - shift - EXTRA_ARGS=("$@") - break - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -require_cmd uv - -if [[ ! -d "$WORKSPACE_ROOT" ]]; then - echo "error: workspace root does not exist: $WORKSPACE_ROOT" >&2 - exit 1 -fi - -if [[ ! -f "$WORKSPACE_ROOT/pyproject.toml" ]]; then - echo "error: missing pyproject.toml at $WORKSPACE_ROOT/pyproject.toml" >&2 - exit 1 -fi - -cd "$WORKSPACE_ROOT" - -CMD=(uv run) -if [[ -n "$PACKAGE_NAME" ]]; then - CMD+=(--package "$PACKAGE_NAME") -fi -CMD+=(pytest) - -if [[ -n "$TEST_PATH" ]]; then - CMD+=("$TEST_PATH") -fi - -if [[ "${#EXTRA_ARGS[@]}" -gt 0 ]]; then - CMD+=("${EXTRA_ARGS[@]}") -fi - -echo "info: running: ${CMD[*]}" -"${CMD[@]}" diff --git a/plugins/repository-skills/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md b/plugins/repository-skills/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md index 0ad49ca90..b32b63051 100644 --- a/plugins/repository-skills/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md +++ b/plugins/repository-skills/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md @@ -46,11 +46,9 @@ Describe the terminology, casing, and naming patterns contributors should match ### Accessibility Expectations -Contributors must keep changes aligned with the project's accessibility contract in [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). - -If a change affects UI semantics, input behavior, focus flow, labels, announcements, motion, contrast, zoom behavior, content structure, or assistive-technology compatibility, verify the affected surface against the documented accessibility standards before asking for review. - -If a change introduces a new accessibility limitation, exception, or remediation plan, update `ACCESSIBILITY.md` in the same pass unless maintainers have explicitly agreed on a different tracking path. +Keep commands, logs, headings, links, errors, and user-facing behavior readable +and actionable. Record product-specific accessibility requirements beside the +surface that owns them; do not create a separate root accessibility contract. ### Verification diff --git a/plugins/reverse-engineering-skills/AGENTS.md b/plugins/reverse-engineering-skills/AGENTS.md index f78c6d5b3..e092f576a 100644 --- a/plugins/reverse-engineering-skills/AGENTS.md +++ b/plugins/reverse-engineering-skills/AGENTS.md @@ -15,3 +15,7 @@ This file is the Reverse Engineering Skills child-repo override for work done fr - Preserve original artifacts by default. Prefer copying inputs into a clearly named working area, recording hashes or identifying metadata when useful, and documenting tool versions and commands used. - Keep platform-specific work delegated where appropriate: use `dotnet-skills` for ordinary .NET development, `apple-dev-skills` for Apple build and Xcode workflows, and this plugin only when compiled artifacts, decompiled output, symbols, or binary metadata are the center of the task. - When this repository changes the root Socket marketplace or root docs, update those root surfaces in the same pass. + +## Validation + +Run `just repo-validate` and `just test` from the Socket root. diff --git a/plugins/reverse-engineering-skills/scripts/validate_repo_metadata.py b/plugins/reverse-engineering-skills/scripts/validate_repo_metadata.py deleted file mode 100755 index e7b4e69eb..000000000 --- a/plugins/reverse-engineering-skills/scripts/validate_repo_metadata.py +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "pyyaml>=6.0.2,<7", -# ] -# /// -"""Validate the Reverse Engineering Skills authored and packaged surfaces.""" - -from __future__ import annotations - -import json -import re -import sys -from dataclasses import dataclass -from pathlib import Path - -import yaml - - -REPO_ROOT = Path(__file__).resolve().parent.parent -SKILLS_ROOT = REPO_ROOT / "skills" -PLUGIN_MANIFEST = REPO_ROOT / ".codex-plugin" / "plugin.json" -SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -MARKDOWN_LINK = re.compile(r"\[[^]]*]\(([^)]+)\)") -MACHINE_LOCAL_MARKERS = ("/Users/", "~/", "../") - - -@dataclass(frozen=True) -class Finding: - """Describe one actionable metadata validation failure.""" - - path: str - message: str - - -def parse_yaml(path: Path) -> object: - """Parse a YAML file without turning expected input failures into exceptions.""" - - try: - return yaml.safe_load(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return None - except yaml.YAMLError as error: - return Finding(str(path.relative_to(REPO_ROOT)), f"contains invalid YAML: {error}") - - -def parse_frontmatter(path: Path) -> tuple[dict[str, object] | None, str, list[Finding]]: - """Split and validate one skill entry point's YAML frontmatter.""" - - text = path.read_text(encoding="utf-8") - findings: list[Finding] = [] - if not text.startswith("---\n"): - return None, text, [Finding(str(path.relative_to(REPO_ROOT)), "must begin with YAML frontmatter")] - try: - raw_frontmatter, body = text[4:].split("\n---\n", 1) - except ValueError: - return None, text, [Finding(str(path.relative_to(REPO_ROOT)), "has unterminated YAML frontmatter")] - try: - parsed = yaml.safe_load(raw_frontmatter) - except yaml.YAMLError as error: - return None, body, [Finding(str(path.relative_to(REPO_ROOT)), f"has invalid YAML frontmatter: {error}")] - if not isinstance(parsed, dict): - findings.append(Finding(str(path.relative_to(REPO_ROOT)), "frontmatter must be a YAML mapping")) - return None, body, findings - return parsed, body, findings - - -def validate_links(path: Path, body: str) -> list[Finding]: - """Check that relative Markdown links remain inside the plugin and resolve.""" - - findings: list[Finding] = [] - for target in MARKDOWN_LINK.findall(body): - if target.startswith(("https://", "http://", "#", "mailto:")): - continue - relative_target = target.split("#", 1)[0] - if not relative_target: - continue - resolved = (path.parent / relative_target).resolve() - try: - resolved.relative_to(REPO_ROOT.resolve()) - except ValueError: - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"links outside the plugin root: {target}")) - continue - if not resolved.exists(): - findings.append(Finding(str(path.relative_to(REPO_ROOT)), f"links to a missing local resource: {target}")) - return findings - - -def validate_skill(skill_dir: Path) -> list[Finding]: - """Validate one authored skill folder and its OpenAI interface metadata.""" - - findings: list[Finding] = [] - relative_dir = str(skill_dir.relative_to(REPO_ROOT)) - skill_path = skill_dir / "SKILL.md" - if not skill_path.is_file(): - return [Finding(relative_dir, "is missing its required SKILL.md entry point")] - - frontmatter, body, parse_findings = parse_frontmatter(skill_path) - findings.extend(parse_findings) - if frontmatter is not None: - unexpected_fields = sorted(set(frontmatter) - {"name", "description", "metadata"}) - if unexpected_fields: - findings.append( - Finding( - str(skill_path.relative_to(REPO_ROOT)), - f"frontmatter contains unsupported fields: {', '.join(unexpected_fields)}", - ) - ) - name = frontmatter.get("name") - if name != skill_dir.name: - findings.append( - Finding( - str(skill_path.relative_to(REPO_ROOT)), - f"frontmatter name must match directory `{skill_dir.name}`, but found `{name}`", - ) - ) - if not isinstance(name, str) or not SKILL_NAME.fullmatch(name): - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "frontmatter name violates skill naming rules")) - description = frontmatter.get("description") - if not isinstance(description, str) or not description.strip(): - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "frontmatter description must be non-empty")) - elif len(description) > 1024: - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "frontmatter description exceeds 1024 characters")) - metadata = frontmatter.get("metadata") - if metadata is not None: - if not isinstance(metadata, dict): - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "metadata must be a mapping")) - else: - hermes_metadata = metadata.get("hermes") - if hermes_metadata is not None: - if not isinstance(hermes_metadata, dict): - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "metadata.hermes must be a mapping")) - else: - category = hermes_metadata.get("category") - tags = hermes_metadata.get("tags") - if not isinstance(category, str) or not category.strip(): - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "metadata.hermes.category must be a non-empty string")) - if not isinstance(tags, list) or not tags or not all(isinstance(tag, str) and tag.strip() for tag in tags): - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "metadata.hermes.tags must be a non-empty string list")) - - if "TODO" in body: - findings.append(Finding(str(skill_path.relative_to(REPO_ROOT)), "contains unresolved TODO scaffold text")) - for marker in MACHINE_LOCAL_MARKERS: - if marker in body: - findings.append( - Finding(str(skill_path.relative_to(REPO_ROOT)), f"contains prohibited machine-local or parent-relative path marker `{marker}`") - ) - findings.extend(validate_links(skill_path, body)) - - agent_path = skill_dir / "agents" / "openai.yaml" - agent_data = parse_yaml(agent_path) - if agent_data is None: - findings.append(Finding(relative_dir, "is missing agents/openai.yaml")) - elif isinstance(agent_data, Finding): - findings.append(agent_data) - elif not isinstance(agent_data, dict) or not isinstance(agent_data.get("interface"), dict): - findings.append(Finding(str(agent_path.relative_to(REPO_ROOT)), "must define an interface mapping")) - else: - interface = agent_data["interface"] - for key in ("display_name", "short_description", "default_prompt"): - value = interface.get(key) - if not isinstance(value, str) or not value.strip(): - findings.append(Finding(str(agent_path.relative_to(REPO_ROOT)), f"interface.{key} must be a non-empty string")) - short_description = interface.get("short_description") - if isinstance(short_description, str) and not 25 <= len(short_description) <= 64: - findings.append(Finding(str(agent_path.relative_to(REPO_ROOT)), "interface.short_description must be 25 to 64 characters")) - default_prompt = interface.get("default_prompt") - if isinstance(default_prompt, str) and f"${skill_dir.name}" not in default_prompt: - findings.append( - Finding( - str(agent_path.relative_to(REPO_ROOT)), - f"interface.default_prompt must mention `${skill_dir.name}` explicitly", - ) - ) - - return findings - - -def validate_manifest() -> list[Finding]: - """Validate that plugin metadata exports the authored skills directory.""" - - try: - manifest = json.loads(PLUGIN_MANIFEST.read_text(encoding="utf-8")) - except FileNotFoundError: - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "is missing")] - except json.JSONDecodeError as error: - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), f"contains invalid JSON: {error}")] - if not isinstance(manifest, dict): - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "manifest must be a JSON object")] - if manifest.get("skills") != "./skills/": - return [Finding(str(PLUGIN_MANIFEST.relative_to(REPO_ROOT)), "must export the authored ./skills/ directory")] - return [] - - -def main() -> int: - """Run every plugin-local validation and return a shell-compatible status.""" - - findings = validate_manifest() - skill_dirs = sorted(path for path in SKILLS_ROOT.iterdir() if path.is_dir()) if SKILLS_ROOT.is_dir() else [] - if not skill_dirs: - findings.append(Finding("skills", "must contain at least one exported skill directory")) - for skill_dir in skill_dirs: - findings.extend(validate_skill(skill_dir)) - - if findings: - print("Reverse Engineering Skills validation failed:", file=sys.stderr) - for finding in findings: - print(f"- {finding.path}: {finding.message}", file=sys.stderr) - return 1 - - print(f"Reverse Engineering Skills validation passed for {len(skill_dirs)} skills.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/server-side-swift/skills/workspace-service-component/scripts/run-workflow.fsx b/plugins/server-side-swift/skills/workspace-service-component/scripts/run-workflow.fsx new file mode 100644 index 000000000..5e13be9f5 --- /dev/null +++ b/plugins/server-side-swift/skills/workspace-service-component/scripts/run-workflow.fsx @@ -0,0 +1,21 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let value flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let has flag = args |> Array.contains flag +let missing = [ "--repo-root"; "--name"; "--framework" ] |> List.filter (value >> Option.isNone) +if missing.Length > 0 then eprintfn "Missing required arguments: %s" (String.concat ", " missing); exit 2 +let repo = value "--repo-root" |> Option.get |> Path.GetFullPath +let name = value "--name" |> Option.get +let framework = value "--framework" |> Option.get +if framework <> "hummingbird" && framework <> "vapor" then eprintfn "--framework must be hummingbird or vapor."; exit 2 +let target = Path.Combine(repo, "Services", name) +if not (has "--dry-run") then + Directory.CreateDirectory(Path.Combine(target, "Sources", name)) |> ignore + let dependency, product = if framework = "hummingbird" then ".package(url: \"https://github.com/hummingbird-project/hummingbird.git\", from: \"2.0.0\")", ".product(name: \"Hummingbird\", package: \"hummingbird\")" else ".package(url: \"https://github.com/vapor/vapor.git\", from: \"4.0.0\")", ".product(name: \"Vapor\", package: \"vapor\")" + File.WriteAllText(Path.Combine(target, "Package.swift"), $"// swift-tools-version: 6.2\nimport PackageDescription\nlet package = Package(name: \"{name}\", platforms: [.macOS(.v15)], dependencies: [{dependency}], targets: [.executableTarget(name: \"{name}\", dependencies: [{product}])])\n") +printfn "%s" (JsonSerializer.Serialize({| status = "success"; service_root = target; framework = framework; dry_run = has "--dry-run"; policy = "fixed-native-macos-and-github-linux" |}, JsonSerializerOptions(WriteIndented = true))) diff --git a/plugins/server-side-swift/skills/workspace-service-component/scripts/run_workflow.py b/plugins/server-side-swift/skills/workspace-service-component/scripts/run_workflow.py deleted file mode 100755 index da10e54e0..000000000 --- a/plugins/server-side-swift/skills/workspace-service-component/scripts/run_workflow.py +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env python3 -"""Add a framework-owned Swift service package to a canonical product workspace.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import subprocess -from pathlib import Path - - -def emit(status: str, root: Path, name: str, framework: str, *, message: str | None = None, actions: list[str] | None = None) -> int: - payload = { - "status": status, - "path_type": "primary", - "output": { - "workspace_root": str(root), - "service_root": str(root / "Services" / name), - "name": name, - "framework": framework, - "actions": actions or [], - "next_step": message, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if status == "success" else 1 - - -def add_service_mapping(root: Path, name: str) -> None: - shared = root / "Services/services-shared.yml" - content = shared.read_text(encoding="utf-8") - if f" {name}:\n" in content: - return - if content.strip() == "packages: {}": - content = "packages:\n" - elif not content.endswith("\n"): - content += "\n" - shared.write_text(content + f" {name}:\n path: Services/{name}\n", encoding="utf-8") - - -def local_services_script() -> str: - return """#!/usr/bin/env sh -set -eu -command -v brew >/dev/null 2>&1 || { echo "Homebrew is required for native local service dependencies." >&2; exit 1; } -formula=${SERVICE_POSTGRES_FORMULA:-} -if [ -z "$formula" ]; then - installed=$(brew list --formula | awk '/^postgresql(@[0-9]+)?$/') - count=$(printf '%s\n' "$installed" | awk 'NF { count += 1 } END { print count + 0 }') - [ "$count" -eq 1 ] || { echo "Set SERVICE_POSTGRES_FORMULA to the one installed PostgreSQL formula this repository uses. Installed candidates: ${installed:-none}. Current standard install: brew install postgresql@18" >&2; exit 1; } - formula=$installed -fi -brew list --formula "$formula" >/dev/null 2>&1 || { echo "Missing $formula. Install it explicitly with: brew install $formula" >&2; exit 1; } -case ${1:-status} in - status) ;; - start) brew services start "$formula" ;; - *) echo "Usage: $0 [status|start]" >&2; exit 1 ;; -esac -brew services list | awk -v formula="$formula" '$1 == formula && $2 == "started" { found=1 } END { exit found ? 0 : 1 }' || { echo "$formula is not running. Start it with: brew services start $formula" >&2; exit 1; } -echo "$formula is installed and running natively through Homebrew services." -""" - - -def github_workflow(name: str) -> str: - image_name = name.lower() - return f"""name: {name} service - -on: - pull_request: - paths: ["Services/{name}/**", ".github/workflows/{name}-service.yml"] - push: - branches: [main] - paths: ["Services/{name}/**", ".github/workflows/{name}-service.yml"] - workflow_dispatch: - inputs: - environment: - type: choice - options: [live-test, production] - default: live-test - -permissions: - contents: read - id-token: write - packages: write - -concurrency: - group: {name}-${{{{ github.event.inputs.environment || 'validation' }}}} - cancel-in-progress: false - -jobs: - validate-linux: - runs-on: ubuntu-latest - outputs: - image: ${{{{ steps.identity.outputs.image }}}} - digest: ${{{{ steps.build.outputs.digest }}}} - defaults: - run: - working-directory: Services/{name} - steps: - - uses: actions/checkout@v6.0.2 - - uses: swift-actions/setup-swift@v2 - - run: swift build -c release - - run: swift test - - id: identity - working-directory: . - run: echo "image=ghcr.io/${{{{ github.repository_owner }}}}/{image_name}" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v4 - if: github.event_name != 'pull_request' - with: - registry: ghcr.io - username: ${{{{ github.actor }}}} - password: ${{{{ github.token }}}} - - uses: docker/setup-buildx-action@v4 - if: github.event_name != 'pull_request' - - id: build - if: github.event_name != 'pull_request' - uses: docker/build-push-action@v7 - with: - context: Services/{name} - push: true - tags: ${{{{ steps.identity.outputs.image }}}}:${{{{ github.sha }}}} - provenance: true - sbom: true - - name: Smoke-test the exact published digest - if: github.event_name != 'pull_request' - working-directory: . - run: Scripts/smoke-{name}-image.sh "${{{{ steps.identity.outputs.image }}}}@${{{{ steps.build.outputs.digest }}}}" - - name: Record immutable image identity - if: github.event_name != 'pull_request' - working-directory: . - run: printf '%s@%s\n' "${{{{ steps.identity.outputs.image }}}}" "${{{{ steps.build.outputs.digest }}}}" > Services/{name}/image.identity - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: {name}-image-identity - path: Services/{name}/image.identity - - deploy: - if: github.event_name == 'workflow_dispatch' - needs: validate-linux - runs-on: ubuntu-latest - environment: ${{{{ inputs.environment }}}} - steps: - - uses: actions/checkout@v6.0.2 - - name: Deploy the GitHub-built revision - run: Scripts/deploy-{name}.sh "${{{{ needs.validate-linux.outputs.image }}}}@${{{{ needs.validate-linux.outputs.digest }}}}" "${{{{ inputs.environment }}}}" -""" - - -def blocked_cloud_script(kind: str, name: str) -> str: - return f"""#!/usr/bin/env sh -set -eu -echo "The {kind} adapter for {name} is not configured. Replace this fail-closed script with the repository's reviewed provider command; keep immutable identity, GitHub environment, OIDC, health, and rollback ownership intact." >&2 -exit 1 -""" - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root", required=True) - parser.add_argument("--name", required=True) - parser.add_argument("--framework", required=True, choices=("hummingbird", "vapor")) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--skip-validation", action="store_true") - args = parser.parse_args() - root = Path(args.repo_root).expanduser().resolve() - target = root / "Services" / args.name - required = (root / "project.yml", root / "Services/services-shared.yml") - if not root.is_dir() or any(not path.is_file() for path in required): - return emit("blocked", root, args.name, args.framework, message="Use the canonical workspace entrypoint and point it at a workspace containing project.yml and Services/services-shared.yml.") - if target.exists(): - return emit("blocked", root, args.name, args.framework, message=f"Services/{args.name} already exists.") - command = ["hb", "init", args.name] if args.framework == "hummingbird" else ["vapor", "new", args.name] - tool = command[0] - actions = [f"run {' '.join(command)} under Services/", "remove generated Compose files and nested Git metadata", "add the service package to Services/services-shared.yml", "install native Homebrew and GitHub Actions boundaries"] - if args.dry_run: - return emit("success", root, args.name, args.framework, actions=actions, message="Run through bootstrap-xcode-workspace add-component; local dependencies will use native brew services and cloud builds will use GitHub Actions.") - if not shutil.which(tool): - install = "brew tap hummingbird-project/tap && brew install hb" if tool == "hb" else "brew install vapor" - return emit("blocked", root, args.name, args.framework, actions=actions, message=f"Missing {tool}. Install it with: {install}") - (root / "Services").mkdir(parents=True, exist_ok=True) - generated = subprocess.run(command, cwd=root / "Services", capture_output=True, text=True, check=False) - if generated.returncode != 0 or not (target / "Package.swift").is_file(): - return emit("failed", root, args.name, args.framework, actions=actions, message=f"{tool} generation failed: {generated.stderr or generated.stdout}") - nested_git = target / ".git" - if nested_git.is_dir(): - shutil.rmtree(nested_git) - elif nested_git.exists(): - nested_git.unlink() - for filename in ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"): - path = target / filename - if path.is_file(): - path.unlink() - scripts = target / "Scripts" - scripts.mkdir(exist_ok=True) - local_script = scripts / "check-local-services.sh" - local_script.write_text(local_services_script(), encoding="utf-8") - local_script.chmod(0o755) - workflow = root / f".github/workflows/{args.name}-service.yml" - workflow.parent.mkdir(parents=True, exist_ok=True) - workflow.write_text(github_workflow(args.name), encoding="utf-8") - for kind in ("smoke", "deploy"): - cloud_script = root / f"Scripts/{kind}-{args.name}-image.sh" if kind == "smoke" else root / f"Scripts/deploy-{args.name}.sh" - cloud_script.parent.mkdir(parents=True, exist_ok=True) - cloud_script.write_text(blocked_cloud_script(kind, args.name), encoding="utf-8") - cloud_script.chmod(0o755) - add_service_mapping(root, args.name) - if not args.skip_validation: - for command in (["swift", "build"], ["swift", "test"]): - checked = subprocess.run(command, cwd=target, capture_output=True, text=True, check=False) - if checked.returncode != 0: - return emit("failed", root, args.name, args.framework, actions=actions, message=f"{' '.join(command)} failed: {checked.stderr or checked.stdout}") - return emit("success", root, args.name, args.framework, actions=actions, message="Use native Swift and brew services locally; use the generated GitHub workflow for Linux artifacts and deployments.") - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/swift-lang/AGENTS.md b/plugins/swift-lang/AGENTS.md index 18d1643db..18b9e0677 100644 --- a/plugins/swift-lang/AGENTS.md +++ b/plugins/swift-lang/AGENTS.md @@ -33,7 +33,7 @@ This file is the Swift Lang child-plugin override for work done from `socket`. F Use the narrowest validation that proves the changed surface. For plugin metadata changes, run from the Socket root: ```bash -uv run scripts/validate_socket_metadata.py +just repo-validate ``` For future Swift helper scripts or tested contracts added under this plugin, add child-plugin validation commands here in the same pass. diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 57bd47f73..000000000 --- a/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[project] -name = "socket-maintenance" -version = "10.0.2" -description = "Root uv tooling baseline for the socket superproject." -requires-python = ">=3.11" -dependencies = [] - -[dependency-groups] -dev = [ - "mypy>=1.20.1", - "pytest>=9.0.3", - "PyYAML>=6.0.0", - "ruff>=0.14.0", - "types-PyYAML>=6.0.12.20250915", -] - -[tool.pytest.ini_options] -testpaths = [ - "tests", - "plugins/model-lab-skills/skills/design-model-experiment/scripts", - "plugins/model-lab-skills/skills/evaluate-language-model/scripts", - "plugins/model-lab-skills/skills/compare-model-checkpoints/scripts", -] - -[tool.mypy] -files = [ - "scripts", - "tests", - "plugins/repository-skills/skills/maintain-project-repo/scripts", - "plugins/model-lab-skills/skills/design-model-experiment/scripts", - "plugins/model-lab-skills/skills/evaluate-language-model/scripts", - "plugins/model-lab-skills/skills/compare-model-checkpoints/scripts", -] diff --git a/scripts/audit_skill_surfaces.py b/scripts/audit_skill_surfaces.py deleted file mode 100755 index 06f28f81e..000000000 --- a/scripts/audit_skill_surfaces.py +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -"""Report token-efficiency and drift hotspots across Socket skill surfaces.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Literal - - -REPO_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_REPEATED_PHRASES = ( - "When the user explicitly requests subagents", - "Dash MCP or Dash HTTP", - "Do not run multiple SwiftPM or Xcode build/test commands concurrently", - "Run the repository's documented validation path", - "Report the intended edit scope", -) -VERSION_SENSITIVE_RE = re.compile(r"\bAs of\b.*?\bv?\d+\.\d+\.\d+\b") -HANDOFF_EXPECTATIONS = { - "plugins/swiftasb-skills/skills/build-appkit-app/SKILL.md": ( - ("swiftasb:explain-swiftasb",), - ("apple-dev-skills:explore-apple-swift-docs", "Apple Dev Skills"), - ), - "plugins/swiftasb-skills/skills/build-swift-package/SKILL.md": ( - ("swiftasb:explain-swiftasb",), - ("apple-dev-skills:bootstrap-xcode-workspace", "Apple Swift workspace workflow skills"), - ), - "plugins/swiftasb-skills/skills/build-swiftui-app/SKILL.md": ( - ("swiftasb:explain-swiftasb",), - ("apple-dev-skills:explore-apple-swift-docs", "Apple Dev Skills"), - ), - "plugins/swiftasb-skills/skills/choose-integration-shape/SKILL.md": ( - ("Apple Dev Skills", "apple-dev-skills"), - ), - "plugins/web-dev-skills/skills/expo-inline-native-modules-workflow/SKILL.md": ( - ("Apple Dev Skills", "apple-dev-skills"), - ), -} - - -@dataclass(frozen=True) -class TextSurface: - plugin: str - path: Path - relative_path: str - line_count: int - text: str - - -@dataclass(frozen=True) -class DuplicateGroup: - digest: str - line_count: int - paths: tuple[str, ...] - - -@dataclass(frozen=True) -class PhraseHit: - phrase: str - file_count: int - total_count: int - paths: tuple[str, ...] - - -@dataclass(frozen=True) -class VersionHit: - path: str - line: int - text: str - - -@dataclass(frozen=True) -class MissingHandoff: - plugin: str - path: str - expected: str - - -@dataclass(frozen=True) -class AuditReport: - skill_count: int - skill_lines: int - reference_count: int - reference_lines: int - skill_lines_by_plugin: dict[str, int] - largest_skills: tuple[TextSurface, ...] - largest_references: tuple[TextSurface, ...] - duplicate_references: tuple[DuplicateGroup, ...] - phrase_hits: tuple[PhraseHit, ...] - version_hits: tuple[VersionHit, ...] - missing_handoffs: tuple[MissingHandoff, ...] - - -def plugin_name_for(path: Path) -> str: - parts = path.parts - try: - plugins_index = parts.index("plugins") - except ValueError: - return "(unknown)" - if len(parts) <= plugins_index + 1: - return "(unknown)" - return parts[plugins_index + 1] - - -def read_text_surface(repo_root: Path, path: Path) -> TextSurface: - text = path.read_text(encoding="utf-8") - return TextSurface( - plugin=plugin_name_for(path.relative_to(repo_root)), - path=path, - relative_path=path.relative_to(repo_root).as_posix(), - line_count=len(text.splitlines()), - text=text, - ) - - -def discover_surfaces(repo_root: Path, pattern: str) -> tuple[TextSurface, ...]: - paths = sorted(repo_root.glob(pattern)) - return tuple(read_text_surface(repo_root, path) for path in paths if path.is_file()) - - -def find_duplicate_references(references: tuple[TextSurface, ...]) -> tuple[DuplicateGroup, ...]: - by_digest: dict[str, list[TextSurface]] = {} - for surface in references: - digest = hashlib.sha256(surface.text.encode("utf-8")).hexdigest() - by_digest.setdefault(digest, []).append(surface) - - groups: list[DuplicateGroup] = [] - for digest, surfaces in by_digest.items(): - if len(surfaces) < 2: - continue - first = surfaces[0] - groups.append( - DuplicateGroup( - digest=digest, - line_count=first.line_count, - paths=tuple(surface.relative_path for surface in sorted(surfaces, key=lambda item: item.relative_path)), - ) - ) - - return tuple(sorted(groups, key=lambda group: (-len(group.paths), -group.line_count, group.digest))) - - -def find_phrase_hits(surfaces: tuple[TextSurface, ...], phrases: tuple[str, ...]) -> tuple[PhraseHit, ...]: - hits: list[PhraseHit] = [] - for phrase in phrases: - paths: list[str] = [] - total_count = 0 - for surface in surfaces: - count = surface.text.count(phrase) - if count == 0: - continue - total_count += count - paths.append(surface.relative_path) - if total_count > 0: - hits.append( - PhraseHit( - phrase=phrase, - file_count=len(paths), - total_count=total_count, - paths=tuple(paths), - ) - ) - return tuple(sorted(hits, key=lambda hit: (-hit.total_count, hit.phrase))) - - -def find_version_hits(surfaces: tuple[TextSurface, ...]) -> tuple[VersionHit, ...]: - hits: list[VersionHit] = [] - for surface in surfaces: - for line_number, line in enumerate(surface.text.splitlines(), start=1): - if VERSION_SENSITIVE_RE.search(line): - hits.append(VersionHit(path=surface.relative_path, line=line_number, text=line.strip())) - return tuple(hits) - - -def find_missing_handoffs(skills: tuple[TextSurface, ...]) -> tuple[MissingHandoff, ...]: - missing: list[MissingHandoff] = [] - for surface in skills: - expectations = HANDOFF_EXPECTATIONS.get(surface.relative_path) - if not expectations: - continue - for alternatives in expectations: - if not any(expected in surface.text for expected in alternatives): - missing.append( - MissingHandoff( - plugin=surface.plugin, - path=surface.relative_path, - expected=" or ".join(alternatives), - ) - ) - return tuple(sorted(missing, key=lambda item: (item.plugin, item.path, item.expected))) - - -def build_report(repo_root: Path, *, top: int = 10) -> AuditReport: - skills = discover_surfaces(repo_root, "plugins/*/skills/*/SKILL.md") - references = discover_surfaces(repo_root, "plugins/*/skills/*/references/**/*.md") - - skill_lines_by_plugin: dict[str, int] = {} - for skill in skills: - skill_lines_by_plugin[skill.plugin] = skill_lines_by_plugin.get(skill.plugin, 0) + skill.line_count - - return AuditReport( - skill_count=len(skills), - skill_lines=sum(skill.line_count for skill in skills), - reference_count=len(references), - reference_lines=sum(reference.line_count for reference in references), - skill_lines_by_plugin=dict(sorted(skill_lines_by_plugin.items())), - largest_skills=tuple(sorted(skills, key=lambda item: (-item.line_count, item.relative_path))[:top]), - largest_references=tuple(sorted(references, key=lambda item: (-item.line_count, item.relative_path))[:top]), - duplicate_references=find_duplicate_references(references), - phrase_hits=find_phrase_hits(skills, DEFAULT_REPEATED_PHRASES), - version_hits=find_version_hits(skills), - missing_handoffs=find_missing_handoffs(skills), - ) - - -def surface_to_json(surface: TextSurface) -> dict[str, object]: - return { - "plugin": surface.plugin, - "path": surface.relative_path, - "line_count": surface.line_count, - } - - -def report_to_json(report: AuditReport) -> dict[str, object]: - return { - "skill_count": report.skill_count, - "skill_lines": report.skill_lines, - "reference_count": report.reference_count, - "reference_lines": report.reference_lines, - "skill_lines_by_plugin": report.skill_lines_by_plugin, - "largest_skills": [surface_to_json(surface) for surface in report.largest_skills], - "largest_references": [surface_to_json(surface) for surface in report.largest_references], - "duplicate_references": [ - { - "digest": group.digest, - "line_count": group.line_count, - "paths": list(group.paths), - } - for group in report.duplicate_references - ], - "phrase_hits": [ - { - "phrase": hit.phrase, - "file_count": hit.file_count, - "total_count": hit.total_count, - "paths": list(hit.paths), - } - for hit in report.phrase_hits - ], - "version_hits": [ - { - "path": hit.path, - "line": hit.line, - "text": hit.text, - } - for hit in report.version_hits - ], - "missing_handoffs": [ - { - "plugin": missing.plugin, - "path": missing.path, - "expected": missing.expected, - } - for missing in report.missing_handoffs - ], - } - - -def markdown_table(rows: list[tuple[object, ...]], headers: tuple[str, ...]) -> str: - lines = [ - "| " + " | ".join(headers) + " |", - "| " + " | ".join("---" for _ in headers) + " |", - ] - for row in rows: - lines.append("| " + " | ".join(str(item) for item in row) + " |") - return "\n".join(lines) - - -def render_markdown(report: AuditReport) -> str: - sections = [ - "# Socket Skill Surface Audit", - "", - "## Summary", - "", - f"- Skills: {report.skill_count} files, {report.skill_lines} lines", - f"- References: {report.reference_count} files, {report.reference_lines} lines", - f"- Exact duplicate reference groups: {len(report.duplicate_references)}", - f"- Version-sensitive lines: {len(report.version_hits)}", - f"- Missing expected handoffs: {len(report.missing_handoffs)}", - "", - "## Skill Lines By Plugin", - "", - markdown_table( - [(plugin, lines) for plugin, lines in sorted(report.skill_lines_by_plugin.items(), key=lambda item: (-item[1], item[0]))], - ("Plugin", "Skill lines"), - ), - "", - "## Largest Skills", - "", - markdown_table( - [(surface.relative_path, surface.line_count) for surface in report.largest_skills], - ("Path", "Lines"), - ), - "", - "## Largest References", - "", - markdown_table( - [(surface.relative_path, surface.line_count) for surface in report.largest_references], - ("Path", "Lines"), - ), - "", - "## Exact Duplicate References", - "", - ] - - if report.duplicate_references: - for group in report.duplicate_references: - sections.append(f"- {len(group.paths)} files, {group.line_count} lines, sha256 `{group.digest[:12]}`") - sections.extend(f" - `{path}`" for path in group.paths) - else: - sections.append("No duplicate reference groups found.") - - sections.extend(["", "## Repeated Phrase Hits", ""]) - if report.phrase_hits: - sections.append( - markdown_table( - [(hit.phrase, hit.file_count, hit.total_count) for hit in report.phrase_hits], - ("Phrase", "Files", "Total hits"), - ) - ) - else: - sections.append("No configured repeated phrase hits found.") - - sections.extend(["", "## Version-Sensitive Lines", ""]) - if report.version_hits: - for hit in report.version_hits: - sections.append(f"- `{hit.path}:{hit.line}`: {hit.text}") - else: - sections.append("No version-sensitive lines found.") - - sections.extend(["", "## Missing Expected Handoffs", ""]) - if report.missing_handoffs: - for missing in report.missing_handoffs: - sections.append(f"- `{missing.path}` does not mention `{missing.expected}`") - else: - sections.append("No missing expected handoffs found.") - - sections.append("") - return "\n".join(sections) - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Report token-efficiency hotspots across Socket skill surfaces.") - parser.add_argument("--repo-root", type=Path, default=REPO_ROOT, help="Socket repository root to audit.") - parser.add_argument("--top", type=int, default=10, help="Number of largest skills and references to show.") - parser.add_argument( - "--output", - type=Path, - help="Optional path for writing the rendered report. Parent directories are created automatically.", - ) - parser.add_argument( - "--format", - choices=("markdown", "json"), - default="markdown", - help="Output format.", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv) - output_format: Literal["markdown", "json"] = args.format - report = build_report(args.repo_root.resolve(), top=args.top) - if output_format == "json": - rendered = json.dumps(report_to_json(report), indent=2, sort_keys=True) + "\n" - else: - rendered = render_markdown(report) - if args.output: - output_path = args.output - if not output_path.is_absolute(): - output_path = args.repo_root.resolve() / output_path - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(rendered, encoding="utf-8") - else: - print(rendered, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/audit_xcode_plugin_compatibility.py b/scripts/audit_xcode_plugin_compatibility.py deleted file mode 100644 index 0f860992c..000000000 --- a/scripts/audit_xcode_plugin_compatibility.py +++ /dev/null @@ -1,338 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -"""Inventory Socket plugin components and classify Xcode agent compatibility.""" - -from __future__ import annotations - -import argparse -import json -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any, Literal - - -REPO_ROOT = Path(__file__).resolve().parent.parent -Status = Literal["likely", "partial", "blocked", "unknown"] - - -@dataclass(frozen=True) -class TargetAssessment: - status: Status - reason: str - next_check: str - - -@dataclass(frozen=True) -class PluginAssessment: - name: str - source: str - available: bool - manifest: str | None - skills: tuple[str, ...] - mcp_servers: tuple[str, ...] - mcp_risks: tuple[str, ...] - hooks: tuple[str, ...] - app_configs: tuple[str, ...] - custom_agents: tuple[str, ...] - openai_interface_metadata_count: int - xcode_internal_plugin: TargetAssessment - xcode_launched_codex: TargetAssessment - external_agent_xcode_mcp: TargetAssessment - - -def load_json(path: Path) -> dict[str, Any]: - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - raise ValueError(f"{path} must contain a JSON object.") - return value - - -def marketplace_entries(repo_root: Path) -> tuple[dict[str, Any], ...]: - document = load_json(repo_root / ".agents" / "plugins" / "marketplace.json") - plugins = document.get("plugins") - if not isinstance(plugins, list): - raise ValueError(".agents/plugins/marketplace.json must define a plugins array.") - return tuple(entry for entry in plugins if isinstance(entry, dict)) - - -def resolve_source(repo_root: Path, entry: dict[str, Any]) -> tuple[str, Path | None]: - source = entry.get("source") - if isinstance(source, str): - if source.startswith("./"): - return source, repo_root / source[2:] - return source, None - if not isinstance(source, dict): - return "unresolved", None - kind = source.get("source") - if kind == "local" and isinstance(source.get("path"), str): - path = source["path"] - if path.startswith("./"): - return path, repo_root / path[2:] - if kind == "url" and isinstance(source.get("url"), str): - ref = source.get("ref") - suffix = f"@{ref}" if isinstance(ref, str) else "" - return f"{source['url']}{suffix}", None - return f"{kind or 'unresolved'}", None - - -def skill_names(root: Path, manifest: dict[str, Any]) -> tuple[str, ...]: - declared = manifest.get("skills", "./skills/") - if not isinstance(declared, str) or not declared.startswith("./"): - return () - skills_root = root / declared[2:] - if not skills_root.is_dir(): - return () - return tuple( - path.parent.name - for path in sorted(skills_root.glob("*/SKILL.md")) - if path.is_file() - ) - - -def mcp_config_paths(root: Path, manifest: dict[str, Any]) -> tuple[Path, ...]: - declared = manifest.get("mcpServers") - values = declared if isinstance(declared, list) else [declared] - paths = [ - root / value[2:] - for value in values - if isinstance(value, str) and value.startswith("./") - ] - default = root / ".mcp.json" - if not paths and default.is_file(): - paths.append(default) - return tuple(path for path in paths if path.is_file()) - - -def inspect_mcp(root: Path, paths: tuple[Path, ...]) -> tuple[tuple[str, ...], tuple[str, ...]]: - servers: set[str] = set() - risks: set[str] = set() - for path in paths: - document = load_json(path) - mapping = document.get("mcpServers", document) - if not isinstance(mapping, dict): - risks.add(f"{path.relative_to(root)} does not define an MCP server mapping") - continue - for name, value in mapping.items(): - if not isinstance(name, str) or not isinstance(value, dict): - continue - servers.add(name) - serialized = json.dumps(value) - cwd = value.get("cwd") - command = value.get("command") - if isinstance(cwd, str) and (cwd.startswith("../") or "/../" in cwd): - risks.add(f"{name}: parent-relative working directory requires Xcode import proof") - if "/Users/" in serialized or serialized.startswith("~"): - risks.add(f"{name}: machine-local path is not portable") - if "${PLUGIN_ROOT}" in serialized: - risks.add(f"{name}: Codex PLUGIN_ROOT expansion is unverified in Xcode internal agents") - if isinstance(command, str) and not command.startswith("https://"): - risks.add(f"{name}: local command and dependencies require Xcode environment proof") - return tuple(sorted(servers)), tuple(sorted(risks)) - - -def declared_paths( - root: Path, - manifest: dict[str, Any], - field: str, - default: str | None, -) -> tuple[str, ...]: - declared = manifest.get(field) - values = declared if isinstance(declared, list) else [declared] - resolved = [ - value[2:] - for value in values - if isinstance(value, str) and value.startswith("./") and (root / value[2:]).exists() - ] - if not resolved and default is not None and (root / default).exists(): - resolved.append(default) - return tuple(sorted(resolved)) - - -def assess_targets( - *, - available: bool, - resolved: bool, - skills: tuple[str, ...], - mcp: tuple[str, ...], - risks: tuple[str, ...], - hooks: tuple[str, ...], - apps: tuple[str, ...], - agents: tuple[str, ...], -) -> tuple[TargetAssessment, TargetAssessment, TargetAssessment]: - if not available: - blocked = TargetAssessment( - "blocked", - "The Socket marketplace marks this plugin unavailable.", - "Make the plugin installable before testing any Xcode target.", - ) - return blocked, blocked, blocked - if not resolved: - unknown = TargetAssessment( - "unknown", - "The plugin payload is Git-backed outside this checkout, so this source audit cannot inspect its components.", - "Resolve the pinned plugin source and rerun the component inventory before Xcode import testing.", - ) - return unknown, unknown, unknown - - portable = bool(skills) - runtime_components = bool(mcp or hooks or apps or agents) - if not portable and not runtime_components: - blocked = TargetAssessment( - "blocked", - "No Xcode-documented skill, MCP, hook, or subagent component is present.", - "Add a supported component or keep the plugin host-specific by design.", - ) - return blocked, blocked, blocked - - if portable and not runtime_components: - internal = TargetAssessment( - "likely", - "The payload is skill-only, matching Xcode's documented plug-in component model.", - "Import from the public Socket URL and invoke one representative skill in an Xcode internal agent.", - ) - launched = TargetAssessment( - "likely", - "Xcode has previously mirrored imported skill payloads into its Codex-specific home.", - "Confirm the skill appears in Xcode-launched Codex for the current beta build.", - ) - external = TargetAssessment( - "likely", - "External agents can use the portable skills in their own host while Xcode access remains a separate MCP bridge.", - "Confirm the external host sees the skill and Xcode's MCP bridge independently.", - ) - return internal, launched, external - - internal_reason = "Xcode documents these component kinds, but runtime behavior needs current-beta proof." - if risks: - internal_reason += f" The audit found {len(risks)} MCP portability risk(s)." - internal = TargetAssessment( - "partial", - internal_reason, - "Import only a representative plugin and verify each skill, MCP server, hook, and subagent component separately.", - ) - launched = TargetAssessment( - "partial", - "Xcode can mirror imported payloads into its Codex home, but hook trust, app behavior, custom agents, and MCP launch environments are not equivalent by assumption.", - "Inspect Xcode's Codex-specific enabled state, then run one harmless check per component kind.", - ) - external_status: Status = "likely" if portable else "partial" - external = TargetAssessment( - external_status, - "External-agent Xcode MCP access is separate from Xcode plug-in import; portable skills remain useful, while host-specific runtime components stay in the external host.", - "Verify the external host plugin and Xcode MCP connection as two independent dependencies.", - ) - return internal, launched, external - - -def inspect_entry(repo_root: Path, entry: dict[str, Any]) -> PluginAssessment: - name = str(entry.get("name", "(unnamed)")) - source, root = resolve_source(repo_root, entry) - available = ( - entry.get("policy", {}).get("installation") != "NOT_AVAILABLE" - if isinstance(entry.get("policy"), dict) - else True - ) - manifest_path = root / ".codex-plugin" / "plugin.json" if root else None - manifest = load_json(manifest_path) if manifest_path and manifest_path.is_file() else {} - skills = skill_names(root, manifest) if root else () - mcp_paths = mcp_config_paths(root, manifest) if root else () - mcp, risks = inspect_mcp(root, mcp_paths) if root else ((), ()) - hooks = declared_paths(root, manifest, "hooks", "hooks/hooks.json") if root else () - apps = declared_paths(root, manifest, "apps", ".app.json") if root else () - agents = ( - tuple(path.relative_to(root).as_posix() for path in sorted(root.glob(".codex/agents/*.toml"))) - if root - else () - ) - interface_count = len(tuple(root.glob("skills/*/agents/openai.yaml"))) if root else 0 - internal, launched, external = assess_targets( - available=available, - resolved=bool(root and manifest_path and manifest_path.is_file()), - skills=skills, - mcp=mcp, - risks=risks, - hooks=hooks, - apps=apps, - agents=agents, - ) - return PluginAssessment( - name=name, - source=source, - available=available, - manifest=manifest_path.relative_to(repo_root).as_posix() if manifest_path and manifest_path.is_file() else None, - skills=skills, - mcp_servers=mcp, - mcp_risks=risks, - hooks=hooks, - app_configs=apps, - custom_agents=agents, - openai_interface_metadata_count=interface_count, - xcode_internal_plugin=internal, - xcode_launched_codex=launched, - external_agent_xcode_mcp=external, - ) - - -def build_report(repo_root: Path) -> tuple[PluginAssessment, ...]: - return tuple(inspect_entry(repo_root, entry) for entry in marketplace_entries(repo_root)) - - -def render_markdown(report: tuple[PluginAssessment, ...]) -> str: - lines = [ - "# Socket Xcode Plug-in Compatibility Audit", - "", - "This is a source-only, read-only assessment. `likely` and `partial` still require current Xcode runtime proof.", - "", - "| Plugin | Skills | MCP | Hooks | Apps | Agents | Xcode internal | Xcode Codex | External agent |", - "| --- | ---: | ---: | ---: | ---: | ---: | --- | --- | --- |", - ] - for item in report: - lines.append( - "| " - + " | ".join( - [ - f"`{item.name}`", - str(len(item.skills)), - str(len(item.mcp_servers)), - str(len(item.hooks)), - str(len(item.app_configs)), - str(len(item.custom_agents)), - item.xcode_internal_plugin.status, - item.xcode_launched_codex.status, - item.external_agent_xcode_mcp.status, - ] - ) - + " |" - ) - lines.extend(["", "## Runtime-proof queue", ""]) - queued = [item for item in report if item.xcode_internal_plugin.status in {"partial", "unknown"}] - if not queued: - lines.append("No partial or unknown Xcode internal plug-ins.") - for item in queued: - lines.append(f"- `{item.name}`: {item.xcode_internal_plugin.reason} {item.xcode_internal_plugin.next_check}") - lines.extend(f" - {risk}" for risk in item.mcp_risks) - lines.append("") - return "\n".join(lines) - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) - parser.add_argument("--format", choices=("markdown", "json"), default="markdown") - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv) - report = build_report(args.repo_root.resolve()) - if args.format == "json": - print(json.dumps([asdict(item) for item in report], indent=2)) - else: - print(render_markdown(report), end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/cleanup_legacy_socket_installs.py b/scripts/cleanup_legacy_socket_installs.py deleted file mode 100644 index 92c89a84b..000000000 --- a/scripts/cleanup_legacy_socket_installs.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -"""Clean up pre-Git-marketplace socket plugin install artifacts.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import sys -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parent.parent -SOCKET_MARKETPLACE_PATH = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" - - -def socket_plugin_names(path: Path = SOCKET_MARKETPLACE_PATH) -> set[str]: - document = json.loads(path.read_text(encoding="utf-8")) - plugins = document.get("plugins", []) - return { - entry["name"] - for entry in plugins - if isinstance(entry, dict) and isinstance(entry.get("name"), str) - } - - -KNOWN_SOCKET_PLUGINS = socket_plugin_names() - -CANONICAL_MARKETPLACE_NAMES = KNOWN_SOCKET_PLUGINS | {"socket"} - - -@dataclass(frozen=True) -class PlannedAction: - kind: str - target: Path - description: str - - -@dataclass(frozen=True) -class RewriteMarketplace: - path: Path - data: dict[str, Any] | None - removed_names: tuple[str, ...] - - -def load_json(path: Path) -> Any: - try: - return json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return None - except json.JSONDecodeError as exc: - raise SystemExit( - f"Cannot inspect legacy marketplace because JSON is invalid at " - f"{path}:{exc.lineno}:{exc.colno}: {exc.msg}" - ) from exc - - -def plugin_name_from_manifest(plugin_root: Path) -> str | None: - manifest_path = plugin_root / ".codex-plugin" / "plugin.json" - manifest = load_json(manifest_path) - if not isinstance(manifest, dict): - return None - name = manifest.get("name") - if isinstance(name, str): - return name - return None - - -def is_legacy_socket_plugin_dir(path: Path) -> bool: - if not path.is_dir(): - return False - if "cache" in path.parts: - return False - return plugin_name_from_manifest(path) in KNOWN_SOCKET_PLUGINS - - -def is_legacy_socket_marketplace_entry(entry: object) -> bool: - if not isinstance(entry, dict): - return False - name = entry.get("name") - if name not in KNOWN_SOCKET_PLUGINS: - return False - source = entry.get("source") - if isinstance(source, str): - return source.startswith("./") or source.startswith("/") or source.startswith("~") - if not isinstance(source, dict): - return False - if source.get("source") != "local": - return False - path = source.get("path") - return isinstance(path, str) and ( - path.startswith("./") or path.startswith("/") or path.startswith("~") - ) - - -def plan_marketplace_cleanup(personal_marketplace: Path) -> RewriteMarketplace | None: - marketplace = load_json(personal_marketplace) - if marketplace is None: - return None - if not isinstance(marketplace, dict): - raise SystemExit( - f"Cannot inspect legacy marketplace because it is not a JSON object: " - f"{personal_marketplace}" - ) - - plugins = marketplace.get("plugins") - if not isinstance(plugins, list): - return None - - kept: list[object] = [] - removed: list[str] = [] - for entry in plugins: - if is_legacy_socket_marketplace_entry(entry): - name = entry.get("name") if isinstance(entry, dict) else None - removed.append(str(name)) - else: - kept.append(entry) - - if not removed: - return None - - if not kept and marketplace.get("name") == "socket": - return RewriteMarketplace(personal_marketplace, None, tuple(sorted(removed))) - - rewritten = dict(marketplace) - rewritten["plugins"] = kept - return RewriteMarketplace(personal_marketplace, rewritten, tuple(sorted(removed))) - - -def plan_plugin_dir_cleanup(codex_plugins_root: Path) -> list[PlannedAction]: - actions: list[PlannedAction] = [] - for plugin_name in sorted(KNOWN_SOCKET_PLUGINS): - plugin_dir = codex_plugins_root / plugin_name - if is_legacy_socket_plugin_dir(plugin_dir): - actions.append( - PlannedAction( - kind="remove-directory", - target=plugin_dir, - description=( - f"Remove copied legacy plugin payload `{plugin_name}` from " - f"{plugin_dir}" - ), - ) - ) - return actions - - -def stale_config_plugin_tables(config_path: Path) -> list[str]: - if not config_path.is_file(): - return [] - stale_tables: list[str] = [] - for line in config_path.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if not stripped.startswith("[plugins.") or not stripped.endswith("]"): - continue - table_name = stripped.removeprefix("[plugins.").removesuffix("]").strip('"') - if "@" not in table_name: - continue - plugin_name, marketplace_name = table_name.rsplit("@", 1) - if ( - plugin_name in KNOWN_SOCKET_PLUGINS - and marketplace_name not in CANONICAL_MARKETPLACE_NAMES - ): - stale_tables.append(table_name) - return stale_tables - - -def backup_path_for(target: Path, *, home: Path, backup_root: Path) -> Path: - try: - relative = target.resolve().relative_to(home.resolve()) - except ValueError: - relative = Path(target.name) - return backup_root / relative - - -def backup_target(target: Path, *, home: Path, backup_root: Path) -> Path: - destination = backup_path_for(target, home=home, backup_root=backup_root) - destination.parent.mkdir(parents=True, exist_ok=True) - if target.is_dir(): - shutil.copytree(target, destination) - else: - shutil.copy2(target, destination) - return destination - - -def apply_marketplace_rewrite(rewrite: RewriteMarketplace, *, home: Path, backup_root: Path) -> None: - backup_target(rewrite.path, home=home, backup_root=backup_root) - if rewrite.data is None: - rewrite.path.unlink() - return - rewrite.path.write_text(json.dumps(rewrite.data, indent=2) + "\n", encoding="utf-8") - - -def apply_action(action: PlannedAction, *, home: Path, backup_root: Path) -> None: - backup_target(action.target, home=home, backup_root=backup_root) - if action.kind == "remove-directory": - shutil.rmtree(action.target) - return - raise AssertionError(f"Unsupported action kind: {action.kind}") - - -def print_plan( - *, - marketplace_rewrite: RewriteMarketplace | None, - actions: list[PlannedAction], - stale_tables: list[str], - apply: bool, - backup_root: Path, -) -> None: - mode = "Applying" if apply else "Dry run" - print(f"{mode}: legacy socket install cleanup") - - if marketplace_rewrite is None and not actions and not stale_tables: - print("No legacy socket install artifacts were found.") - return - - if marketplace_rewrite is not None: - target = marketplace_rewrite.path - if marketplace_rewrite.data is None: - print(f"- Remove personal marketplace file after backing it up: {target}") - else: - print(f"- Rewrite personal marketplace after backing it up: {target}") - print( - " Removes legacy plugin entries: " - + ", ".join(marketplace_rewrite.removed_names) - ) - - for action in actions: - print(f"- {action.description}") - - if stale_tables: - print("- Stale non-socket plugin enablement entries found in config.toml:") - for table_name in stale_tables: - print(f" - [plugins.\"{table_name}\"]") - print(" These are reported only; this helper does not rewrite config.toml yet.") - - if apply: - print(f"Backups written under: {backup_root}") - else: - print("No files changed. Re-run with --apply to perform this cleanup.") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Remove legacy copied socket plugin payloads and personal marketplace entries " - "after migrating to the Git-backed socket marketplace." - ) - ) - parser.add_argument( - "--home", - type=Path, - default=Path.home(), - help="Home directory to inspect. Defaults to the current user's home.", - ) - parser.add_argument( - "--apply", - action="store_true", - help="Back up and remove the detected legacy artifacts.", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> None: - args = parse_args(sys.argv[1:] if argv is None else argv) - home = args.home.expanduser().resolve() - timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - backup_root = home / ".codex" / "backups" / "socket-legacy-install-cleanup" / timestamp - - personal_marketplace = home / ".agents" / "plugins" / "marketplace.json" - codex_plugins_root = home / ".codex" / "plugins" - config_path = home / ".codex" / "config.toml" - - marketplace_rewrite = plan_marketplace_cleanup(personal_marketplace) - actions = plan_plugin_dir_cleanup(codex_plugins_root) - stale_tables = stale_config_plugin_tables(config_path) - - print_plan( - marketplace_rewrite=marketplace_rewrite, - actions=actions, - stale_tables=stale_tables, - apply=args.apply, - backup_root=backup_root, - ) - - if not args.apply: - return - - if marketplace_rewrite is not None: - apply_marketplace_rewrite(marketplace_rewrite, home=home, backup_root=backup_root) - for action in actions: - apply_action(action, home=home, backup_root=backup_root) - - -if __name__ == "__main__": - main() diff --git a/scripts/export_hermes_skills.py b/scripts/export_hermes_skills.py deleted file mode 100644 index ea51afb9d..000000000 --- a/scripts/export_hermes_skills.py +++ /dev/null @@ -1,343 +0,0 @@ -#!/usr/bin/env python3 -"""Generate Socket's checked-in Hermes skill-tap export from its authored skills.""" - -from __future__ import annotations - -import argparse -import filecmp -import shutil -import tempfile -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -SOURCE_ROOT = REPO_ROOT / "plugins" / "agent-portability-skills" / "skills" -MESSAGING_SOURCE_ROOT = ( - REPO_ROOT / "plugins" / "messaging-collaboration-skills" / "skills" -) -APPLE_SOURCE_ROOT = REPO_ROOT / "plugins" / "apple-dev-skills" / "skills" -CYBERSECURITY_SOURCE_ROOT = REPO_ROOT / "plugins" / "cybersecurity-skills" / "skills" -REVERSE_ENGINEERING_SOURCE_ROOT = ( - REPO_ROOT / "plugins" / "reverse-engineering-skills" / "skills" -) -SERVER_SIDE_SWIFT_SOURCE_ROOT = REPO_ROOT / "plugins" / "server-side-swift" / "skills" -SWIFT_LANG_SOURCE_ROOT = REPO_ROOT / "plugins" / "swift-lang" / "skills" -MODEL_LAB_SOURCE_ROOT = REPO_ROOT / "plugins" / "model-lab-skills" / "skills" -DOTNET_SOURCE_ROOT = REPO_ROOT / "plugins" / "dotnet-skills" / "skills" -AGENT_ENGINEERING_SOURCE_ROOT = REPO_ROOT / "plugins" / "agent-engineering-skills" / "skills" -PYTHON_SOURCE_ROOT = REPO_ROOT / "plugins" / "python-skills" / "skills" -JVM_SOURCE_ROOT = REPO_ROOT / "plugins" / "server-side-jvm" / "skills" -CLOUD_DEPLOYMENT_SOURCE_ROOT = REPO_ROOT / "plugins" / "cloud-deployment-skills" / "skills" -REPOSITORY_SOURCE_ROOT = REPO_ROOT / "plugins" / "repository-skills" / "skills" -EXPORT_ROOT = REPO_ROOT / "skills" -AGENT_PORTABILITY_SKILLS = ( - "bootstrap-skills-plugin-repo", - "build-acp-agent", - "build-hermes-agent-extensions", - "choose-agent-integration-protocol", - "choose-hermes-agent-workflow", - "hermes-agent-compatibility", - "operate-acp-agent-integration", - "operate-a2a-agent-integration", - "operate-hermes-agent", - "operate-hermes-agent-gateway", - "operate-zed-agent", - "sync-skills-repo-guidance", - "use-nous-research-services", -) -MESSAGING_SKILLS = ( - "apple-communication-workflow", - "choose-platform-integration", - "communication-notifications-workflow", - "conversation-state-human-handoff", - "default-communication-app-workflow", - "discord-app-workflow", - "google-meet-collaboration-workflow", - "imessage-app-and-collaboration-workflow", - "push-to-talk-workflow", - "slack-app-workflow", - "sms-mms-rcs-workflow", - "teams-agent-workflow", - "telegram-bot-workflow", - "voip-sip-calling-workflow", - "webhook-and-event-lifecycle", - "whatsapp-business-workflow", -) -APPLE_SKILLS = ( - "app-extension-architecture-workflow", - "diagnose-apple-entitlements", - "choose-macos-virtualization-shape", - "virtualization-framework-workflow", - "linux-development-vm-workflow", - "macos-development-vm-workflow", - "macos-privacy-permissions-workflow", - "macos-sandbox-file-access-workflow", - "mailkit-workflow", - "file-provider-and-finder-sync-workflow", - "safari-mcp-workflow", - "swift-package-extension-workflow", - "tvos-app-experience-workflow", - "tvos-media-playback-workflow", - "bootstrap-xcode-workspace", -) -CYBERSECURITY_SKILLS = ( - "analyze-suspicious-script-or-document", - "assess-and-explain-threat", - "assess-exposure-and-impact", - "assess-macos-threat", - "author-detection-content", - "author-yara-x-rules", - "check-artifact-reputation", - "contain-and-recover-macos", - "contain-security-incident", - "harden-macos", - "hunt-security-indicators", - "inspect-macos-persistence", - "inspect-macos-runtime-activity", - "map-malware-behavior", - "operate-agentic-security-tools", - "perform-dynamic-malware-analysis", - "perform-static-malware-analysis", - "preserve-security-evidence", - "prepare-isolated-analysis-lab", - "recover-security-incident", - "report-security-assessment", - "route-security-work", - "scope-authorized-security-test", - "select-analysis-isolation", - "test-network-services", - "test-web-and-api-security", - "triage-security-incident", - "triage-suspicious-content", - "triage-vulnerability-report", - "use-objective-see-tools", - "validate-vulnerability", -) -SERVER_SIDE_SWIFT_SKILLS = ( - "leaf-rendered-web-workflow", - "soto-aws-workflow", -) -REVERSE_ENGINEERING_SKILLS = ( - "connect-hopper-mcp", - "research-macos-security-control", - "script-hopper-analysis", - "use-ghidra", - "use-hopper", -) -SWIFT_LANG_SKILLS = ( - "choose-swift-language-tooling", - "sourcekit-lsp-workflow", - "swift-compiler-inspection-workflow", - "swift-semantic-indexing-workflow", - "swift-syntax-tooling-workflow", -) -MODEL_LAB_SKILLS = ( - "choose-model-lab-workflow", - "design-model-experiment", - "prepare-language-model-dataset", - "fine-tune-language-model", - "evaluate-language-model", - "compare-model-checkpoints", - "choose-apple-model-runtime", - "research-model-representations", - "steer-language-model-behavior", - "ablate-refusal-representations", - "evaluate-jailbreak-resilience", - "evaluate-tool-calling-model", - "benchmark-model-runtime", -) -DOTNET_SKILLS = ( - "choose-fsharp-web-framework", - "build-giraffe-web-app", - "build-falco-web-app", - "build-oxpecker-web-app", - "build-dotnet-agent-service", -) -AGENT_ENGINEERING_SKILLS = ( - "coordinate-external-agents", - "coordinate-worktrees-and-threads", - "design-n8n-agent-workflow", - "orchestrate-agent-work", -) -PYTHON_SKILLS = ( - "build-python-agent-service", - "fastapi-service-workflow", - "fastmcp-service-workflow", - "python-testing-workflow", -) -JVM_SKILLS = ("build-jvm-agent-service",) -CLOUD_DEPLOYMENT_SKILLS = ( - "cloud-deployment-routing-workflow", - "dockerized-service-release-deployment-workflow", -) -REPOSITORY_SKILLS = ( - "repository-operations-workflow", - "git-workflow", - "github-collaboration-workflow", - "maintain-github-repository", - "maintain-project-agents", - "maintain-project-contributing", - "maintain-project-readme", - "maintain-project-repo", - "maintain-project-roadmap", -) -EXPORTED_SKILLS = ( - AGENT_PORTABILITY_SKILLS - + MESSAGING_SKILLS - + APPLE_SKILLS - + CYBERSECURITY_SKILLS - + SERVER_SIDE_SWIFT_SKILLS - + REVERSE_ENGINEERING_SKILLS - + SWIFT_LANG_SKILLS - + MODEL_LAB_SKILLS - + DOTNET_SKILLS - + AGENT_ENGINEERING_SKILLS - + PYTHON_SKILLS - + JVM_SKILLS - + CLOUD_DEPLOYMENT_SKILLS - + REPOSITORY_SKILLS -) -EXPORT_IGNORED_NAMES = ("tests", "__pycache__") - - -class ExportError(RuntimeError): - """Raised when the Hermes skill export cannot be created or verified.""" - - -def source_paths(source_root: Path | None = None) -> dict[str, Path]: - if source_root is not None: - return {skill_name: source_root / skill_name for skill_name in EXPORTED_SKILLS} - roots = { - **{skill_name: SOURCE_ROOT for skill_name in AGENT_PORTABILITY_SKILLS}, - **{skill_name: MESSAGING_SOURCE_ROOT for skill_name in MESSAGING_SKILLS}, - **{skill_name: APPLE_SOURCE_ROOT for skill_name in APPLE_SKILLS}, - **{ - skill_name: CYBERSECURITY_SOURCE_ROOT for skill_name in CYBERSECURITY_SKILLS - }, - **{ - skill_name: SERVER_SIDE_SWIFT_SOURCE_ROOT - for skill_name in SERVER_SIDE_SWIFT_SKILLS - }, - **{ - skill_name: REVERSE_ENGINEERING_SOURCE_ROOT - for skill_name in REVERSE_ENGINEERING_SKILLS - }, - **{skill_name: SWIFT_LANG_SOURCE_ROOT for skill_name in SWIFT_LANG_SKILLS}, - **{skill_name: MODEL_LAB_SOURCE_ROOT for skill_name in MODEL_LAB_SKILLS}, - **{skill_name: DOTNET_SOURCE_ROOT for skill_name in DOTNET_SKILLS}, - **{ - skill_name: AGENT_ENGINEERING_SOURCE_ROOT - for skill_name in AGENT_ENGINEERING_SKILLS - }, - **{skill_name: PYTHON_SOURCE_ROOT for skill_name in PYTHON_SKILLS}, - **{skill_name: JVM_SOURCE_ROOT for skill_name in JVM_SKILLS}, - **{ - skill_name: CLOUD_DEPLOYMENT_SOURCE_ROOT - for skill_name in CLOUD_DEPLOYMENT_SKILLS - }, - **{skill_name: REPOSITORY_SOURCE_ROOT for skill_name in REPOSITORY_SKILLS}, - } - return { - skill_name: roots[skill_name] / skill_name for skill_name in EXPORTED_SKILLS - } - - -def validate_sources(source_root: Path | None = None) -> None: - sources = source_paths(source_root) - for skill_name in EXPORTED_SKILLS: - skill_path = sources[skill_name] / "SKILL.md" - if not skill_path.is_file(): - raise ExportError( - f"Hermes export source is missing {skill_name}/SKILL.md at {sources[skill_name]}." - ) - - -def write_export( - source_root: Path | None = None, - export_root: Path | None = None, -) -> None: - export_root = EXPORT_ROOT if export_root is None else export_root - validate_sources(source_root) - sources = source_paths(source_root) - with tempfile.TemporaryDirectory( - prefix="socket-hermes-skills.", dir=export_root.parent - ) as temp_dir: - staged_root = Path(temp_dir) / "skills" - staged_root.mkdir() - for skill_name in EXPORTED_SKILLS: - shutil.copytree( - sources[skill_name], - staged_root / skill_name, - ignore=shutil.ignore_patterns(*EXPORT_IGNORED_NAMES, "*.pyc"), - ) - if export_root.exists(): - shutil.rmtree(export_root) - staged_root.replace(export_root) - - -def has_exact_export( - source_root: Path | None = None, - export_root: Path | None = None, -) -> bool: - export_root = EXPORT_ROOT if export_root is None else export_root - if not export_root.is_dir(): - return False - sources = source_paths(source_root) - export_names = {path.name for path in export_root.iterdir()} - if set(sources) != set(EXPORTED_SKILLS) or export_names != set(EXPORTED_SKILLS): - return False - if any(path.name in EXPORT_IGNORED_NAMES for path in export_root.rglob("*")): - return False - for skill_name in EXPORTED_SKILLS: - comparison = filecmp.dircmp( - sources[skill_name], - export_root / skill_name, - ignore=list(EXPORT_IGNORED_NAMES), - ) - if comparison.left_only or comparison.right_only or comparison.funny_files: - return False - for _, mismatches, errors in _walk_comparison(comparison): - if mismatches or errors: - return False - return True - - -def _walk_comparison( - comparison: filecmp.dircmp, -) -> list[tuple[list[str], list[str], list[str]]]: - results = [(comparison.left_only, comparison.diff_files, comparison.funny_files)] - for child in comparison.subdirs.values(): - results.extend(_walk_comparison(child)) - return results - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Generate or verify the checked-in Socket Hermes skill-tap export." - ) - parser.add_argument( - "--check", - action="store_true", - help="Fail if the checked-in export differs from its authored source.", - ) - args = parser.parse_args(argv) - if args.check: - validate_sources() - if not has_exact_export(): - raise ExportError( - "Root skills/ is stale or incomplete. Run `uv run scripts/export_hermes_skills.py` " - "and commit the refreshed export." - ) - print("Hermes skill-tap export matches its authored source.") - return 0 - write_export() - print("Generated the checked-in Hermes skill-tap export at skills/.") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except ExportError as error: - print(f"export-hermes-skills: {error}") - raise SystemExit(1) diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 2923ad6d0..000000000 --- a/scripts/release.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env sh -set -eu - -SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) - -exec uv run python "$SELF_DIR/release_workflow.py" "$@" diff --git a/scripts/release_version.py b/scripts/release_version.py deleted file mode 100755 index f99facf5e..000000000 --- a/scripts/release_version.py +++ /dev/null @@ -1,628 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -from __future__ import annotations - -import json -import os -import re -import subprocess -import tempfile -import tomllib -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -SEMVER_RE = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" - r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" -) -IGNORED_PARTS = {".build", ".git", ".venv", "__pycache__", "node_modules"} -EXCLUDED_VERSION_PATHS = { - Path("plugins/SpeakSwiftlyServer/.codex-plugin/plugin.json"), -} -SUBTREE_GATES: tuple[dict[str, str], ...] = () -DEFAULT_RELEASE_EVIDENCE_PATH = Path(".socket-release-evidence.json") -DEPENDABOT_ALERTS_ENDPOINT = "repos/gaelic-ghost/socket/dependabot/alerts?state=open&per_page=100" - - -class VersionToolError(RuntimeError): - """Raised when the release-version workflow cannot continue safely.""" - - -@dataclass(frozen=True) -class VersionTarget: - kind: str - path: Path - version: str - project_name: str | None = None - - @property - def display_path(self) -> str: - return self.path.as_posix() - - -@dataclass(frozen=True) -class ReleaseEvidence: - commit: str - captured_at: str - marketplace_smoke: dict[str, Any] - dependabot_alerts: tuple[dict[str, str | int], ...] - - -def run_git(root: Path, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: - result = subprocess.run( - ["git", *args], - cwd=root, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if check and result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() - raise VersionToolError(f"`git {' '.join(args)}` failed. {detail}") - return result - - -def run_command( - root: Path, - args: list[str], - check: bool = True, - env: dict[str, str] | None = None, - timeout_seconds: int | None = None, -) -> subprocess.CompletedProcess[str]: - try: - result = subprocess.run( - args, - cwd=root, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - timeout=timeout_seconds, - ) - except subprocess.TimeoutExpired as error: - raise VersionToolError( - f"`{' '.join(args)}` timed out after {timeout_seconds}s." - ) from error - if check and result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() - raise VersionToolError(f"`{' '.join(args)}` failed. {detail}") - return result - - -def run_git_in_path( - path: Path, - args: list[str], - check: bool = True, - timeout_seconds: int | None = None, -) -> subprocess.CompletedProcess[str]: - try: - result = subprocess.run( - ["git", *args], - cwd=path, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=timeout_seconds, - ) - except subprocess.TimeoutExpired as error: - raise VersionToolError( - f"`git {' '.join(args)}` in {path} timed out after {timeout_seconds}s." - ) from error - if check and result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() - raise VersionToolError(f"`git {' '.join(args)}` in {path} failed. {detail}") - return result - - -def validate_semver(version: str) -> str: - if not SEMVER_RE.fullmatch(version): - raise VersionToolError( - f"Expected a semantic version like 1.2.3 for custom bumps, but got {version!r}." - ) - return version - - -def evidence_path(root: Path, requested_path: str | None = None) -> Path: - path = DEFAULT_RELEASE_EVIDENCE_PATH if requested_path is None else Path(requested_path) - return path if path.is_absolute() else root / path - - -def utc_timestamp() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def run_local_marketplace_smoke(root: Path) -> dict[str, Any]: - with tempfile.TemporaryDirectory(prefix="socket-codex-home.") as temp_dir: - codex_home = Path(temp_dir) - command_env = os.environ.copy() - command_env["CODEX_HOME"] = str(codex_home) - added = False - try: - add_result = run_command( - root, - ["codex", "plugin", "marketplace", "add", str(root)], - env=command_env, - ) - added = True - config_path = codex_home / "config.toml" - if not config_path.is_file(): - raise VersionToolError( - "Temporary CODEX_HOME marketplace smoke test did not create config.toml after adding Socket." - ) - config = tomllib.loads(config_path.read_text(encoding="utf-8")) - marketplaces = config.get("marketplaces") - socket_marketplace = marketplaces.get("socket") if isinstance(marketplaces, dict) else None - if not isinstance(socket_marketplace, dict): - raise VersionToolError( - "Temporary CODEX_HOME marketplace smoke test did not register marketplaces.socket." - ) - if socket_marketplace.get("source_type") != "local": - raise VersionToolError( - "Temporary CODEX_HOME marketplace smoke test expected marketplaces.socket.source_type " - f"to be 'local', but found {socket_marketplace.get('source_type')!r}." - ) - if socket_marketplace.get("source") != str(root): - raise VersionToolError( - "Temporary CODEX_HOME marketplace smoke test registered an unexpected Socket source: " - f"{socket_marketplace.get('source')!r}." - ) - finally: - if added: - remove_result = run_command( - root, - ["codex", "plugin", "marketplace", "remove", "socket"], - env=command_env, - ) - config_path = codex_home / "config.toml" - if config_path.is_file() and config_path.read_text(encoding="utf-8").strip(): - raise VersionToolError( - "Temporary CODEX_HOME marketplace smoke test left marketplace configuration behind " - "after removing Socket." - ) - return { - "status": "passed", - "marketplace": "socket", - "source_type": "local", - "add_output": add_result.stdout.strip(), - "remove_output": remove_result.stdout.strip(), - "cleanup": "temporary CODEX_HOME removed with no marketplace configuration left behind", - } - - -def query_open_dependabot_alerts(root: Path) -> tuple[dict[str, str | int], ...]: - result = run_command(root, ["gh", "api", DEPENDABOT_ALERTS_ENDPOINT]) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as error: - raise VersionToolError( - "GitHub Dependabot alert query returned output that was not valid JSON." - ) from error - if not isinstance(payload, list): - raise VersionToolError( - "GitHub Dependabot alert query returned an unexpected response shape; expected a JSON array." - ) - alerts: list[dict[str, str | int]] = [] - for raw_alert in payload: - if not isinstance(raw_alert, dict): - raise VersionToolError( - "GitHub Dependabot alert query returned an alert entry that was not a JSON object." - ) - dependency = raw_alert.get("dependency") - advisory = raw_alert.get("security_advisory") - package = dependency.get("package") if isinstance(dependency, dict) else None - alerts.append( - { - "number": int(raw_alert.get("number", 0)), - "severity": str(advisory.get("severity", "unknown")) - if isinstance(advisory, dict) - else "unknown", - "package": str(package.get("name", "unknown")) - if isinstance(package, dict) - else "unknown", - "manifest_path": str(dependency.get("manifest_path", "unknown")) - if isinstance(dependency, dict) - else "unknown", - } - ) - return tuple(sorted(alerts, key=lambda alert: int(alert["number"]))) - - -def capture_release_evidence(root: Path, output_path: Path) -> ReleaseEvidence: - ensure_clean_checkout(root) - commit = run_git(root, ["rev-parse", "HEAD"]).stdout.strip() - if not commit: - raise VersionToolError("Release evidence capture could not determine the current Git commit.") - evidence = ReleaseEvidence( - commit=commit, - captured_at=utc_timestamp(), - marketplace_smoke=run_local_marketplace_smoke(root), - dependabot_alerts=query_open_dependabot_alerts(root), - ) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text( - json.dumps( - { - "schema_version": 1, - "commit": evidence.commit, - "captured_at": evidence.captured_at, - "marketplace_smoke": evidence.marketplace_smoke, - "dependabot": { - "endpoint": DEPENDABOT_ALERTS_ENDPOINT, - "open_alert_count": len(evidence.dependabot_alerts), - "alerts": list(evidence.dependabot_alerts), - }, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - return evidence - - -def render_evidence_summary(evidence: ReleaseEvidence) -> str: - severity_counts: dict[str, int] = {} - for alert in evidence.dependabot_alerts: - severity = str(alert.get("severity", "unknown")) - severity_counts[severity] = severity_counts.get(severity, 0) + 1 - if severity_counts: - severity_summary = ", ".join( - f"{severity}: {count}" for severity, count in sorted(severity_counts.items()) - ) - else: - severity_summary = "none" - return ( - f"- Passed the temporary `CODEX_HOME` Socket marketplace add/remove smoke test at " - f"commit `{evidence.commit}`.\n" - f"- Queried the GitHub Dependabot alerts API and found " - f"{len(evidence.dependabot_alerts)} open alert(s); severity counts: {severity_summary}.\n" - f"- Captured release evidence at `{evidence.captured_at}`.\n" - ) - - -def bump_version(version: str, mode: str) -> str: - match = SEMVER_RE.fullmatch(version) - if not match: - raise VersionToolError( - f"Cannot calculate a {mode} bump from non-semver version {version!r}." - ) - major, minor, patch = (int(match.group(index)) for index in range(1, 4)) - if mode == "patch": - patch += 1 - elif mode == "minor": - minor += 1 - patch = 0 - elif mode == "major": - major += 1 - minor = 0 - patch = 0 - else: - raise VersionToolError(f"Unsupported bump mode: {mode}") - return f"{major}.{minor}.{patch}" - - -def should_ignore(path: Path) -> bool: - return path in EXCLUDED_VERSION_PATHS or any(part in IGNORED_PARTS for part in path.parts) - - -def discover_pyproject_targets(root: Path) -> list[VersionTarget]: - targets: list[VersionTarget] = [] - candidate_paths = [root / "pyproject.toml"] - candidate_paths.extend(sorted((root / "plugins").glob("*/pyproject.toml"))) - candidate_paths.extend(sorted((root / "plugins").glob("*/mcp/pyproject.toml"))) - for path in candidate_paths: - rel_path = path.relative_to(root) - if should_ignore(rel_path) or not path.is_file(): - continue - data = tomllib.loads(path.read_text(encoding="utf-8")) - project = data.get("project") - if not isinstance(project, dict): - continue - version = project.get("version") - name = project.get("name") - if not isinstance(version, str) or not isinstance(name, str): - continue - targets.append( - VersionTarget( - kind="pyproject", - path=rel_path, - version=version, - project_name=name, - ) - ) - return targets - - -def discover_plugin_targets(root: Path) -> list[VersionTarget]: - targets: list[VersionTarget] = [] - for path in sorted((root / "plugins").glob("*/.codex-plugin/plugin.json")): - rel_path = path.relative_to(root) - if should_ignore(rel_path) or not path.is_file(): - continue - data = json.loads(path.read_text(encoding="utf-8")) - version = data.get("version") - if not isinstance(version, str): - continue - targets.append(VersionTarget(kind="plugin", path=rel_path, version=version)) - return targets - - -def discover_targets(root: Path) -> list[VersionTarget]: - targets = discover_pyproject_targets(root) + discover_plugin_targets(root) - return sorted(targets, key=lambda target: target.display_path) - - -def read_versions(targets: list[VersionTarget]) -> list[str]: - return sorted({target.version for target in targets}) - - -def determine_target_version(targets: list[VersionTarget], mode: str, custom_version: str | None) -> str: - current_versions = read_versions(targets) - if mode == "custom": - if custom_version is None: - raise VersionToolError("Custom mode requires an explicit semantic version.") - return validate_semver(custom_version) - if len(current_versions) != 1: - joined_versions = ", ".join(current_versions) - raise VersionToolError( - "Patch, minor, and major bumps require every maintained version surface to " - f"already share one version. Current versions: {joined_versions}. " - "Choose one explicit target version and run `scripts/release.sh prepare X.Y.Z`." - ) - return bump_version(current_versions[0], mode) - - -def replace_project_version(text: str, desired_version: str) -> str: - lines = text.splitlines(keepends=True) - in_project = False - for index, line in enumerate(lines): - stripped = line.strip() - if stripped == "[project]": - in_project = True - continue - if in_project and stripped.startswith("[") and stripped != "[project]": - break - if in_project and re.match(r'^version\s*=\s*"[^"]+"\s*$', stripped): - newline = "\n" if line.endswith("\n") else "" - prefix = line[: len(line) - len(line.lstrip())] - lines[index] = f'{prefix}version = "{desired_version}"{newline}' - return "".join(lines) - raise VersionToolError("Could not find [project].version in pyproject.toml.") - - -def update_pyproject(path: Path, desired_version: str) -> bool: - original_text = path.read_text(encoding="utf-8") - updated_text = replace_project_version(original_text, desired_version) - if updated_text == original_text: - return False - path.write_text(updated_text, encoding="utf-8") - return True - - -def update_plugin_manifest(path: Path, desired_version: str) -> bool: - data = json.loads(path.read_text(encoding="utf-8")) - if data.get("version") == desired_version: - return False - data["version"] = desired_version - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return True - - -def update_uv_lock(path: Path, project_name: str, desired_version: str) -> bool: - if not path.is_file(): - return False - lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - for index in range(len(lines) - 1): - if lines[index].strip() == f'name = "{project_name}"' and lines[index + 1].lstrip().startswith("version = "): - current_line = lines[index + 1] - newline = "\n" if current_line.endswith("\n") else "" - replacement = f'version = "{desired_version}"' - if current_line.strip() == replacement: - return False - prefix = current_line[: len(current_line) - len(current_line.lstrip())] - lines[index + 1] = f"{prefix}{replacement}{newline}" - path.write_text("".join(lines), encoding="utf-8") - return True - raise VersionToolError( - f"Expected to find package entry for {project_name!r} in {path.as_posix()}, but it was missing." - ) - - -def apply_version(root: Path, targets: list[VersionTarget], desired_version: str) -> tuple[list[str], list[str]]: - changed_files: list[str] = [] - unchanged_files: list[str] = [] - for target in targets: - full_path = root / target.path - if target.kind == "pyproject": - changed = update_pyproject(full_path, desired_version) - lock_path = full_path.with_name("uv.lock") - lock_changed = False - if target.project_name is None: - raise VersionToolError(f"Pyproject target {target.display_path} is missing its project name.") - if lock_path.is_file(): - lock_changed = update_uv_lock(lock_path, target.project_name, desired_version) - if changed: - changed_files.append(target.display_path) - else: - unchanged_files.append(target.display_path) - if lock_changed: - changed_files.append(lock_path.relative_to(root).as_posix()) - elif lock_path.is_file(): - unchanged_files.append(lock_path.relative_to(root).as_posix()) - elif target.kind == "plugin": - changed = update_plugin_manifest(full_path, desired_version) - if changed: - changed_files.append(target.display_path) - else: - unchanged_files.append(target.display_path) - else: - raise VersionToolError(f"Unsupported target kind {target.kind!r}.") - return changed_files, unchanged_files - - -def render_inventory(targets: list[VersionTarget]) -> int: - versions = read_versions(targets) - print("Maintained version targets:") - for target in targets: - print(f"- {target.display_path}: {target.version} ({target.kind})") - if len(versions) == 1: - print(f"\nShared version: {versions[0]}") - else: - print(f"\nVersion sets: {', '.join(versions)}") - print("Patch/minor/major bumps are blocked until these surfaces are aligned.") - return 0 - - -def previous_release_ref(root: Path) -> str | None: - result = run_git(root, ["describe", "--tags", "--abbrev=0", "HEAD^"], check=False) - if result.returncode != 0: - return None - ref = result.stdout.strip() - return ref or None - - -def changed_files_since_previous_release(root: Path) -> set[str]: - previous_ref = previous_release_ref(root) - diff_args = ["diff", "--name-only", "HEAD"] if previous_ref is None else ["diff", "--name-only", f"{previous_ref}..HEAD"] - result = run_git(root, diff_args) - return {line.strip() for line in result.stdout.splitlines() if line.strip()} - - -def ensure_clean_checkout(root: Path) -> None: - result = run_git(root, ["status", "--porcelain"]) - if result.stdout.strip(): - raise VersionToolError( - "Release evidence and reviewed-main gates require a clean checkout. " - "Commit or stash local changes before continuing." - ) - - -def ensure_main_matches_origin(root: Path) -> None: - branch = run_git(root, ["branch", "--show-current"]).stdout.strip() - if branch != "main": - raise VersionToolError(f"Reviewed-main verification must run on local main, but the current branch is {branch!r}.") - head = run_git(root, ["rev-parse", "HEAD"]).stdout.strip() - origin_main = run_git(root, ["rev-parse", "origin/main"]).stdout.strip() - if head != origin_main: - raise VersionToolError( - "Reviewed-main verification requires local main to match origin/main before tagging. " - "Push or fast-forward main first." - ) - - -def ensure_versions_match_release(targets: list[VersionTarget], version: str) -> None: - versions = read_versions(targets) - if versions != [version]: - joined_versions = ", ".join(versions) - raise VersionToolError( - f"Reviewed-main verification expected every maintained version surface to be {version}, " - f"but found: {joined_versions}." - ) - - -def version_only_paths(targets: list[VersionTarget]) -> set[str]: - paths: set[str] = set() - for target in targets: - paths.add(target.display_path) - if target.kind == "pyproject": - paths.add(target.path.with_name("uv.lock").as_posix()) - return paths - - -def ensure_subtree_gates(root: Path, changed_files: set[str], version_paths: set[str]) -> list[str]: - accounted: list[str] = [] - for gate in SUBTREE_GATES: - prefix = gate["prefix"] - touched_paths = sorted(path for path in changed_files if path == prefix or path.startswith(f"{prefix}/")) - if not touched_paths: - accounted.append(f"{gate['name']}: untouched") - continue - substantive_paths = [path for path in touched_paths if path not in version_paths] - if not substantive_paths: - accounted.append(f"{gate['name']}: version-only changes; no subtree push required") - continue - split = run_git(root, ["subtree", "split", f"--prefix={prefix}", "HEAD"]).stdout.strip().splitlines()[-1] - remote_ref = f"refs/heads/{gate['branch']}" - remote = run_git(root, ["ls-remote", gate["remote"], remote_ref]).stdout.strip() - remote_head = remote.split()[0] if remote else "" - if split != remote_head: - raise VersionToolError( - f"{gate['name']} changed in this release, but {gate['remote']}/{gate['branch']} " - "does not match the current subtree split. Run " - f"`git subtree push --prefix={prefix} {gate['remote']} {gate['branch']}` before tagging or " - "creating the GitHub release." - ) - accounted.append(f"{gate['name']}: pushed to {gate['remote']}/{gate['branch']}") - return accounted - - -def codex_home() -> Path: - return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).expanduser() - - -def configured_socket_marketplace() -> tuple[Path, str]: - home = codex_home() - config_path = home / "config.toml" - if not config_path.is_file(): - raise VersionToolError( - f"Codex config not found at {config_path}; cannot recover Socket marketplace cache." - ) - config = tomllib.loads(config_path.read_text(encoding="utf-8")) - marketplaces = config.get("marketplaces") - socket_marketplace = marketplaces.get("socket") if isinstance(marketplaces, dict) else None - if not isinstance(socket_marketplace, dict): - raise VersionToolError( - "Codex config does not contain marketplaces.socket; cannot recover Socket marketplace cache." - ) - if socket_marketplace.get("source_type") != "git": - raise VersionToolError( - "Codex marketplaces.socket is not a Git-backed marketplace; " - f"found source_type={socket_marketplace.get('source_type')!r}." - ) - source = socket_marketplace.get("source") - if not isinstance(source, str) or not source: - raise VersionToolError("Codex marketplaces.socket does not have a non-empty Git source.") - return home / ".tmp" / "marketplaces" / "socket", source - - -def refresh_socket_marketplace_cache(root: Path) -> None: - command = ["codex", "plugin", "marketplace", "upgrade", "socket"] - result = run_command(root, command, check=False, timeout_seconds=45) - if result.returncode == 0: - if result.stdout.strip(): - print(result.stdout.strip()) - return - - detail = result.stderr.strip() or result.stdout.strip() - known_timeout = "timed out after 30s" in detail and "fatal: early EOF" in detail - if not known_timeout: - raise VersionToolError(f"`{' '.join(command)}` failed. {detail}") - - print( - "Codex marketplace upgrade hit the known 30s clone timeout; " - "fast-forwarding the existing Socket marketplace cache instead." - ) - cache_root, configured_source = configured_socket_marketplace() - if not (cache_root / ".git").is_dir(): - raise VersionToolError( - f"Socket marketplace cache is missing at {cache_root}; cannot fast-forward fallback." - ) - remote_url = run_git_in_path(cache_root, ["remote", "get-url", "origin"]).stdout.strip() - if remote_url != configured_source: - raise VersionToolError( - "Socket marketplace cache origin does not match the configured marketplace source: " - f"{remote_url!r} != {configured_source!r}." - ) - run_git_in_path(cache_root, ["fetch", "origin", "main"], timeout_seconds=45) - run_git_in_path(cache_root, ["merge", "--ff-only", "origin/main"], timeout_seconds=45) - head = run_git_in_path(cache_root, ["rev-parse", "HEAD"]).stdout.strip() - print(f"Socket marketplace cache fast-forwarded to {head}.") diff --git a/scripts/release_workflow.py b/scripts/release_workflow.py deleted file mode 100644 index 1acb1f39d..000000000 --- a/scripts/release_workflow.py +++ /dev/null @@ -1,528 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -import tempfile -from dataclasses import dataclass -from pathlib import Path - -import release_version - - -ROOT = Path(__file__).resolve().parent.parent -ACCOUNTING_STATUSES = {"preserved", "in-progress", "archived", "merged", "safe-to-delete"} -REQUIRED_CHECKS = {"validate"} - - -class ReleaseWorkflowError(RuntimeError): - pass - - -@dataclass(frozen=True) -class PullRequestSnapshot: - number: int - url: str - state: str - head_ref: str - head_sha: str - review_decision: str - comments: int - checks: tuple[tuple[str, str], ...] - - @property - def phase(self) -> str: - buckets = {bucket for _, bucket in self.checks} - if self.state == "MERGED": - return "merged" - if self.state != "OPEN": - return "closed" - if not self.checks: - return "awaiting-github-state" - check_names = {name for name, _ in self.checks} - if not REQUIRED_CHECKS <= check_names: - return "awaiting-required-checks" - if buckets & {"fail", "cancel"}: - return "failed-checks" - if "pending" in buckets: - return "awaiting-pr-checks" - if self.review_decision == "CHANGES_REQUESTED": - return "changes-requested" - if self.comments: - return "comments-require-review" - return "ready-to-advance" - - -def run(args: list[str], *, cwd: Path = ROOT, check: bool = True) -> subprocess.CompletedProcess[str]: - result = subprocess.run( - args, cwd=cwd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - if check and result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() - raise ReleaseWorkflowError(f"`{' '.join(args)}` failed in {cwd}: {detail}") - return result - - -def git(args: list[str], *, cwd: Path = ROOT, check: bool = True) -> subprocess.CompletedProcess[str]: - return run(["git", *args], cwd=cwd, check=check) - - -def gh(args: list[str], *, cwd: Path = ROOT, check: bool = True) -> subprocess.CompletedProcess[str]: - return run(["gh", *args], cwd=cwd, check=check) - - -def normalized_version(value: str) -> str: - try: - return release_version.validate_semver(value.removeprefix("v")) - except release_version.VersionToolError as error: - raise ReleaseWorkflowError(str(error)) from error - - -def release_tag(version: str) -> str: - return f"v{version}" - - -def current_branch(cwd: Path = ROOT) -> str: - return git(["branch", "--show-current"], cwd=cwd).stdout.strip() - - -def ensure_clean(cwd: Path = ROOT) -> None: - if git(["status", "--porcelain"], cwd=cwd).stdout.strip(): - raise ReleaseWorkflowError(f"Release workflow requires a clean worktree: {cwd}") - - -def ensure_feature_branch() -> str: - branch = current_branch() - if not branch or branch == "main": - raise ReleaseWorkflowError( - "Release prepare/inspect/advance must run from a named feature worktree, not main." - ) - return branch - - -def release_notes_path(version: str, root: Path = ROOT) -> Path: - path = root / "docs" / "releases" / f"v{version}.md" - if not path.is_file(): - raise ReleaseWorkflowError( - f"Release notes are required at {path.relative_to(root)} before release preparation." - ) - return path - - -def find_main_worktree() -> Path: - output = git(["worktree", "list", "--porcelain"]).stdout - worktree: Path | None = None - for line in output.splitlines(): - if line.startswith("worktree "): - worktree = Path(line.removeprefix("worktree ")) - elif line == "branch refs/heads/main" and worktree is not None: - return worktree - raise ReleaseWorkflowError( - "No worktree owns local main. Restore the clean Socket main checkout before advancing." - ) - - -def remote_main_sha() -> str: - output = git(["ls-remote", "origin", "refs/heads/main"]).stdout.strip() - if not output: - raise ReleaseWorkflowError("origin/main is not readable.") - return output.split()[0] - - -def ensure_unpublished(version: str) -> None: - tag = release_tag(version) - if git(["tag", "-l", tag]).stdout.strip(): - raise ReleaseWorkflowError(f"Release tag {tag} already exists locally.") - if git(["ls-remote", "origin", f"refs/tags/{tag}"]).stdout.strip(): - raise ReleaseWorkflowError(f"Release tag {tag} already exists on origin.") - - -def parse_accounting(values: list[str]) -> dict[str, str]: - accounting: dict[str, str] = {} - for value in values: - branch, separator, status = value.partition("=") - if not separator or not branch or status not in ACCOUNTING_STATUSES: - allowed = ", ".join(sorted(ACCOUNTING_STATUSES)) - raise ReleaseWorkflowError( - f"Invalid branch accounting {value!r}; use BRANCH=STATUS where STATUS is one of: {allowed}." - ) - accounting[branch] = status - return accounting - - -def branch_accounting(main_root: Path, supplied: dict[str, str]) -> dict[str, str]: - lines = git( - ["branch", "--no-merged", "main", "--format=%(refname:short)"], cwd=main_root - ).stdout.splitlines() - branches = sorted(line.strip() for line in lines if line.strip() and line.strip() != "main") - missing = [branch for branch in branches if branch not in supplied] - unknown = sorted(set(supplied) - set(branches)) - if missing: - examples = " ".join( - f"--branch-accounting {branch}=in-progress" for branch in missing - ) - raise ReleaseWorkflowError( - "Branch accounting is incomplete for: " - + ", ".join(missing) - + f". Re-run with explicit classifications, for example: {examples}" - ) - if unknown: - raise ReleaseWorkflowError( - "Branch accounting named branches that are already contained by main or absent locally: " - + ", ".join(unknown) - ) - return {branch: supplied[branch] for branch in branches} - - -def pr_number_for_branch(branch: str) -> int | None: - result = gh( - [ - "pr", "list", "--state", "all", "--head", branch, "--base", "main", - "--limit", "1", "--json", "number", - ] - ) - values = json.loads(result.stdout) - return int(values[0]["number"]) if values else None - - -def snapshot_pr(number: int) -> PullRequestSnapshot: - data = json.loads( - gh( - [ - "pr", "view", str(number), "--json", - "number,url,state,headRefName,headRefOid,reviewDecision,comments,reviews", - ] - ).stdout - ) - checks_result = gh(["pr", "checks", str(number), "--json", "name,bucket"], check=False) - checks: list[tuple[str, str]] = [] - if checks_result.stdout.strip(): - checks = [ - (str(item["name"]), str(item["bucket"])) - for item in json.loads(checks_result.stdout) - ] - comments = len(data.get("comments") or []) + len( - [review for review in data.get("reviews") or [] if review.get("state") == "COMMENTED"] - ) - return PullRequestSnapshot( - number=int(data["number"]), - url=str(data["url"]), - state=str(data["state"]), - head_ref=str(data["headRefName"]), - head_sha=str(data["headRefOid"]), - review_decision=str(data.get("reviewDecision") or ""), - comments=comments, - checks=tuple(checks), - ) - - -def continuation_packet(snapshot: PullRequestSnapshot, version: str) -> str: - return json.dumps( - { - "schema": "socket-release-continuation/v1", - "operation": "socket-release", - "repository": "gaelic-ghost/socket", - "release_tag": release_tag(version), - "branch": snapshot.head_ref, - "head_commit": snapshot.head_sha, - "pr_number": snapshot.number, - "phase": snapshot.phase, - "minimum_delay_minutes": 5, - "resume_command": f"scripts/release.sh inspect {version}", - "advance_command": f"scripts/release.sh advance {version}", - }, - sort_keys=True, - ) - - -def print_snapshot(snapshot: PullRequestSnapshot, version: str) -> None: - checks = ", ".join(f"{name}:{bucket}" for name, bucket in snapshot.checks) or "none" - print( - f"PR #{snapshot.number}: phase={snapshot.phase}; checks={checks}; " - f"review={snapshot.review_decision or 'none'}; comments={snapshot.comments}; {snapshot.url}" - ) - if snapshot.phase not in {"ready-to-advance", "merged"}: - print(continuation_packet(snapshot, version)) - - -def ensure_version_matches(version: str) -> list[release_version.VersionTarget]: - targets = release_version.discover_targets(ROOT) - try: - release_version.ensure_versions_match_release(targets, version) - except release_version.VersionToolError as error: - raise ReleaseWorkflowError(str(error)) from error - return targets - - -def ensure_next_stable_version( - targets: list[release_version.VersionTarget], version: str -) -> None: - allowed = { - release_version.determine_target_version(targets, mode, None) - for mode in ("patch", "minor", "major") - } - if version not in allowed: - rendered = ", ".join(sorted(allowed)) - raise ReleaseWorkflowError( - f"Requested version {version} is not the next patch, minor, or major release. " - f"Choose one of: {rendered}." - ) - - -def create_or_update_pr(branch: str, version: str) -> int: - title = f"release: prepare Socket {release_tag(version)}" - body = ( - "## Summary\n\n" - f"- prepares Socket {release_tag(version)} through the single protected-main release workflow\n" - "- includes the canonical Swift workspace, native local-server, Soto, and GitHub-only deployment changes\n" - "- consolidates Socket release automation and guidance\n\n" - "## Verification\n\n" - "- `uv run scripts/validate_socket.py --profile full`\n" - f"- `scripts/release.sh prepare {version}`\n" - ) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md") as body_file: - body_file.write(body) - body_file.flush() - number = pr_number_for_branch(branch) - if number is None: - gh( - [ - "pr", "create", "--base", "main", "--head", branch, - "--title", title, "--body-file", body_file.name, - "--label", "needs-triage", - ] - ) - number = pr_number_for_branch(branch) - if number is None: - raise ReleaseWorkflowError("GitHub did not return the release PR after creation.") - else: - print(f"Keeping the existing release PR #{number} body and reviewer-added content.") - return number - - -def run_full_validation(root: Path = ROOT) -> None: - result = subprocess.run( - ["uv", "run", "scripts/validate_socket.py", "--profile", "full"], - cwd=root, - check=False, - ) - if result.returncode != 0: - raise ReleaseWorkflowError("Full Socket validation failed.") - - -def prepare(version: str) -> int: - branch = ensure_feature_branch() - ensure_clean() - ensure_unpublished(version) - release_notes_path(version) - targets = release_version.discover_targets(ROOT) - current_versions = release_version.read_versions(targets) - if current_versions != [version]: - ensure_next_stable_version(targets, version) - try: - changed, _ = release_version.apply_version(ROOT, targets, version) - except release_version.VersionToolError as error: - raise ReleaseWorkflowError(str(error)) from error - if not changed: - commit = git( - [ - "log", "-1", "--format=%H", "--fixed-strings", "--grep", - f"release: prepare Socket {release_tag(version)}", - ] - ).stdout.strip() - if not commit: - raise ReleaseWorkflowError( - f"Version surfaces already equal {version}, but the branch has no matching release-preparation commit." - ) - else: - git(["add", *changed]) - git(["commit", "-m", f"release: prepare Socket {release_tag(version)}"]) - run_full_validation() - ensure_clean() - git(["push", "-u", "origin", branch]) - remote_output = git(["ls-remote", "origin", f"refs/heads/{branch}"]).stdout.split() - remote_sha = remote_output[0] if remote_output else "" - head_sha = git(["rev-parse", "HEAD"]).stdout.strip() - if remote_sha != head_sha: - raise ReleaseWorkflowError("The release branch is not visible at the expected commit on origin.") - number = create_or_update_pr(branch, version) - print_snapshot(snapshot_pr(number), version) - return 0 - - -def inspect(version: str) -> int: - branch = ensure_feature_branch() - ensure_clean() - ensure_version_matches(version) - number = pr_number_for_branch(branch) - if number is None: - raise ReleaseWorkflowError( - f"No release PR exists for {branch}; run `scripts/release.sh prepare {version}` first." - ) - print_snapshot(snapshot_pr(number), version) - return 0 - - -def append_release_evidence( - notes: str, - evidence: release_version.ReleaseEvidence, - accounting: list[str], -) -> str: - lines = "\n".join(f"- {line}" for line in accounting) or "- No child synchronization was required." - return ( - notes.rstrip() - + "\n\n## Release evidence\n\n" - + release_version.render_evidence_summary(evidence) - + "\n## Child synchronization accounting\n\n" - + lines - + "\n" - ) - - -def advance(version: str, accounting_values: list[str], review_comments_addressed: bool) -> int: - branch = ensure_feature_branch() - ensure_clean() - ensure_version_matches(version) - number = pr_number_for_branch(branch) - if number is None: - raise ReleaseWorkflowError(f"No release PR exists for {branch}.") - snapshot = snapshot_pr(number) - if snapshot.phase not in {"ready-to-advance", "merged"}: - print_snapshot(snapshot, version) - if snapshot.phase != "comments-require-review" or not review_comments_addressed: - raise ReleaseWorkflowError( - f"Release PR #{number} is not ready to advance: {snapshot.phase}." - ) - current_head = git(["rev-parse", "HEAD"]).stdout.strip() - if snapshot.head_ref != branch or snapshot.head_sha != current_head: - raise ReleaseWorkflowError( - "Release PR branch or commit identity changed; inspect and reconcile before advancing." - ) - if snapshot.state != "MERGED": - gh(["pr", "merge", str(number), "--auto", "--merge", "--delete-branch"]) - merged = snapshot_pr(number) - if merged.state != "MERGED": - print_snapshot(merged, version) - return 0 - - main_root = find_main_worktree() - ensure_clean(main_root) - git(["fetch", "origin", "main", "--prune"], cwd=main_root) - git(["pull", "--ff-only", "origin", "main"], cwd=main_root) - main_head = git(["rev-parse", "HEAD"], cwd=main_root).stdout.strip() - if main_head != remote_main_sha(): - raise ReleaseWorkflowError( - "Local main does not match the current origin/main after fast-forward." - ) - targets = release_version.discover_targets(main_root) - release_version.ensure_versions_match_release(targets, version) - notes_path = release_notes_path(version, main_root) - supplied = parse_accounting(accounting_values) - accounted_branches = branch_accounting(main_root, supplied) - changed_files = release_version.changed_files_since_previous_release(main_root) - child_accounting = release_version.ensure_subtree_gates( - main_root, - changed_files, - release_version.version_only_paths(targets), - ) - - run_full_validation(main_root) - release_version.ensure_clean_checkout(main_root) - release_version.ensure_main_matches_origin(main_root) - evidence = release_version.capture_release_evidence( - main_root, release_version.evidence_path(main_root) - ) - tag = release_tag(version) - local_tag = git(["tag", "-l", tag], cwd=main_root).stdout.strip() - remote_tag_result = git( - ["ls-remote", "origin", f"refs/tags/{tag}^{{}}"], cwd=main_root - ).stdout.strip() - if local_tag: - local_tag_commit = git(["rev-list", "-n", "1", tag], cwd=main_root).stdout.strip() - if local_tag_commit != main_head: - raise ReleaseWorkflowError(f"Local tag {tag} does not point at reviewed main {main_head}.") - if remote_tag_result and remote_tag_result.split()[0] != main_head: - raise ReleaseWorkflowError(f"Remote tag {tag} does not point at reviewed main {main_head}.") - if not local_tag and not remote_tag_result: - git(["tag", "-a", tag, "-m", f"Socket {tag}"], cwd=main_root) - local_tag = tag - if local_tag and not remote_tag_result: - git(["push", "origin", tag], cwd=main_root) - remote_tag = git( - ["ls-remote", "origin", f"refs/tags/{tag}^{{}}"], cwd=main_root - ).stdout.strip() - if not remote_tag or remote_tag.split()[0] != main_head: - raise ReleaseWorkflowError( - f"Annotated tag {tag} is not visible on origin at reviewed main." - ) - - if gh(["release", "view", tag], cwd=main_root, check=False).returncode != 0: - notes = append_release_evidence( - notes_path.read_text(encoding="utf-8"), evidence, child_accounting - ) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md") as notes_file: - notes_file.write(notes) - notes_file.flush() - gh( - [ - "release", "create", tag, "--verify-tag", "--title", - f"Socket {tag}", "--notes-file", notes_file.name, - ], - cwd=main_root, - ) - release_data = json.loads( - gh( - ["release", "view", tag, "--json", "tagName,isPrerelease,url"], - cwd=main_root, - ).stdout - ) - if release_data["tagName"] != tag or release_data["isPrerelease"]: - raise ReleaseWorkflowError( - f"GitHub release metadata for {tag} does not match the stable major release." - ) - print("Branch accounting:") - if accounted_branches: - for accounted_branch, status in accounted_branches.items(): - print(f"- {accounted_branch}: {status}") - else: - print("- No local branches remain outside main.") - release_version.refresh_socket_marketplace_cache(main_root) - print(f"Socket {tag} released from {main_head}: {release_data['url']}") - return 0 - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Run Socket's single protected-main release workflow." - ) - subparsers = parser.add_subparsers(dest="operation", required=True) - subparsers.add_parser("inventory", help="List maintained Socket version surfaces.") - for operation in ("prepare", "inspect"): - child = subparsers.add_parser(operation) - child.add_argument("version") - advance_parser = subparsers.add_parser("advance") - advance_parser.add_argument("version") - advance_parser.add_argument("--branch-accounting", action="append", default=[]) - advance_parser.add_argument("--review-comments-addressed", action="store_true") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - args = parse_args(argv) - if args.operation == "inventory": - return release_version.render_inventory(release_version.discover_targets(ROOT)) - version = normalized_version(args.version) - if args.operation == "prepare": - return prepare(version) - if args.operation == "inspect": - return inspect(version) - return advance(version, args.branch_accounting, args.review_comments_addressed) - - -if __name__ == "__main__": - try: - raise SystemExit(main(sys.argv[1:])) - except (ReleaseWorkflowError, release_version.VersionToolError) as error: - print(f"socket-release: {error}", file=sys.stderr) - raise SystemExit(1) diff --git a/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md b/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md index 0ad49ca90..b32b63051 100644 --- a/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md +++ b/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md @@ -46,11 +46,9 @@ Describe the terminology, casing, and naming patterns contributors should match ### Accessibility Expectations -Contributors must keep changes aligned with the project's accessibility contract in [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). - -If a change affects UI semantics, input behavior, focus flow, labels, announcements, motion, contrast, zoom behavior, content structure, or assistive-technology compatibility, verify the affected surface against the documented accessibility standards before asking for review. - -If a change introduces a new accessibility limitation, exception, or remediation plan, update `ACCESSIBILITY.md` in the same pass unless maintainers have explicitly agreed on a different tracking path. +Keep commands, logs, headings, links, errors, and user-facing behavior readable +and actionable. Record product-specific accessibility requirements beside the +surface that owns them; do not create a separate root accessibility contract. ### Verification diff --git a/scripts/repo-maintenance/syncing/10-managed-repository-assets.fsx b/scripts/repo-maintenance/syncing/10-managed-repository-assets.fsx new file mode 100644 index 000000000..2d478d05e --- /dev/null +++ b/scripts/repo-maintenance/syncing/10-managed-repository-assets.fsx @@ -0,0 +1,23 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let installer = Path.Combine(root, "plugins", "repository-skills", "skills", "maintain-project-repo", "scripts", "maintain-project-repo.fsx") +use profileDocument = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "scripts", "repo-maintenance", "config", "profile.json"))) +let profile = profileDocument.RootElement.GetProperty("profile").GetString() +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; installer; "--repo-root"; root; "--operation"; "refresh"; "--profile"; profile ] do info.ArgumentList.Add(argument) +use child = Process.Start info +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Managed repository asset refresh failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "Refreshed managed repository assets from repository-skills 10.0.2." diff --git a/scripts/repo-maintenance/syncing/30-apple-workflow-runtime.fsx b/scripts/repo-maintenance/syncing/30-apple-workflow-runtime.fsx new file mode 100644 index 000000000..1e772edb3 --- /dev/null +++ b/scripts/repo-maintenance/syncing/30-apple-workflow-runtime.fsx @@ -0,0 +1,14 @@ +#!/usr/bin/env -S dotnet fsi + +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let source = Path.Combine(root, "plugins", "apple-dev-skills", "shared", "workflow-planner.fsx") +let skills = + [ "author-swift-docc-docs"; "structure-swift-sources"; "swift-package-build-run-workflow" + "swift-package-testing-workflow"; "xcode-build-run-workflow"; "xcode-testing-workflow" ] +for skill in skills do + let target = Path.Combine(root, "plugins", "apple-dev-skills", "skills", skill, "scripts", "run-workflow.fsx") + Directory.CreateDirectory(Path.GetDirectoryName target) |> ignore + File.Copy(source, target, true) +printfn "Synchronized the managed Apple workflow planner to %d skills." skills.Length diff --git a/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx index 4c893bf0a..92c6619d8 100644 --- a/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx +++ b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx @@ -4,7 +4,7 @@ open System open System.IO let repositoryRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) -let pluginRoot = Path.Combine(repositoryRoot, "plugins", "repository-skills") +let pluginsRoot = Path.Combine(repositoryRoot, "plugins") let copyTree (source: string) (target: string) = if not (Directory.Exists(source)) then failwith $"Repository-skills source is missing: {source}" @@ -32,20 +32,24 @@ let replaceTree (source: string) (target: string) = if not (Directory.Exists(target)) && Directory.Exists(backup) then Directory.Move(backup, target) raise error -let skillNames = - [ "maintain-project-readme" - "maintain-project-contributing" - "maintain-project-agents" - "maintain-project-roadmap" - "maintain-project-repo" ] - -for skillName in skillNames do - replaceTree - (Path.Combine(pluginRoot, "skills", skillName)) - (Path.Combine(repositoryRoot, "skills", skillName)) +let exportedSkills = + Directory.GetDirectories(Path.Combine(repositoryRoot, "skills")) + |> Array.filter (fun directory -> File.Exists(Path.Combine(directory, "SKILL.md"))) + |> Array.sort +for target in exportedSkills do + let skillName = Path.GetFileName(target) + let source = + let candidates = Directory.GetDirectories(pluginsRoot) |> Array.map (fun plugin -> Path.Combine(plugin, "skills", skillName)) |> Array.filter Directory.Exists + match candidates with + | [| only |] -> only + | [||] -> failwith $"Root skill export has no owning plugin source: {skillName}" + | many -> + let rendered = String.concat ", " many + failwith $"Root skill export has ambiguous owners: {skillName} ({rendered})" + replaceTree source target replaceTree - (Path.Combine(pluginRoot, "shared", "project-docs")) + (Path.Combine(pluginsRoot, "repository-skills", "shared", "project-docs")) (Path.Combine(repositoryRoot, "shared", "project-docs")) -printfn "Synchronized %d repository skills and the shared documentation runtime." skillNames.Length +printfn "Synchronized %d managed root skill exports and the shared documentation runtime." exportedSkills.Length diff --git a/scripts/repo-maintenance/validations/50-socket.fsx b/scripts/repo-maintenance/validations/50-socket.fsx index 160e43788..f535468ea 100644 --- a/scripts/repo-maintenance/validations/50-socket.fsx +++ b/scripts/repo-maintenance/validations/50-socket.fsx @@ -57,13 +57,15 @@ let nestedTests = not (parts |> Array.exists (fun part -> part = ".git" || part = ".venv" || part = ".codex")) && parts.Length > 1 && parts[0] <> "tests" - && (parts |> Array.exists (fun part -> part = "test" || part = "tests"))) + && (parts |> Array.exists (fun part -> part = "test" || part = "tests" || part = "evals"))) if not (Array.isEmpty nestedTests) then fail $"Tests must live only at Socket root; found {relative nestedTests[0]}." -let repositorySkillRoot = Path.Combine(root, "plugins", "repository-skills") -let legacyRepositoryScripts = - Directory.GetFiles(repositorySkillRoot, "*", SearchOption.AllDirectories) - |> Array.filter (fun path -> path.EndsWith(".py") || path.EndsWith(".sh")) -if not (Array.isEmpty legacyRepositoryScripts) then fail $"Repository Skills contains a legacy script: {relative legacyRepositoryScripts[0]}." +let legacyScripts = + Directory.GetFiles(root, "*", SearchOption.AllDirectories) + |> Array.filter (fun path -> + let parts = relative path |> fun value -> value.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + not (parts |> Array.exists (fun part -> part = ".git" || part = ".venv" || part = ".codex")) + && (path.EndsWith(".py", StringComparison.Ordinal) || path.EndsWith(".sh", StringComparison.Ordinal))) +if not (Array.isEmpty legacyScripts) then fail $"Socket automation must use FSX only; found {relative legacyScripts[0]}." -printfn "Socket marketplace integration, compatibility wiring, root-only tests, and repository-skills automation are valid." +printfn "Socket marketplace integration, compatibility wiring, root-only E2E tests, and FSX-only automation are valid." diff --git a/scripts/spi_add_package.py b/scripts/spi_add_package.py deleted file mode 100755 index 0f13e5bd0..000000000 --- a/scripts/spi_add_package.py +++ /dev/null @@ -1,470 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -"""Prepare and open the single documented Swift Package Index add-package flow.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from pathlib import Path - - -PACKAGE_LIST_ADD_FORM_URL = ( - "https://raw.githubusercontent.com/SwiftPackageIndex/PackageList/" - "main/.github/ISSUE_TEMPLATE/add_package.yml" -) -PACKAGE_LIST_ISSUE_FORM_URL = "https://github.com/SwiftPackageIndex/PackageList/issues/new" -SPI_PACKAGE_BASE_URL = "https://swiftpackageindex.com" -ZEN_BROWSER_BUNDLE_ID = "app.zen-browser.zen" -ZEN_BROWSER_APP_NAME = "Zen" -SEMVER_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") -SWIFT_TOOLS_VERSION_RE = re.compile(r"//\s*swift-tools-version\s*:\s*(\d+)(?:\.(\d+))?") - - -class SPIAddPackageError(RuntimeError): - """Raised when the package cannot safely proceed to the SPI issue form.""" - - -@dataclass(frozen=True) -class PackageIdentity: - owner: str - repository: str - git_url: str - - @property - def spi_url(self) -> str: - return f"{SPI_PACKAGE_BASE_URL}/{self.owner}/{self.repository}" - - -@dataclass(frozen=True) -class ReadinessResult: - package_root: Path - identity: PackageIdentity - semver_tags: tuple[str, ...] - indexed_state: str - checked_steps: tuple[str, ...] - skipped_steps: tuple[str, ...] - - -def run_command( - args: list[str], - *, - cwd: Path, - check: bool = True, -) -> subprocess.CompletedProcess[str]: - result = subprocess.run( - args, - cwd=cwd, - capture_output=True, - text=True, - check=False, - ) - if check and result.returncode != 0: - stderr = result.stderr.strip() - stdout = result.stdout.strip() - details = stderr or stdout or f"exit status {result.returncode}" - raise SPIAddPackageError( - f"Command failed while preparing SPI submission: {' '.join(args)}\n{details}" - ) - return result - - -def normalize_github_url(remote_url: str) -> PackageIdentity: - candidate = remote_url.strip() - if candidate.startswith("git@github.com:"): - candidate = "https://github.com/" + candidate.removeprefix("git@github.com:") - if candidate.startswith("ssh://git@github.com/"): - candidate = "https://github.com/" + candidate.removeprefix("ssh://git@github.com/") - if candidate.startswith("http://github.com/"): - candidate = "https://github.com/" + candidate.removeprefix("http://github.com/") - if candidate.startswith("https://www.github.com/"): - candidate = "https://github.com/" + candidate.removeprefix("https://www.github.com/") - - parsed = urllib.parse.urlparse(candidate) - if parsed.scheme != "https" or parsed.netloc != "github.com": - raise SPIAddPackageError( - "SPI package URLs must use a public GitHub HTTPS repository URL. " - f"Found remote URL: {remote_url}" - ) - - path_parts = [part for part in parsed.path.strip("/").split("/") if part] - if len(path_parts) != 2: - raise SPIAddPackageError( - "Expected a GitHub repository URL shaped as " - f"`https://github.com/owner/repository.git`, but found: {remote_url}" - ) - - owner, repository = path_parts - repository = repository.removesuffix(".git") - git_url = f"https://github.com/{owner}/{repository}.git" - return PackageIdentity(owner=owner, repository=repository, git_url=git_url) - - -def identity_from_repo(package_root: Path, override_url: str | None) -> PackageIdentity: - if override_url: - return normalize_github_url(override_url) - - result = run_command(["git", "remote", "get-url", "origin"], cwd=package_root) - return normalize_github_url(result.stdout) - - -def discover_semver_tags(package_root: Path) -> tuple[str, ...]: - result = run_command(["git", "tag", "--list"], cwd=package_root) - tags = tuple(sorted(tag for tag in result.stdout.splitlines() if SEMVER_TAG_RE.match(tag))) - if not tags: - raise SPIAddPackageError( - "SPI requires at least one semantic-version release tag before submission. " - "Create and push a real release tag before opening the Add Package form." - ) - return tags - - -def confirm_remote_semver_tag(identity: PackageIdentity, package_root: Path, local_tags: tuple[str, ...]) -> None: - result = run_command(["git", "ls-remote", "--tags", identity.git_url], cwd=package_root) - remote_tags = { - line.rsplit("/", maxsplit=1)[-1].removesuffix("^{}") - for line in result.stdout.splitlines() - if "refs/tags/" in line - } - matching_tags = sorted(tag for tag in local_tags if tag in remote_tags) - if not matching_tags: - raise SPIAddPackageError( - "SPI requires an accessible semantic-version release tag. " - f"Local SemVer tags exist, but none were visible on {identity.git_url}. " - "Push the release tag before opening the Add Package form." - ) - - -def confirm_public_repository(identity: PackageIdentity, package_root: Path) -> None: - run_command(["git", "ls-remote", "--exit-code", identity.git_url, "HEAD"], cwd=package_root) - - -def confirm_swift_tools_version(package_root: Path) -> None: - manifest_prefix = (package_root / "Package.swift").read_text(encoding="utf-8", errors="replace")[:300] - match = SWIFT_TOOLS_VERSION_RE.search(manifest_prefix) - if not match: - raise SPIAddPackageError( - "Package.swift must declare a Swift tools version before SPI submission, " - "for example `// swift-tools-version: 5.10`." - ) - major = int(match.group(1)) - minor = int(match.group(2) or "0") - if (major, minor) < (5, 0): - raise SPIAddPackageError( - "SPI requires packages to be written in Swift 5.0 or later. " - f"Package.swift declares swift-tools-version {major}.{minor}." - ) - - -def dump_package_json(package_root: Path) -> dict[str, object]: - result = run_command(["swift", "package", "dump-package"], cwd=package_root) - try: - package_data = json.loads(result.stdout) - except json.JSONDecodeError as exc: - raise SPIAddPackageError( - "SPI requires `swift package dump-package` to emit valid JSON. " - f"JSON parsing failed at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(package_data, dict): - raise SPIAddPackageError("`swift package dump-package` did not emit a JSON object.") - products = package_data.get("products") - if not isinstance(products, list) or not products: - raise SPIAddPackageError( - "SPI requires the package to contain at least one library or executable product." - ) - return package_data - - -def check_spi_index_state(identity: PackageIdentity, timeout: float = 10.0) -> str: - request = urllib.request.Request(identity.spi_url, headers={"User-Agent": "socket-spi-add-package/1"}) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - if response.status == 200: - return "indexed" - return f"unknown-http-{response.status}" - except urllib.error.HTTPError as exc: - if exc.code == 404: - return "not-indexed" - return f"unknown-http-{exc.code}" - except urllib.error.URLError: - return "unknown-network" - - -def fetch_url(url: str, timeout: float = 15.0) -> str: - request = urllib.request.Request(url, headers={"User-Agent": "socket-spi-add-package/1"}) - with urllib.request.urlopen(request, timeout=timeout) as response: - return response.read().decode("utf-8") - - -def validate_live_add_package_form(form_text: str) -> None: - required = { - "form name": "name: Add Package(s)", - "form title": "title: 'Add <Package>'", - "default Add Package label": "labels: ['Add Package']", - "New Packages field id": "id: list", - "New Packages label": "label: New Packages", - "required field": "required: true", - } - missing = [label for label, needle in required.items() if needle not in form_text] - if missing: - raise SPIAddPackageError( - "SwiftPackageIndex/PackageList changed its Add Package issue form. " - "Refusing to open a submission until the script is updated. Missing: " - + ", ".join(missing) - ) - - -def build_issue_form_url(identity: PackageIdentity) -> str: - query = urllib.parse.urlencode( - { - "template": "add_package.yml", - "title": f"Add {identity.repository}", - "list": identity.git_url, - } - ) - return f"{PACKAGE_LIST_ISSUE_FORM_URL}?{query}" - - -def run_readiness( - package_root: Path, - *, - override_url: str | None, - skip_build: bool, - skip_tests: bool, - skip_remote_check: bool, - skip_index_check: bool, -) -> ReadinessResult: - package_root = package_root.resolve() - if not package_root.is_dir(): - raise SPIAddPackageError(f"Package root does not exist: {package_root}") - if not (package_root / "Package.swift").is_file(): - raise SPIAddPackageError(f"SPI requires Package.swift at the package root: {package_root}") - - checked_steps: list[str] = ["Package.swift"] - skipped_steps: list[str] = [] - identity = identity_from_repo(package_root, override_url) - - if skip_remote_check: - skipped_steps.append("public repository check") - else: - confirm_public_repository(identity, package_root) - checked_steps.append("public repository") - - semver_tags = discover_semver_tags(package_root) - checked_steps.append("semantic version tags") - confirm_swift_tools_version(package_root) - checked_steps.append("Swift tools version") - - dump_package_json(package_root) - checked_steps.append("swift package dump-package JSON and products") - - if skip_build: - skipped_steps.append("swift build") - else: - run_command(["swift", "build"], cwd=package_root) - checked_steps.append("swift build") - - if skip_tests: - skipped_steps.append("swift test") - else: - run_command(["swift", "test"], cwd=package_root) - checked_steps.append("swift test") - - if (package_root / ".spi.yml").is_file(): - checked_steps.append(".spi.yml present") - else: - skipped_steps.append(".spi.yml not present") - - indexed_state = "unknown-skipped" - if skip_index_check: - skipped_steps.append("SPI indexed-state check") - else: - indexed_state = check_spi_index_state(identity) - checked_steps.append(f"SPI indexed-state: {indexed_state}") - - if skip_remote_check: - skipped_steps.append("remote semantic-version tag visibility") - else: - confirm_remote_semver_tag(identity, package_root, semver_tags) - checked_steps.append("remote semantic-version tag") - - if indexed_state == "indexed": - raise SPIAddPackageError( - f"{identity.owner}/{identity.repository} already appears to be indexed on SPI: " - f"{identity.spi_url}" - ) - - return ReadinessResult( - package_root=package_root, - identity=identity, - semver_tags=semver_tags, - indexed_state=indexed_state, - checked_steps=tuple(checked_steps), - skipped_steps=tuple(skipped_steps), - ) - - -def open_in_browser(url: str, *, browser: str) -> None: - run_command(["open", "-b", browser, url], cwd=Path.cwd()) - - -def computer_use_handoff(url: str, *, result: ReadinessResult, browser: str) -> dict[str, object]: - return { - "mode": "computer-use-hands-free", - "browser_bundle_id": browser, - "preferred_browser_name": ZEN_BROWSER_APP_NAME, - "official_issue_form_url": url, - "allowed_actions": [ - "Use Computer Use get_app_state for the browser.", - "Confirm the page is the SwiftPackageIndex/PackageList Add Package(s) issue form.", - "Confirm the New Packages field contains the package URL exactly once.", - "Click GitHub's Submit new issue button.", - "After creation, verify the issue has the Add Package label and report the URL.", - ], - "forbidden_actions": [ - "Do not run gh issue create.", - "Do not add or edit labels directly.", - "Do not fork SwiftPackageIndex/PackageList.", - "Do not clone SwiftPackageIndex/PackageList.", - "Do not edit packages.json.", - "Do not create or push PackageList branches.", - "Do not open a PackageList pull request.", - "Do not touch CLA-triggering contribution paths.", - ], - "package": { - "owner": result.identity.owner, - "repository": result.identity.repository, - "git_url": result.identity.git_url, - "spi_url": result.identity.spi_url, - }, - } - - -def print_summary(result: ReadinessResult, issue_form_url: str) -> None: - print("SPI readiness passed.") - print(f"Package: {result.identity.owner}/{result.identity.repository}") - print(f"Repository URL: {result.identity.git_url}") - print(f"SPI page: {result.identity.spi_url}") - print(f"SemVer tags found: {', '.join(result.semver_tags[-5:])}") - print("Checked:") - for step in result.checked_steps: - print(f" - {step}") - if result.skipped_steps: - print("Skipped:") - for step in result.skipped_steps: - print(f" - {step}") - print("Official Add Package issue-form URL:") - print(issue_form_url) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Validate Swift Package Index readiness and open only the official " - "SwiftPackageIndex/PackageList Add Package issue form." - ) - ) - parser.add_argument( - "mode", - choices=("readiness", "url", "open", "hands-free"), - help=( - "`readiness` checks only, `url` prints the official issue-form URL, " - "`open` opens the prefilled form, and `hands-free` opens the form plus " - "prints the Codex Computer Use handoff." - ), - ) - parser.add_argument("package_root", nargs="?", default=".", help="Swift package repository root.") - parser.add_argument("--repo-url", help="Override the GitHub package URL.") - parser.add_argument("--browser", default=ZEN_BROWSER_BUNDLE_ID, help="Browser bundle id for open/hands-free.") - parser.add_argument("--skip-build", action="store_true", help="Diagnostic-only for readiness/url: skip `swift build`.") - parser.add_argument("--skip-tests", action="store_true", help="Diagnostic-only for readiness/url: skip `swift test`.") - parser.add_argument( - "--skip-remote-check", - action="store_true", - help="Diagnostic-only for readiness/url: skip public GitHub remote and tag checks.", - ) - parser.add_argument( - "--skip-index-check", - action="store_true", - help="Diagnostic-only for readiness/url: skip SPI already-indexed check.", - ) - parser.add_argument( - "--skip-live-form-check", - action="store_true", - help="Diagnostic-only for readiness/url: skip live PackageList form-shape check.", - ) - return parser.parse_args(argv) - - -def validate_mode_and_skip_flags(args: argparse.Namespace) -> None: - skipped = [ - flag - for flag in ( - "skip_build", - "skip_tests", - "skip_remote_check", - "skip_index_check", - "skip_live_form_check", - ) - if getattr(args, flag) - ] - if args.mode in {"open", "hands-free"} and skipped: - flags = ", ".join("--" + flag.replace("_", "-") for flag in skipped) - raise SPIAddPackageError( - "Browser-opening SPI submission modes require complete readiness and live form checks. " - f"Remove these skip flags before using `{args.mode}`: {flags}" - ) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv or sys.argv[1:]) - try: - validate_mode_and_skip_flags(args) - result = run_readiness( - Path(args.package_root), - override_url=args.repo_url, - skip_build=args.skip_build, - skip_tests=args.skip_tests, - skip_remote_check=args.skip_remote_check, - skip_index_check=args.skip_index_check, - ) - if args.skip_live_form_check: - form_text = "" - else: - form_text = fetch_url(PACKAGE_LIST_ADD_FORM_URL) - validate_live_add_package_form(form_text) - issue_form_url = build_issue_form_url(result.identity) - - print_summary(result, issue_form_url) - - if args.mode in {"open", "hands-free"}: - open_in_browser(issue_form_url, browser=args.browser) - print(f"Opened official Add Package issue form in browser bundle `{args.browser}`.") - - if args.mode == "hands-free": - print("Codex Computer Use handoff:") - print(json.dumps(computer_use_handoff(issue_form_url, result=result, browser=args.browser), indent=2)) - - return 0 - except SPIAddPackageError as exc: - print(f"SPI add-package gate failed: {exc}", file=sys.stderr) - return 1 - except urllib.error.URLError as exc: - print(f"SPI add-package gate failed while reading live SPI/GitHub data: {exc}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - print("SPI add-package gate interrupted.", file=sys.stderr) - return 130 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate_claude_compatibility.py b/scripts/validate_claude_compatibility.py deleted file mode 100644 index 28713e139..000000000 --- a/scripts/validate_claude_compatibility.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Socket's Claude Code marketplace and Cowork compatibility inventory.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parent.parent -CODEX_MARKETPLACE_PATH = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" -CLAUDE_MARKETPLACE_PATH = REPO_ROOT / ".claude-plugin" / "marketplace.json" -INVENTORY_PATH = REPO_ROOT / "docs" / "maintainers" / "claude-compatibility.json" -EXCLUDED_CLAUDE_PLUGINS = {"agentdeck", "speak-swiftly"} -PLUGIN_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -ALLOWED_CODE_STATUSES = {"supported", "local_mcp", "remote_mcp", "not_supported"} -ALLOWED_COWORK_STATUSES = {"skills_only", "remote_mcp", "not_supported"} -MACHINE_LOCAL_PATH_RE = re.compile(r"(?:^|[\s'\"])~[/\\]|/Users/|(?:^|[\s'\"])\.\./") - - -class ValidationError(RuntimeError): - """Raised when a checked-in Claude compatibility artifact is invalid.""" - - -def load_json(path: Path) -> Any: - try: - return json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} is not valid JSON: {error}") from error - - -def require_mapping(value: Any, location: str) -> dict[str, Any]: - if not isinstance(value, dict): - raise ValidationError(f"{location} must be a JSON object.") - return value - - -def require_string(value: Any, location: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValidationError(f"{location} must be a non-empty string.") - return value - - -def codex_entries() -> dict[str, dict[str, Any]]: - document = require_mapping(load_json(CODEX_MARKETPLACE_PATH), ".agents/plugins/marketplace.json") - plugins = document.get("plugins") - if not isinstance(plugins, list): - raise ValidationError(".agents/plugins/marketplace.json must define a plugins array.") - entries: dict[str, dict[str, Any]] = {} - for index, value in enumerate(plugins): - entry = require_mapping(value, f"Codex plugin entry {index}") - name = require_string(entry.get("name"), f"Codex plugin entry {index}.name") - if name in entries: - raise ValidationError(f"Codex marketplace repeats plugin {name!r}.") - entries[name] = entry - return entries - - -def source_path(entry: dict[str, Any], name: str) -> Path | None: - source = entry.get("source") - if isinstance(source, str): - if not source.startswith("./"): - raise ValidationError(f"Claude plugin {name!r} relative source must begin with './'.") - return REPO_ROOT / source[2:] - if not isinstance(source, dict): - raise ValidationError(f"Claude plugin {name!r} source must be a string path or source object.") - source_kind = require_string(source.get("source"), f"Claude plugin {name!r}.source.source") - if source_kind == "url": - require_string(source.get("url"), f"Claude plugin {name!r}.source.url") - return None - raise ValidationError(f"Claude plugin {name!r} uses unsupported source type {source_kind!r}.") - - -def load_mcp_servers(path: Path) -> dict[str, dict[str, Any]]: - document = require_mapping(load_json(path), path.relative_to(REPO_ROOT).as_posix()) - servers = document.get("mcpServers") - if not isinstance(servers, dict) or not servers: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} must define a non-empty mcpServers object for Claude.") - typed_servers: dict[str, dict[str, Any]] = {} - for name, value in servers.items(): - if not isinstance(name, str) or not isinstance(value, dict): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} must map string server names to objects.") - transports = [field for field in ("command", "url") if field in value] - if len(transports) != 1: - raise ValidationError( - f"{path.relative_to(REPO_ROOT)} server {name!r} must define exactly one transport: command or url." - ) - if not isinstance(value[transports[0]], str) or not value[transports[0]].strip(): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} server {name!r} has an empty transport value.") - if MACHINE_LOCAL_PATH_RE.search(json.dumps(value)): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} server {name!r} contains a machine-local path.") - if "${PLUGIN_ROOT}" in json.dumps(value): - raise ValidationError( - f"{path.relative_to(REPO_ROOT)} server {name!r} uses Codex-only ${{PLUGIN_ROOT}}; use ${{CLAUDE_PLUGIN_ROOT}}." - ) - typed_servers[name] = value - return typed_servers - - -def validate_marketplace(codex: dict[str, dict[str, Any]]) -> set[str]: - document = require_mapping(load_json(CLAUDE_MARKETPLACE_PATH), ".claude-plugin/marketplace.json") - if document.get("name") != "socket": - raise ValidationError(".claude-plugin/marketplace.json must use the Socket marketplace name.") - owner = require_mapping(document.get("owner"), ".claude-plugin/marketplace.json.owner") - require_string(owner.get("name"), ".claude-plugin/marketplace.json.owner.name") - require_string(document.get("description"), ".claude-plugin/marketplace.json.description") - plugins = document.get("plugins") - if not isinstance(plugins, list) or not plugins: - raise ValidationError(".claude-plugin/marketplace.json must define a non-empty plugins array.") - - names: set[str] = set() - for index, value in enumerate(plugins): - entry = require_mapping(value, f"Claude plugin entry {index}") - name = require_string(entry.get("name"), f"Claude plugin entry {index}.name") - if not PLUGIN_NAME_RE.fullmatch(name): - raise ValidationError(f"Claude plugin {name!r} must be lowercase kebab-case.") - if name in names: - raise ValidationError(f"Claude marketplace repeats plugin {name!r}.") - if name not in codex: - raise ValidationError(f"Claude marketplace plugin {name!r} is absent from the Socket Codex marketplace.") - if entry.get("strict") is not False: - raise ValidationError(f"Claude plugin {name!r} must set strict to false so Socket keeps one authored payload.") - require_string(entry.get("description"), f"Claude plugin {name!r}.description") - root = source_path(entry, name) - if root is not None: - if not root.is_dir(): - raise ValidationError(f"Claude plugin {name!r} source directory is missing: {root.relative_to(REPO_ROOT)}.") - if not (root / "skills").is_dir(): - raise ValidationError(f"Claude plugin {name!r} must expose a skills directory.") - mcp_path = entry.get("mcpServers") - if mcp_path is not None: - relative_mcp_path = require_string(mcp_path, f"Claude plugin {name!r}.mcpServers") - if not relative_mcp_path.startswith("./"): - raise ValidationError(f"Claude plugin {name!r}.mcpServers must be relative to its plugin root.") - load_mcp_servers(root / relative_mcp_path[2:]) - names.add(name) - - expected = set(codex) - EXCLUDED_CLAUDE_PLUGINS - if names != expected: - missing = sorted(expected - names) - extra = sorted(names - expected) - details = [] - if missing: - details.append(f"missing {', '.join(missing)}") - if extra: - details.append(f"unexpected {', '.join(extra)}") - raise ValidationError("Claude marketplace inventory differs from the approved Socket classification: " + "; ".join(details) + ".") - return names - - -def validate_inventory(codex: dict[str, dict[str, Any]], claude_names: set[str]) -> None: - document = require_mapping(load_json(INVENTORY_PATH), "docs/maintainers/claude-compatibility.json") - if document.get("schemaVersion") != 1 or document.get("catalog") != "socket": - raise ValidationError("Claude compatibility inventory must use schemaVersion 1 for the Socket catalog.") - entries = require_mapping(document.get("entries"), "docs/maintainers/claude-compatibility.json.entries") - if set(entries) != set(codex): - raise ValidationError("Claude compatibility inventory must classify every Socket Codex marketplace plugin exactly once.") - for name, value in entries.items(): - entry = require_mapping(value, f"Claude compatibility inventory entry {name!r}") - code_status = entry.get("claudeCode") - cowork_status = entry.get("cowork") - if code_status not in ALLOWED_CODE_STATUSES: - raise ValidationError(f"Claude compatibility inventory entry {name!r} has invalid claudeCode status {code_status!r}.") - if cowork_status not in ALLOWED_COWORK_STATUSES: - raise ValidationError(f"Claude compatibility inventory entry {name!r} has invalid cowork status {cowork_status!r}.") - require_string(entry.get("note"), f"Claude compatibility inventory entry {name!r}.note") - if name in EXCLUDED_CLAUDE_PLUGINS and code_status != "not_supported": - raise ValidationError(f"Excluded Claude plugin {name!r} must be marked not_supported.") - if name in claude_names and code_status == "not_supported": - raise ValidationError(f"Installed Claude marketplace plugin {name!r} cannot be marked not_supported.") - if code_status == "local_mcp" and cowork_status != "skills_only": - raise ValidationError(f"Local-MCP plugin {name!r} must be Cowork skills_only.") - - -def main() -> int: - codex = codex_entries() - claude_names = validate_marketplace(codex) - validate_inventory(codex, claude_names) - print("Socket Claude Code and Cowork compatibility validation passed.") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except ValidationError as error: - print(f"validate-claude-compatibility: {error}") - raise SystemExit(1) diff --git a/scripts/validate_hermes_compatibility.py b/scripts/validate_hermes_compatibility.py deleted file mode 100644 index 187e09a47..000000000 --- a/scripts/validate_hermes_compatibility.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Socket's explicit Hermes Agent skill-tap compatibility surface.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path -from typing import Any - -import yaml - -import export_hermes_skills - - -REPO_ROOT = Path(__file__).resolve().parent.parent -EXPORT_ROOT = REPO_ROOT / "skills" -GROUPINGS_PATH = REPO_ROOT / "skills.sh.json" -MCP_EXAMPLES_PATH = REPO_ROOT / "docs" / "maintainers" / "hermes-mcp-examples.yaml" -MCP_TRANSLATIONS_INDEX_PATH = REPO_ROOT / "docs" / "maintainers" / "hermes-mcp" / "index.yaml" -HERMES_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -MAX_FRIENDLY_DESCRIPTION_LENGTH = 240 -MACHINE_LOCAL_PATH_RE = re.compile(r"(?:^|[\s'\"])~[/\\]|/Users/|(?:^|[\s'\"])\.\./") -ENV_PLACEHOLDER_RE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)") -MCP_TRANSLATION_STATUSES = {"ready", "manual_setup_required", "not_supported"} - - -class ValidationError(RuntimeError): - """Raised when the checked-in Hermes compatibility surface is invalid.""" - - -def load_yaml_mapping(path: Path) -> dict[str, Any]: - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as error: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} is not valid YAML: {error}") from error - if not isinstance(value, dict): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} must decode to a YAML mapping.") - return value - - -def read_frontmatter(path: Path) -> dict[str, Any]: - contents = path.read_text(encoding="utf-8") - if not contents.startswith("---\n"): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} must begin with YAML frontmatter.") - _, separator, remaining = contents.partition("\n---\n") - if not separator: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} has unterminated YAML frontmatter.") - try: - value = yaml.safe_load(contents[4 : len(contents) - len(remaining) - len(separator)]) - except yaml.YAMLError as error: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} has invalid YAML frontmatter: {error}") from error - if not isinstance(value, dict): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} frontmatter must be a YAML mapping.") - return value - - -def contains_machine_local_path(value: object) -> bool: - if isinstance(value, str): - return bool(MACHINE_LOCAL_PATH_RE.search(value)) - if isinstance(value, list): - return any(contains_machine_local_path(item) for item in value) - if isinstance(value, dict): - return any(contains_machine_local_path(item) for item in value.values()) - return False - - -def validate_exported_skills() -> None: - overlong_descriptions: list[str] = [] - try: - if not export_hermes_skills.has_exact_export(): - raise ValidationError( - "Root skills/ is stale or incomplete. Run `uv run scripts/export_hermes_skills.py` " - "before validating Hermes compatibility." - ) - except export_hermes_skills.ExportError as error: - raise ValidationError(str(error)) from error - - for skill_name in export_hermes_skills.EXPORTED_SKILLS: - skill_path = EXPORT_ROOT / skill_name / "SKILL.md" - metadata = read_frontmatter(skill_path) - name = metadata.get("name") - description = metadata.get("description") - if name != skill_name or not isinstance(name, str) or not HERMES_NAME_RE.fullmatch(name): - raise ValidationError( - f"{skill_path.relative_to(REPO_ROOT)} must use its lowercase hyphenated directory " - f"name {skill_name!r}, but found {name!r}." - ) - if not isinstance(description, str) or not description.strip(): - raise ValidationError( - f"{skill_path.relative_to(REPO_ROOT)} must define a non-empty description." - ) - if len(description) > MAX_FRIENDLY_DESCRIPTION_LENGTH: - overlong_descriptions.append( - f"{skill_path.relative_to(REPO_ROOT)} description is {len(description)} characters; " - f"maximum is {MAX_FRIENDLY_DESCRIPTION_LENGTH}." - ) - if contains_machine_local_path(metadata): - raise ValidationError( - f"{skill_path.relative_to(REPO_ROOT)} frontmatter contains a machine-local or parent-relative path." - ) - if overlong_descriptions: - raise ValidationError( - "Hermes skill descriptions exceed the maximum length:\n- " - + "\n- ".join(overlong_descriptions) - ) - - -def validate_groupings() -> None: - try: - document = json.loads(GROUPINGS_PATH.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - raise ValidationError(f"skills.sh.json is not valid JSON: {error}") from error - groupings = document.get("groupings") if isinstance(document, dict) else None - if not isinstance(groupings, list) or not groupings: - raise ValidationError("skills.sh.json must define a non-empty groupings array.") - exported = set(export_hermes_skills.EXPORTED_SKILLS) - grouped_names: set[str] = set() - for grouping in groupings: - if not isinstance(grouping, dict) or not isinstance(grouping.get("title"), str): - raise ValidationError("Each skills.sh.json grouping must define a string title.") - skills = grouping.get("skills") - if not isinstance(skills, list) or not all(isinstance(skill, str) for skill in skills): - raise ValidationError("Each skills.sh.json grouping must define a string skills array.") - unknown = set(skills) - exported - if unknown: - raise ValidationError( - "skills.sh.json refers to skills absent from the Hermes export: " - f"{', '.join(sorted(unknown))}." - ) - grouped_names.update(skills) - missing = exported - grouped_names - if missing: - raise ValidationError( - "skills.sh.json does not group every exported Hermes skill: " - f"{', '.join(sorted(missing))}." - ) - - -def validate_mcp_examples() -> None: - document = load_yaml_mapping(MCP_EXAMPLES_PATH) - servers = document.get("mcp_servers") - if not isinstance(servers, dict) or not servers: - raise ValidationError("hermes-mcp-examples.yaml must define a non-empty mcp_servers mapping.") - for name, config in servers.items(): - if not isinstance(name, str) or not isinstance(config, dict): - raise ValidationError("Each Hermes MCP example must have a string name and mapping configuration.") - transports = [field for field in ("command", "url") if field in config] - if len(transports) != 1: - raise ValidationError( - f"Hermes MCP example {name!r} must define exactly one transport: command or url." - ) - - -def load_socket_mcp_servers(path: Path) -> dict[str, Any]: - try: - document = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} is not valid JSON: {error}") from error - servers = document.get("mcpServers") if isinstance(document, dict) else None - if servers is None: - servers = document - if not isinstance(servers, dict) or not servers: - raise ValidationError(f"{path.relative_to(REPO_ROOT)} must define a non-empty MCP server mapping.") - if not all(isinstance(name, str) and isinstance(config, dict) for name, config in servers.items()): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} must map string server names to configurations.") - return servers - - -def placeholders_in(value: object) -> set[str]: - if isinstance(value, str): - return {match.group(1) or match.group(2) for match in ENV_PLACEHOLDER_RE.finditer(value)} - if isinstance(value, list): - return set().union(*(placeholders_in(item) for item in value)) if value else set() - if isinstance(value, dict): - return set().union(*(placeholders_in(item) for item in value.values())) if value else set() - return set() - - -def validate_hermes_server(name: str, config: dict[str, Any], path: Path) -> set[str]: - transports = [field for field in ("command", "url") if field in config] - if len(transports) != 1: - raise ValidationError( - f"{path.relative_to(REPO_ROOT)} server {name!r} must define exactly one transport: command or url." - ) - if "cwd" in config: - raise ValidationError( - f"{path.relative_to(REPO_ROOT)} server {name!r} uses unsupported Hermes field 'cwd'; use a documented portable launcher." - ) - if not isinstance(config[transports[0]], str) or not config[transports[0]].strip(): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} server {name!r} has an empty {transports[0]!r}.") - args = config.get("args") - if args is not None and (not isinstance(args, list) or not all(isinstance(arg, str) for arg in args)): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} server {name!r} args must be a string list.") - env = config.get("env") - if env is not None and (not isinstance(env, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in env.items())): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} server {name!r} env must be a string mapping.") - if contains_machine_local_path(config): - raise ValidationError(f"{path.relative_to(REPO_ROOT)} server {name!r} contains a machine-local path.") - return placeholders_in(config) - - -def validate_mcp_translations() -> None: - index = load_yaml_mapping(MCP_TRANSLATIONS_INDEX_PATH) - translations = index.get("translations") - if not isinstance(translations, dict) or not translations: - raise ValidationError("hermes-mcp/index.yaml must define a non-empty translations mapping.") - declared_sources = {path.relative_to(REPO_ROOT).as_posix() for path in REPO_ROOT.glob("plugins/**/.mcp.json")} - indexed_sources: set[str] = set() - for plugin_name, entry in translations.items(): - if not isinstance(plugin_name, str) or not isinstance(entry, dict): - raise ValidationError("Each Hermes MCP translation index entry must be a plugin-name mapping.") - source = entry.get("source") - translation = entry.get("translation") - status = entry.get("status") - documented_environment = entry.get("required_environment") - setup = entry.get("setup") - if not isinstance(source, str) or source not in declared_sources: - raise ValidationError(f"Hermes MCP translation {plugin_name!r} has no declared Socket .mcp.json source.") - if not isinstance(translation, str) or not translation.startswith("docs/maintainers/hermes-mcp/"): - raise ValidationError(f"Hermes MCP translation {plugin_name!r} must use a checked-in hermes-mcp translation path.") - if not isinstance(status, str) or status not in MCP_TRANSLATION_STATUSES: - raise ValidationError(f"Hermes MCP translation {plugin_name!r} has unsupported status {status!r}.") - if not isinstance(documented_environment, list) or not all(isinstance(item, str) for item in documented_environment): - raise ValidationError(f"Hermes MCP translation {plugin_name!r} must list documented required_environment names.") - if not isinstance(setup, str) or not setup.strip(): - raise ValidationError(f"Hermes MCP translation {plugin_name!r} must include a setup note.") - translation_path = REPO_ROOT / translation - document = load_yaml_mapping(translation_path) - servers = document.get("mcp_servers") - if not isinstance(servers, dict) or not servers: - raise ValidationError(f"{translation} must define a non-empty mcp_servers mapping.") - source_servers = load_socket_mcp_servers(REPO_ROOT / source) - if set(servers) != set(source_servers): - raise ValidationError(f"{translation} server names must exactly match {source}.") - placeholders: set[str] = set() - for name, config in servers.items(): - if not isinstance(name, str) or not isinstance(config, dict): - raise ValidationError(f"{translation} must map string server names to mappings.") - placeholders.update(validate_hermes_server(name, config, translation_path)) - undocumented = placeholders - set(documented_environment) - if undocumented: - raise ValidationError(f"{translation} has undocumented environment placeholders: {', '.join(sorted(undocumented))}.") - indexed_sources.add(source) - missing = declared_sources - indexed_sources - extra = indexed_sources - declared_sources - if missing or extra: - details = [] - if missing: - details.append(f"missing translations for {', '.join(sorted(missing))}") - if extra: - details.append(f"unknown translation sources {', '.join(sorted(extra))}") - raise ValidationError("Hermes MCP translation inventory is incomplete: " + "; ".join(details) + ".") - - -def main() -> int: - validate_exported_skills() - validate_groupings() - validate_mcp_examples() - validate_mcp_translations() - print("Socket Hermes compatibility validation passed.") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except ValidationError as error: - print(f"validate-hermes-compatibility: {error}") - raise SystemExit(1) diff --git a/scripts/validate_socket.py b/scripts/validate_socket.py deleted file mode 100644 index 851104b1c..000000000 --- a/scripts/validate_socket.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -"""Run Socket validation profiles without duplicating child-suite ownership.""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Sequence - - -REPO_ROOT = Path(__file__).resolve().parent.parent -PYTEST_CACHE = REPO_ROOT / ".codex" / ".cache" / "pytest" -MYPY_CACHE = REPO_ROOT / ".codex" / ".cache" / "mypy" -RUFF_CACHE = REPO_ROOT / ".codex" / ".cache" / "ruff" - - -@dataclass(frozen=True) -class Check: - name: str - command: tuple[str, ...] - cwd: Path = REPO_ROOT - - -def root_python(script_name: str) -> tuple[str, ...]: - return (sys.executable, "-B", f"scripts/{script_name}") - - -def python_module(module: str, *args: str) -> tuple[str, ...]: - return (sys.executable, "-B", "-m", module, *args) - - -def pytest(*paths: str) -> tuple[str, ...]: - return python_module( - "pytest", - *paths, - "-o", - f"cache_dir={PYTEST_CACHE}", - ) - - -def mypy(*paths: str, cache_name: str = "root") -> tuple[str, ...]: - return python_module( - "mypy", - "--cache-dir", - str(MYPY_CACHE / cache_name), - *paths, - ) - - -def ruff(*paths: str, cache_name: str = "root") -> tuple[str, ...]: - return python_module( - "ruff", - "check", - "--cache-dir", - str(RUFF_CACHE / cache_name), - *paths, - ) - - -CORE_CHECKS = ( - Check("root marketplace metadata", root_python("validate_socket_metadata.py")), - Check("shared skill metadata", root_python("validate_socket_skill_metadata.py")), - Check("root tests", pytest()), - Check("root type checks", mypy()), - Check("root lint", ruff("scripts", "tests")), -) -COMPATIBILITY_CHECKS = ( - Check("Hermes compatibility", root_python("validate_hermes_compatibility.py")), - Check("Claude compatibility", root_python("validate_claude_compatibility.py")), -) -CHILD_CHECKS = ( - Check( - "Agent Engineering Skills tests", - pytest( - "plugins/agent-engineering-skills/skills/design-agent-automation-workflow/tests", - "plugins/agent-engineering-skills/skills/design-agent-eval-workflow/tests", - ), - ), - Check( - "Agent Portability Skills tests", - pytest( - "plugins/agent-portability-skills/tests", - "plugins/agent-portability-skills/skills/bootstrap-skills-plugin-repo/tests", - "plugins/agent-portability-skills/skills/sync-skills-repo-guidance/tests", - ), - ), - Check( - "Agent Portability Skills lint", - ruff("plugins/agent-portability-skills", cache_name="agent-portability-skills"), - ), - Check( - "Agent Portability Skills type checks", - mypy("plugins/agent-portability-skills", cache_name="agent-portability-skills"), - ), - Check( - "Apple Dev Skills docs", - ("bash", ".github/scripts/validate_repo_docs.sh"), - REPO_ROOT / "plugins" / "apple-dev-skills", - ), - Check( - "Apple Dev Skills tests", - pytest("plugins/apple-dev-skills/tests"), - ), - Check( - "Professional Skills tests", - pytest("plugins/professional-skills/skills/dice-job-search-workflow/tests"), - ), - Check( - "Python Skills metadata", - ( - sys.executable, - "-B", - "scripts/validate_repo_metadata.py", - ), - REPO_ROOT / "plugins" / "python-skills", - ), - Check( - "Python Skills tests", - pytest("plugins/python-skills/tests"), - ), - Check( - "Python Skills lint", - ruff("plugins/python-skills", cache_name="python-skills"), - ), - Check( - "Python Skills type checks", - mypy("plugins/python-skills", cache_name="python-skills"), - ), - Check( - "Cybersecurity Skills metadata", - ( - sys.executable, - "-B", - "scripts/validate_repo_metadata.py", - ), - REPO_ROOT / "plugins" / "cybersecurity-skills", - ), - Check( - "Cybersecurity Skills tests", - pytest("plugins/cybersecurity-skills/tests"), - ), - Check( - "Reverse Engineering Skills metadata", - ( - sys.executable, - "-B", - "scripts/validate_repo_metadata.py", - ), - REPO_ROOT / "plugins" / "reverse-engineering-skills", - ), - Check( - "Reverse Engineering Skills tests", - pytest("plugins/reverse-engineering-skills/tests"), - ), -) - - -def checks_for_profile(profile: str) -> tuple[Check, ...]: - checks: tuple[Check, ...] = CORE_CHECKS - if profile in {"compatibility", "full"}: - checks += COMPATIBILITY_CHECKS - if profile == "full": - checks += CHILD_CHECKS - return checks - - -def run_check(check: Check, *, dry_run: bool) -> None: - rendered_command = " ".join(check.command) - relative_cwd = check.cwd.relative_to(REPO_ROOT) - print(f"\n==> {check.name}\n cwd: {relative_cwd or '.'}\n {rendered_command}") - if not dry_run: - environment = os.environ.copy() - environment["PYTHONDONTWRITEBYTECODE"] = "1" - if check.cwd != REPO_ROOT: - environment.pop("VIRTUAL_ENV", None) - subprocess.run(check.command, cwd=check.cwd, check=True, env=environment) - - -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--profile", - choices=("core", "compatibility", "full"), - default="core", - help="Validation breadth; defaults to the fast PR-safe core profile.", - ) - parser.add_argument( - "--dry-run", action="store_true", help="Print checks without running them." - ) - return parser.parse_args(argv) - - -def main(argv: Sequence[str] | None = None) -> int: - args = parse_args(argv) - try: - checks = checks_for_profile(args.profile) - except ValueError as error: - raise SystemExit(f"validate-socket: {error}") from error - for check in checks: - run_check(check, dry_run=args.dry_run) - print( - f"\nSocket validation profile `{args.profile}` passed ({len(checks)} checks)." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate_socket_metadata.py b/scripts/validate_socket_metadata.py deleted file mode 100644 index 4a9fec908..000000000 --- a/scripts/validate_socket_metadata.py +++ /dev/null @@ -1,579 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -"""Validate root marketplace wiring for the socket superproject.""" - -from __future__ import annotations - -import json -import sys -import tomllib -from pathlib import Path -from typing import NoReturn, cast - - -REPO_ROOT = Path(__file__).resolve().parent.parent -MARKETPLACE_PATH = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" -GIT_SOURCE_KINDS = {"url", "git-subdir"} -INSTALLATION_POLICIES = {"AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"} -AUTHENTICATION_POLICIES = {"ON_INSTALL", "ON_FIRST_USE"} -MARKETPLACE_INTERFACE_ASSET_FIELDS = {"banner"} -PLUGIN_INTERFACE_ASSET_FIELDS = {"composerIcon", "logo"} -PLUGIN_INTERFACE_ASSET_LIST_FIELDS = {"screenshots"} -CUSTOM_AGENT_REQUIRED_FIELDS = {"name", "description", "developer_instructions"} -CUSTOM_AGENT_REVIEW_TERMS = ("draft", "review") -REVIEW_PACKET_AGENT_NAME_PARTS = ("steward", "auditor", "triager") -REVIEW_PACKET_AGENT_NAMES = {"skills-repo-guidance-sync"} -REVIEW_PACKET_AGENT_REPORT_TERMS = ("review packet", "proposed patch set", "validation handoff") -MCP_SERVER_TRANSPORT_FIELDS = ("command", "url") - - -def fail(message: str) -> NoReturn: - print(message, file=sys.stderr) - raise SystemExit(1) - - -def load_json(path: Path) -> object: - try: - return json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - fail(f"Required JSON file is missing: {path}") - except json.JSONDecodeError as exc: - fail(f"JSON file is invalid at {path}:{exc.lineno}:{exc.colno}: {exc.msg}") - - -def load_toml(path: Path) -> dict[str, object]: - try: - data = tomllib.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - fail(f"Required TOML file is missing: {path}") - except tomllib.TOMLDecodeError as exc: - fail(f"TOML file is invalid at {path}: {exc}") - if not isinstance(data, dict): - fail(f"TOML file must decode to an object: {path}") - return data - - -def validate_optional_git_selector( - *, - plugin_name: str, - source: dict[str, object], -) -> None: - ref = source.get("ref") - sha = source.get("sha") - if ref is not None and (not isinstance(ref, str) or not ref): - fail(f"Marketplace plugin `{plugin_name}` has an invalid Git source ref: {ref}") - if sha is not None and (not isinstance(sha, str) or not sha): - fail(f"Marketplace plugin `{plugin_name}` has an invalid Git source sha: {sha}") - if ref is not None and sha is not None: - fail(f"Marketplace plugin `{plugin_name}` must not set both Git source ref and sha.") - - -def validate_git_source( - *, - plugin_name: str, - source: dict[str, object], - source_kind: str, -) -> None: - url = source.get("url") - if not isinstance(url, str) or not url: - fail(f"Marketplace plugin `{plugin_name}` must define a non-empty Git source url.") - - validate_optional_git_selector(plugin_name=plugin_name, source=source) - - if source_kind == "url": - if "path" in source: - fail( - f"Marketplace plugin `{plugin_name}` uses a root Git source and must not " - "also define source.path. Use `git-subdir` for repository subdirectories." - ) - return - - path = source.get("path") - if not isinstance(path, str) or not path.startswith("./"): - fail( - f"Marketplace plugin `{plugin_name}` uses `git-subdir` and must define a " - f"`./...` source.path, but found `{path}`." - ) - - -def validate_marketplace_interface(marketplace: dict[str, object]) -> None: - interface = marketplace.get("interface") - if not isinstance(interface, dict): - fail("Root marketplace must define an `interface` object.") - - display_name = interface.get("displayName") - if not isinstance(display_name, str) or not display_name: - fail("Root marketplace interface must define a non-empty `displayName`.") - - for field_name in MARKETPLACE_INTERFACE_ASSET_FIELDS: - field_value = interface.get(field_name) - if field_value is None: - continue - if not isinstance(field_value, str) or not field_value.startswith("./"): - fail( - f"Root marketplace interface `{field_name}` must use a repo-relative " - f"`./...` path, but found `{field_value}`." - ) - - asset_path = (REPO_ROOT / field_value).resolve() - try: - asset_path.relative_to(REPO_ROOT.resolve()) - except ValueError: - fail( - f"Root marketplace interface `{field_name}` points outside the " - f"repository root: {field_value}" - ) - if not asset_path.is_file(): - fail( - f"Root marketplace interface `{field_name}` points at a missing file: " - f"{field_value}" - ) - - -def validate_manifest_path( - *, - plugin_name: str, - plugin_root: Path, - field_name: str, - field_value: object, - expected_kind: str, -) -> Path: - if not isinstance(field_value, str) or not field_value.startswith("./"): - fail( - f"Packaged plugin manifest for `{plugin_name}` must use a root-relative " - f"`./...` `{field_name}` path, but found `{field_value}`." - ) - - component_path = (plugin_root / field_value).resolve() - try: - component_path.relative_to(plugin_root) - except ValueError: - fail( - f"Packaged plugin manifest for `{plugin_name}` points `{field_name}` outside " - f"its plugin root: {field_value}" - ) - - if expected_kind == "directory" and not component_path.is_dir(): - fail( - f"Packaged plugin manifest for `{plugin_name}` points `{field_name}` at a " - f"missing directory: {component_path.relative_to(REPO_ROOT)}." - ) - if expected_kind == "file" and not component_path.is_file(): - fail( - f"Packaged plugin manifest for `{plugin_name}` points `{field_name}` at a " - f"missing file: {component_path.relative_to(REPO_ROOT)}." - ) - - return component_path - - -def validate_policy(*, plugin_name: str, entry: dict[str, object]) -> str: - policy = entry.get("policy") - if not isinstance(policy, dict): - fail(f"Marketplace plugin `{plugin_name}` is missing its `policy` object.") - - installation = policy.get("installation") - if installation not in INSTALLATION_POLICIES: - allowed = ", ".join(sorted(INSTALLATION_POLICIES)) - fail( - f"Marketplace plugin `{plugin_name}` has invalid policy.installation " - f"`{installation}`. Expected one of: {allowed}." - ) - - authentication = policy.get("authentication") - if authentication not in AUTHENTICATION_POLICIES: - allowed = ", ".join(sorted(AUTHENTICATION_POLICIES)) - fail( - f"Marketplace plugin `{plugin_name}` has invalid policy.authentication " - f"`{authentication}`. Expected one of: {allowed}." - ) - - category = entry.get("category") - if not isinstance(category, str) or not category: - fail(f"Marketplace plugin `{plugin_name}` must define a non-empty category.") - - return installation - - -def manifest_exports_content(*, plugin_root: Path, plugin_manifest: dict[str, object]) -> bool: - skills_path = plugin_manifest.get("skills") - if isinstance(skills_path, str): - skills_root = (plugin_root / skills_path).resolve() - try: - skills_root.relative_to(plugin_root) - except ValueError: - return False - if skills_root.is_dir() and any(skills_root.glob("*/SKILL.md")): - return True - - for field_name in ("mcpServers", "hooks", "apps"): - if plugin_manifest.get(field_name) is not None: - return True - - return False - - -def validate_mcp_server_entry(*, plugin_name: str, server_name: str, server_config: object) -> None: - if not isinstance(server_config, dict): - fail( - f"MCP server `{server_name}` for `{plugin_name}` must be a JSON object, " - f"but found `{server_config}`." - ) - - transport_fields = [field for field in MCP_SERVER_TRANSPORT_FIELDS if field in server_config] - if len(transport_fields) != 1: - allowed = " or ".join(MCP_SERVER_TRANSPORT_FIELDS) - fail( - f"MCP server `{server_name}` for `{plugin_name}` must define exactly one " - f"transport field, {allowed}." - ) - - for field_name in transport_fields: - field_value = server_config[field_name] - if not isinstance(field_value, str) or not field_value: - fail( - f"MCP server `{server_name}` for `{plugin_name}` has an invalid " - f"`{field_name}` value: {field_value}." - ) - - -def validate_mcp_config(*, plugin_name: str, mcp_config_path: Path, mcp_config: object) -> None: - if not isinstance(mcp_config, dict): - fail( - f"MCP server configuration for `{plugin_name}` must decode to a JSON object: " - f"{mcp_config_path.relative_to(REPO_ROOT)}" - ) - - if "mcpServers" in mcp_config: - servers = mcp_config["mcpServers"] - if not isinstance(servers, dict) or not servers: - fail( - f"MCP server configuration for `{plugin_name}` must define a non-empty " - f"`mcpServers` object: {mcp_config_path.relative_to(REPO_ROOT)}" - ) - else: - servers = mcp_config - if not servers: - fail( - f"MCP server configuration for `{plugin_name}` must define at least one " - f"server: {mcp_config_path.relative_to(REPO_ROOT)}" - ) - - for server_name, server_config in servers.items(): - if not isinstance(server_name, str) or not server_name: - fail( - f"MCP server configuration for `{plugin_name}` has an invalid server " - f"name: {server_name}." - ) - validate_mcp_server_entry( - plugin_name=plugin_name, - server_name=server_name, - server_config=server_config, - ) - - -def validate_plugin_interface_assets( - *, - plugin_name: str, - plugin_root: Path, - plugin_manifest: dict[str, object], -) -> None: - interface = plugin_manifest.get("interface") - if interface is None: - return - if not isinstance(interface, dict): - fail(f"Packaged plugin manifest for `{plugin_name}` has an invalid `interface` object.") - - for field_name in PLUGIN_INTERFACE_ASSET_FIELDS: - field_value = interface.get(field_name) - if field_value is None: - continue - validate_manifest_path( - plugin_name=plugin_name, - plugin_root=plugin_root, - field_name=f"interface.{field_name}", - field_value=field_value, - expected_kind="file", - ) - - for field_name in PLUGIN_INTERFACE_ASSET_LIST_FIELDS: - field_value = interface.get(field_name) - if field_value is None: - continue - if not isinstance(field_value, list): - fail( - f"Packaged plugin manifest for `{plugin_name}` must define " - f"`interface.{field_name}` as a list of repo-relative paths." - ) - for index, item in enumerate(field_value): - validate_manifest_path( - plugin_name=plugin_name, - plugin_root=plugin_root, - field_name=f"interface.{field_name}[{index}]", - field_value=item, - expected_kind="file", - ) - - -def validate_custom_agent_file(*, plugin_name: str, agent_path: Path) -> str: - agent = load_toml(agent_path) - relative_path = agent_path.relative_to(REPO_ROOT) - - for field_name in sorted(CUSTOM_AGENT_REQUIRED_FIELDS): - field_value = agent.get(field_name) - if not isinstance(field_value, str) or not field_value.strip(): - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` must define a " - f"non-empty `{field_name}` string." - ) - - agent_name = cast(str, agent["name"]) - if agent_path.stem != agent_name: - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` declares name " - f"`{agent_name}` but the file stem is `{agent_path.stem}`." - ) - - sandbox_mode = agent.get("sandbox_mode") - if sandbox_mode != "read-only": - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` must use " - '`sandbox_mode = "read-only"` until write-capable steward workflows ' - "have an explicit apply contract." - ) - - model = agent.get("model") - if model is not None and (not isinstance(model, str) or not model.strip()): - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` must define " - "`model` as a non-empty string when present." - ) - - instructions = cast(str, agent["developer_instructions"]) - lowered_instructions = instructions.lower() - for term in CUSTOM_AGENT_REVIEW_TERMS: - if term not in lowered_instructions: - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` must mention " - f"`{term}` in developer_instructions so draft-patch output stays " - "review-oriented." - ) - - if agent_name in REVIEW_PACKET_AGENT_NAMES or any( - name_part in agent_name for name_part in REVIEW_PACKET_AGENT_NAME_PARTS - ): - for term in REVIEW_PACKET_AGENT_REPORT_TERMS: - if term not in lowered_instructions: - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` must " - f"mention `{term}` in developer_instructions so draft-patch output " - "uses the shared review-packet contract." - ) - - nickname_candidates = agent.get("nickname_candidates") - if nickname_candidates is not None: - if not isinstance(nickname_candidates, list) or not nickname_candidates: - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` must define " - "`nickname_candidates` as a non-empty list when present." - ) - for index, nickname in enumerate(nickname_candidates): - if not isinstance(nickname, str) or not nickname.strip(): - fail( - f"Custom agent `{relative_path}` for `{plugin_name}` has an invalid " - f"`nickname_candidates[{index}]` value." - ) - - return agent_name - - -def validate_custom_agents(*, plugin_name: str, plugin_root: Path) -> None: - agents_root = plugin_root / ".codex" / "agents" - if not agents_root.exists(): - return - if not agents_root.is_dir(): - fail( - f"Custom agent path for `{plugin_name}` must be a directory: " - f"{agents_root.relative_to(REPO_ROOT)}" - ) - - agent_paths = sorted(agents_root.glob("*.toml")) - if not agent_paths: - fail( - f"Custom agent directory for `{plugin_name}` has no TOML files: " - f"{agents_root.relative_to(REPO_ROOT)}" - ) - - seen_names: set[str] = set() - for agent_path in agent_paths: - agent_name = validate_custom_agent_file(plugin_name=plugin_name, agent_path=agent_path) - if agent_name in seen_names: - fail(f"Custom agent name `{agent_name}` is duplicated for `{plugin_name}`.") - seen_names.add(agent_name) - - -def validate_local_plugin_entry( - *, - name: str, - source: dict[str, object], - installation_policy: str, -) -> None: - relative_path = source.get("path") - if not isinstance(relative_path, str) or not relative_path.startswith("./"): - fail( - f"Marketplace plugin `{name}` must use a repo-relative `./...` source.path, " - f"but found `{relative_path}`." - ) - - plugin_root = (REPO_ROOT / relative_path).resolve() - try: - plugin_root.relative_to(REPO_ROOT.resolve()) - except ValueError: - fail( - f"Marketplace plugin `{name}` points outside the repository root: {relative_path}" - ) - - if not plugin_root.is_dir(): - fail( - f"Marketplace plugin `{name}` points at a missing packaged plugin directory: " - f"{relative_path}" - ) - - plugin_manifest_path = plugin_root / ".codex-plugin" / "plugin.json" - if not plugin_manifest_path.is_file(): - fail( - f"Marketplace plugin `{name}` is missing its packaged manifest at " - f"{plugin_manifest_path.relative_to(REPO_ROOT)}." - ) - - plugin_manifest = load_json(plugin_manifest_path) - if not isinstance(plugin_manifest, dict): - fail( - f"Packaged plugin manifest for `{name}` must decode to a JSON object: " - f"{plugin_manifest_path.relative_to(REPO_ROOT)}" - ) - - manifest_name = plugin_manifest.get("name") - if manifest_name != name: - fail( - f"Marketplace plugin `{name}` points at a packaged manifest that declares " - f"`{manifest_name}` instead." - ) - - if ( - installation_policy != "NOT_AVAILABLE" - and not manifest_exports_content(plugin_root=plugin_root, plugin_manifest=plugin_manifest) - ): - fail( - f"Marketplace plugin `{name}` is installable but does not export skills, " - "MCP servers, hooks, or apps. Empty placeholder plugins must use " - "policy.installation `NOT_AVAILABLE` until they ship content." - ) - - validate_plugin_interface_assets( - plugin_name=name, - plugin_root=plugin_root, - plugin_manifest=plugin_manifest, - ) - validate_custom_agents(plugin_name=name, plugin_root=plugin_root) - - skills_dir = plugin_root / "skills" - skills_path = plugin_manifest.get("skills") - if skills_dir.is_dir(): - if skills_path is None: - fail( - f"Packaged plugin manifest for `{name}` must expose its root skills " - f"directory with `\"skills\": \"./skills/\"`." - ) - if skills_path != "./skills/": - fail( - f"Packaged plugin manifest for `{name}` must expose its root skills " - f"directory with `\"skills\": \"./skills/\"`, but found `{skills_path}`." - ) - validate_manifest_path( - plugin_name=name, - plugin_root=plugin_root, - field_name="skills", - field_value=skills_path, - expected_kind="directory", - ) - elif skills_path is not None: - validate_manifest_path( - plugin_name=name, - plugin_root=plugin_root, - field_name="skills", - field_value=skills_path, - expected_kind="directory", - ) - - mcp_servers_path = plugin_manifest.get("mcpServers") - if mcp_servers_path is not None: - mcp_config_path = validate_manifest_path( - plugin_name=name, - plugin_root=plugin_root, - field_name="mcpServers", - field_value=mcp_servers_path, - expected_kind="file", - ) - mcp_config = load_json(mcp_config_path) - validate_mcp_config(plugin_name=name, mcp_config_path=mcp_config_path, mcp_config=mcp_config) - - -def validate_plugin_entry(entry: object, seen_names: set[str]) -> None: - if not isinstance(entry, dict): - fail("Each marketplace plugin entry must be a JSON object.") - - name = entry.get("name") - if not isinstance(name, str) or not name: - fail("Each marketplace plugin entry must define a non-empty string `name`.") - if name in seen_names: - fail(f"Marketplace plugin name `{name}` is duplicated.") - seen_names.add(name) - - installation_policy = validate_policy(plugin_name=name, entry=entry) - - source = entry.get("source") - if not isinstance(source, dict): - fail(f"Marketplace plugin `{name}` is missing its `source` object.") - source_kind = source.get("source") - if source_kind == "local": - validate_local_plugin_entry( - name=name, - source=source, - installation_policy=installation_policy, - ) - return - if source_kind in GIT_SOURCE_KINDS: - validate_git_source(plugin_name=name, source=source, source_kind=source_kind) - return - - fail( - f"Marketplace plugin `{name}` must use a supported source kind " - f"(`local`, `url`, or `git-subdir`), but found `{source_kind}`." - ) - - -def main() -> None: - print("Validating root marketplace presence...") - marketplace = load_json(MARKETPLACE_PATH) - if not isinstance(marketplace, dict): - fail("Root marketplace must decode to a JSON object.") - - validate_marketplace_interface(marketplace) - - plugins = marketplace.get("plugins") - if not isinstance(plugins, list) or not plugins: - fail("Root marketplace must contain a non-empty `plugins` array.") - - print("Validating marketplace entries...") - seen_names: set[str] = set() - for entry in plugins: - validate_plugin_entry(entry, seen_names) - - print("Socket marketplace validation passed.") - - -if __name__ == "__main__": - main() diff --git a/scripts/validate_socket_skill_metadata.py b/scripts/validate_socket_skill_metadata.py deleted file mode 100644 index 97a1cfa0c..000000000 --- a/scripts/validate_socket_skill_metadata.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -"""Validate shared skill and child-plugin metadata contracts across Socket.""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path -from typing import Any, NoReturn - -import yaml - - -REPO_ROOT = Path(__file__).resolve().parent.parent -SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -MAX_DESCRIPTION_LENGTH = 1024 -OPTIONAL_INTERFACE_FIELDS = ("display_name", "short_description") - - -def fail(message: str) -> NoReturn: - print(f"validate-socket-skill-metadata: {message}", file=sys.stderr) - raise SystemExit(1) - - -def load_yaml(path: Path) -> dict[str, Any]: - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as error: - fail(f"{path.relative_to(REPO_ROOT)} is not valid YAML: {error}") - if not isinstance(value, dict): - fail(f"{path.relative_to(REPO_ROOT)} must decode to a YAML mapping.") - return value - - -def read_frontmatter(path: Path) -> dict[str, Any]: - contents = path.read_text(encoding="utf-8") - if not contents.startswith("---\n"): - fail(f"{path.relative_to(REPO_ROOT)} must begin with YAML frontmatter.") - raw_frontmatter, separator, _ = contents[4:].partition("\n---\n") - if not separator: - fail(f"{path.relative_to(REPO_ROOT)} has unterminated YAML frontmatter.") - try: - value = yaml.safe_load(raw_frontmatter) - except yaml.YAMLError as error: - fail(f"{path.relative_to(REPO_ROOT)} has invalid YAML frontmatter: {error}") - if not isinstance(value, dict): - fail(f"{path.relative_to(REPO_ROOT)} frontmatter must be a YAML mapping.") - return value - - -def validate_skill(path: Path) -> None: - metadata = read_frontmatter(path) - expected_name = path.parent.name - name = metadata.get("name") - if name != expected_name: - fail( - f"{path.relative_to(REPO_ROOT)} must use its directory name " - f"{expected_name!r}, but found {name!r}." - ) - if not isinstance(name, str) or not SKILL_NAME_RE.fullmatch(name): - fail(f"{path.relative_to(REPO_ROOT)} must use a lowercase kebab-case skill name.") - description = metadata.get("description") - if not isinstance(description, str) or not description.strip(): - fail(f"{path.relative_to(REPO_ROOT)} must define a non-empty description.") - if len(description) > MAX_DESCRIPTION_LENGTH: - fail( - f"{path.relative_to(REPO_ROOT)} description exceeds " - f"{MAX_DESCRIPTION_LENGTH} characters." - ) - - openai_metadata = path.parent / "agents" / "openai.yaml" - if openai_metadata.exists(): - validate_openai_interface(openai_metadata, expected_name) - - -def validate_openai_interface(path: Path, skill_name: str) -> None: - metadata = load_yaml(path) - interface = metadata.get("interface") - if not isinstance(interface, dict) or not interface: - fail(f"{path.relative_to(REPO_ROOT)} must define a non-empty interface mapping.") - - default_prompt = interface.get("default_prompt") - if not isinstance(default_prompt, str) or not default_prompt.strip(): - fail(f"{path.relative_to(REPO_ROOT)} must define a non-empty interface.default_prompt.") - if f"${skill_name}" not in default_prompt: - fail( - f"{path.relative_to(REPO_ROOT)} interface.default_prompt must include " - f"the ${skill_name} invocation token." - ) - - for field_name in OPTIONAL_INTERFACE_FIELDS: - value = interface.get(field_name) - if value is not None and (not isinstance(value, str) or not value.strip()): - fail(f"{path.relative_to(REPO_ROOT)} interface.{field_name} must be a non-empty string.") - - -def main() -> int: - plugin_roots = sorted( - path.parent.parent - for path in REPO_ROOT.glob("plugins/*/.codex-plugin/plugin.json") - if path.is_file() - ) - skill_paths = sorted(REPO_ROOT.glob("plugins/*/skills/*/SKILL.md")) - if not skill_paths: - fail("No authored plugin SKILL.md files were found.") - for skill_path in skill_paths: - validate_skill(skill_path) - - print( - "Socket shared skill metadata validation passed " - f"({len(plugin_roots)} plugins, {len(skill_paths)} skills)." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills.sh.json b/skills.sh.json index 5ff51e9e8..9c3574236 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -125,9 +125,6 @@ { "title": "Python Skills", "skills": [ - "build-python-agent-service", - "fastapi-service-workflow", - "fastmcp-service-workflow", "python-testing-workflow" ] }, diff --git a/skills/app-extension-architecture-workflow/SKILL.md b/skills/app-extension-architecture-workflow/SKILL.md index 24cfc5f0d..d423a7bf1 100644 --- a/skills/app-extension-architecture-workflow/SKILL.md +++ b/skills/app-extension-architecture-workflow/SKILL.md @@ -119,12 +119,6 @@ It owns extension-point routing, target and process boundaries, activation, enti - Recommend `explore-apple-swift-docs` for a current Apple documentation pass before choosing an unfamiliar extension point. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable project-structure policy after the extension plan is settled. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the shared customization-file contract. This workflow has no runtime-enforced settings because extension-point and entitlement choices must stay evidence-driven for each app. - ## References ### Workflow References @@ -132,7 +126,6 @@ Use `references/customization-flow.md`. - `references/extension-points-targets-and-lifecycle.md` - `references/entitlements-shared-containers-and-data-flow.md` - `references/privacy-validation-signing-and-distribution.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -145,5 +138,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project guidance for containing-app and extension-target work. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/skills/app-extension-architecture-workflow/references/customization-flow.md b/skills/app-extension-architecture-workflow/references/customization-flow.md deleted file mode 100644 index e46c8d564..000000000 --- a/skills/app-extension-architecture-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# App Extension Architecture Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without turning extension-point, entitlement, or privacy decisions into persistent defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` maintains the common configuration shape. -- The workflow ignores persisted settings because every extension-point and capability decision needs current Apple documentation and project evidence. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after its stable behavior and safety boundary are documented. -3. Validate the YAML before persisting it. diff --git a/skills/app-extension-architecture-workflow/references/customization.template.yaml b/skills/app-extension-architecture-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/app-extension-architecture-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/app-extension-architecture-workflow/scripts/customization_config.py b/skills/app-extension-architecture-workflow/scripts/customization_config.py deleted file mode 100755 index 27ef917b5..000000000 --- a/skills/app-extension-architecture-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-extension-control-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/bootstrap-skills-plugin-repo/SKILL.md b/skills/bootstrap-skills-plugin-repo/SKILL.md deleted file mode 100644 index abcbd55ca..000000000 --- a/skills/bootstrap-skills-plugin-repo/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: bootstrap-skills-plugin-repo -description: Bootstrap or align a source-first Agent Skills repository with root `skills/`, discovery mirrors, maintainer docs, and explicit Codex plugin boundaries. Use for new skills repos or structural alignment, not narrow docs or host-adapter work. -metadata: - hermes: - category: agent-portability - tags: [agent-skills, codex, plugin, portability] ---- - -# Bootstrap Skills Plugin Repo - -Bootstrap or align a source-first Agent Skills repository. - -This is the Codex-ready bootstrap workflow inside Agent Portability Skills. Use it for the shared skills repository shape first, then hand off to future host-adapter workflows when a target such as Zed Agent, Xcode, OpenCode, or Claude Code needs additional package or config decisions. - -## Codex Model Note - -State plainly that OpenAI's documented Codex plugin system exposes repo-visible plugins through marketplace catalogs and does not document a richer repo-private scoping model beyond that. This repository pattern allows root `.codex-plugin` packaging. Do not normalize nested staged plugin directories or installer-era helper workflows for this repo family. - -Before adding detailed guidance about Codex Plugins, Skills, MCP, Hooks, marketplaces, or subagents, refresh the relevant OpenAI Codex docs. Keep generated repo guidance focused on durable local policy and link to the official docs for details that can drift. - -## Codex Plugin Root Structure - -When bootstrapping or aligning a plugin repo, follow the current OpenAI plugin structure: - -- every plugin has a manifest at `.codex-plugin/plugin.json` -- only `plugin.json` belongs in `.codex-plugin/` -- `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` belong at the plugin root -- plugin manifests should point to bundled skill folders with `"skills": "./skills/"` -- plugin manifests may point to bundled lifecycle hooks with `"hooks": "./hooks/hooks.json"`; if hooks live at `./hooks/hooks.json`, Codex checks that default path automatically -- plugin-bundled hooks are non-managed hooks, so installing or enabling a plugin does not make those hooks trusted automatically -- marketplace `source.path` should point at the plugin root directory - -## Dependency Provenance - -When creating or aligning `AGENTS.md`, include strict dependency guidance: - -- shared project dependencies must resolve from GitHub repository URLs, package managers, package registries, or other real remote repositories -- committed dependency declarations, lockfiles, scripts, docs, examples, generated project files, and CI config must not point at machine-local paths -- machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly - -## Codex Subagent Guidance - -For existing repositories that need broad guidance drift discovery before edits, prefer `sync-skills-repo-guidance` and its `skills-repo-guidance-sync` custom-agent role. Use this bootstrap skill for new repository structure or structural alignment after the main thread has reviewed any audit or subagent findings. - -When creating or aligning skills that can benefit from parallel support work, add optional `Codex Subagent Fit` guidance that matches OpenAI's current Codex subagent docs: - -- Current Codex releases enable subagent workflows by default, but Codex only spawns subagents when there is an explicit trigger: the user asks for subagents or parallel agent work, or a narrower skill/plugin workflow instructs the agent to ask first and the user grants explicit permission. -- Built-in agents include `default`, `worker`, and `explorer`; mention project-scoped custom agents under `.codex/agents/` only when the repo intentionally owns agent configuration. -- Good fits are bounded read-heavy discovery, docs pulling, tests, triage, log analysis, and summarization. -- Subagents should return concise findings, evidence, links, or file references instead of raw intermediate output. -- Apply-mode or implementation edits should stay in the main thread unless the user explicitly asks for parallel implementation and each worker has a disjoint write scope. -- Plugin-specific guidance can be stricter. For example, Codex Security repository-wide scans may require asking for subagent use because the scan quality depends on parallel file-pass review. - -Do not add subagent guidance to every skill by default. Use -`references/codex-subagent-skill-guidance.md` to decide whether the target skill -has real parallelizable support work. - -## Codex Install Guidance - -Bootstrap docs should make the Git-backed marketplace path the default user install/update story: - -```bash -codex plugin marketplace add <owner>/<repo> -codex plugin marketplace upgrade <marketplace-name> -``` - -Use explicit refs such as `<owner>/<repo>@vX.Y.Z` only for pinned reproducible installs. Use manual local marketplace or copied-payload instructions only for local development, unpublished testing, or fallback cases. - -Keep discovery mirrors, plugin packaging, marketplace catalogs, plugin payload directories, installed cache paths, and config-state separate. Do not blur "where Codex can see a plugin", "where the plugin payload lives", "how Codex updates the marketplace", and "whether the plugin is enabled" into one sentence. - -If you mention project-scoped `.codex/config.toml`, describe it as a general Codex config surface from the config reference, not as a separate documented plugin install surface. - -## GitHub Repository Settings - -When the bootstrapped repository has a GitHub remote, use -`repository-skills:maintain-github-repository` to audit or apply the current -recommended GitHub repository settings. Keep local structure bootstrap separate -from server-side settings mutation, keep visibility changes approval-gated, and -preserve any documented maintainer direct-push workflow. - -The GitHub settings pass should cover repository features, merge modes, -Dependabot and security settings, private vulnerability reporting for public -repos, web commit sign-off when DCO applies, and branch protection that requires -the actual CI check context without requiring unavailable reviewers. diff --git a/skills/bootstrap-skills-plugin-repo/agents/openai.yaml b/skills/bootstrap-skills-plugin-repo/agents/openai.yaml deleted file mode 100644 index cfb87e6ed..000000000 --- a/skills/bootstrap-skills-plugin-repo/agents/openai.yaml +++ /dev/null @@ -1,2 +0,0 @@ -interface: - default_prompt: "Use $bootstrap-skills-plugin-repo to audit a skills-export repository first, then create or align the source-first repo structure with root `skills/`, root `.codex-plugin` packaging, repo-local discovery mirrors, maintainer docs, AGENTS guidance, Git-backed marketplace install/update guidance, and clear Codex plugin-boundary wording. Refresh current OpenAI Codex docs before making plugin, skill, MCP, hooks, marketplace, or subagent policy claims. Do not recreate nested staged plugin directories, manual-first local install stories, installer workflows, or install-validation workflows for this repo family." diff --git a/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md b/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md deleted file mode 100644 index 8b1fb2c0e..000000000 --- a/skills/bootstrap-skills-plugin-repo/references/bootstrap-contract.md +++ /dev/null @@ -1,16 +0,0 @@ -# Bootstrap Contract - -A bootstrapped skills-export repository should include: - -- root `skills/` -- `.agents/skills -> ../skills` -- `README.md` -- `AGENTS.md` -- `ROADMAP.md` -- `docs/maintainers/reality-audit.md` -- maintainer Python tooling guidance -- strict dependency-provenance guidance in `AGENTS.md` requiring shared dependencies to resolve from GitHub, package managers, package registries, or other real remote repositories -- an explicit `AGENTS.md` prohibition on machine-local dependency paths in public or publicly shared projects -- an explicit instruction to refresh current OpenAI Codex docs before changing plugin, skill, MCP, hooks, marketplace, or subagent guidance - -It may include root `.codex-plugin` packaging. When it does, `.codex-plugin/plugin.json` should point at bundled skills with `"skills": "./skills/"`. User-facing install and update examples should default to Git-backed marketplace sources and official marketplace add/upgrade commands. It should not include a nested staged plugin directory, manual-first local install story, installer skill, or install-validation skill for itself. diff --git a/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md b/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md deleted file mode 100644 index 1ead0f734..000000000 --- a/skills/bootstrap-skills-plugin-repo/references/codex-subagent-skill-guidance.md +++ /dev/null @@ -1,62 +0,0 @@ -# Codex Subagent Skill Guidance - -Use this reference when bootstrapping or auditing guidance about Codex subagents -in skills-export and plugin-export repositories. It does not replace OpenAI's -Codex documentation; it records the narrow Socket house pattern for optional -subagent guidance. - -Date checked: 2026-07-19. - -## Official Model - -- Codex calls delegated agents `subagents` and their coordinated use a - `subagent workflow`. -- Subagent workflows require an explicit trigger: the user asks for subagents - or parallel work, or narrower workflow guidance asks first and the user grants - permission. -- Built-in roles include `default`, `worker`, and `explorer`. Project-scoped - custom roles belong under `.codex/agents/` only when the repository - intentionally owns that configuration. -- Bounded read-heavy discovery, tests, triage, log analysis, documentation - lookup, and summarization are the normal fit. -- Parallel writes require disjoint ownership because shared edits create merge - conflicts and coordination overhead. - -## What Skills Should Say - -Add a `Codex Subagent Fit` section only when the workflow has independently -useful support work. Good candidates include documentation verification, -metadata or packaging audits, broad codebase exploration, test or CI triage, -and migration checks with separate evidence surfaces. - -Avoid subagent guidance for narrow single-file changes, one sequential command, -workflows where each output determines the next input, or tightly bounded write -targets with no independent discovery phase. - -When subagent guidance is present, require: - -- bounded, independently useful jobs; -- concise findings, evidence, links, or file references instead of raw logs; -- main-thread ownership of apply-mode edits unless the user explicitly requests - parallel implementation with disjoint write scopes; -- local model choices only when a repository intentionally owns them, without - turning one role's choice into a global rule; and -- stricter plugin-specific policy when the owning workflow requires it. - -## Review Checklist - -Flag guidance that: - -- implies Codex delegates automatically; -- recommends delegation merely because work is long or complex; -- recommends parallel writes without separate ownership; -- hides token, latency, or coordination costs; -- requests raw exploratory dumps instead of distilled findings; or -- uses vague `multi-agent` wording where current Codex documentation uses - `subagent`. - -## Official References - -- [OpenAI Codex Subagents](https://developers.openai.com/codex/subagents) -- [OpenAI Codex Subagent concepts](https://developers.openai.com/codex/concepts/subagents) -- [OpenAI Codex subagent model guidance](https://learn.chatgpt.com/docs/agent-configuration/subagents#choosing-models-and-reasoning) diff --git a/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md b/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md deleted file mode 100644 index 9a48d49c4..000000000 --- a/skills/bootstrap-skills-plugin-repo/references/posix-symlink-policy.md +++ /dev/null @@ -1,7 +0,0 @@ -# POSIX Symlink Policy - -Use POSIX symlink mirrors for local source-skill discovery in this repo family: - -- `.agents/skills -> ../skills` - -Do not replace those mirrors with duplicate nested skill trees. diff --git a/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py b/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py deleted file mode 100644 index ba8bde120..000000000 --- a/skills/bootstrap-skills-plugin-repo/scripts/bootstrap_skills_plugin_repo.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -from __future__ import annotations - -import argparse -import json -import os -import sys -from dataclasses import asdict, dataclass -from pathlib import Path - - -EXACT_NO_FINDINGS = "No findings." - - -@dataclass -class Finding: - path: str - issue_id: str - message: str - - -def infer_plugin_name(repo_root: Path, explicit: str | None) -> str: - return explicit or repo_root.name - - -def expected_files(repo_root: Path, _plugin_name: str) -> dict[Path, str]: - return { - repo_root / ".gitignore": """.venv/ -__pycache__/ -.pytest_cache/ -*.pyc -""", - repo_root / "README.md": f"# {repo_root.name}\n\nInstallable maintainer skills for skills-export and plugin-export repositories.\n", - repo_root / "AGENTS.md": """# AGENTS.md - -Root `skills/` is canonical. - -Before changing Codex plugin, skill, MCP, hooks, marketplace, or subagent guidance, check the current OpenAI Codex docs. Keep repo guidance focused on durable local policy rather than copying the full upstream docs. - -Only `plugin.json` belongs in `.codex-plugin/`. Keep `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` at the plugin root. Plugin manifests should point to bundled skill folders with `"skills": "./skills/"`. - -Default user-facing Codex plugin install and update guidance to Git-backed marketplace sources with `codex plugin marketplace add <owner>/<repo>` and `codex plugin marketplace upgrade <marketplace-name>`. Explicit refs such as `<owner>/<repo>@vX.Y.Z` are for pinned reproducible installs. Manual local marketplace roots and copied plugin payloads are development, unpublished-testing, or fallback paths. - -Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories that another contributor can fetch. - -Do not commit dependency declarations, lockfiles, scripts, docs, examples, generated project files, or CI config that point at machine-local paths such as `/Users/...`, `~/...`, `../...`, local worktrees, or private checkout paths. - -Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly. If local integration is needed, keep it uncommitted or convert it to a tagged release, branch, or registry dependency before sharing. -""", - repo_root / "ROADMAP.md": "# Project Roadmap\n\n## Vision\n\n- Define the long-term outcome for this skills-export repository.\n", - repo_root / "docs" / "maintainers" / "reality-audit.md": "# Repo Reality Audit\n\nRoot `skills/` is canonical.\n", - } - - -def expected_symlinks(repo_root: Path, _plugin_name: str) -> dict[Path, str]: - return { - repo_root / ".agents" / "skills": "../skills", - } - - -def audit_repo(repo_root: Path, plugin_name: str) -> list[Finding]: - findings: list[Finding] = [] - for path in expected_files(repo_root, plugin_name): - if not path.exists(): - findings.append(Finding(str(path.relative_to(repo_root)), "missing-path", "Required bootstrap path is missing.")) - for path, target in expected_symlinks(repo_root, plugin_name).items(): - rel = str(path.relative_to(repo_root)) - if not path.exists() and not path.is_symlink(): - findings.append(Finding(rel, "missing-symlink", f"Expected symlink to {target}.")) - continue - if not path.is_symlink(): - findings.append(Finding(rel, "not-symlink", f"Expected POSIX symlink to {target}.")) - continue - actual_target = os.readlink(path) - if actual_target != target: - findings.append(Finding(rel, "wrong-symlink-target", f"Expected {target}, found {actual_target}.")) - if (repo_root / "plugins").exists(): - findings.append(Finding("plugins", "forbidden-path", "Nested plugin directories are forbidden for this repo model.")) - if (repo_root / ".agents" / "plugins" / "marketplace.json").exists(): - findings.append( - Finding( - ".agents/plugins/marketplace.json", - "forbidden-path", - "Repo marketplace files are forbidden for this repo model.", - ) - ) - return findings - - -def _ensure_parent(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - - -def apply_repo(repo_root: Path, plugin_name: str) -> tuple[list[dict[str, str]], list[str]]: - actions: list[dict[str, str]] = [] - created_paths: list[str] = [] - for path, content in expected_files(repo_root, plugin_name).items(): - if path.exists(): - continue - _ensure_parent(path) - path.write_text(content, encoding="utf-8") - actions.append({"action": "create-file", "path": str(path.relative_to(repo_root))}) - created_paths.append(str(path.relative_to(repo_root))) - for directory in [repo_root / "skills", repo_root / "docs" / "maintainers"]: - if directory.exists(): - continue - directory.mkdir(parents=True, exist_ok=True) - actions.append({"action": "create-dir", "path": str(directory.relative_to(repo_root))}) - created_paths.append(str(directory.relative_to(repo_root))) - for path, target in expected_symlinks(repo_root, plugin_name).items(): - if path.is_symlink() and os.readlink(path) == target: - continue - if path.exists() and not path.is_symlink(): - actions.append( - { - "action": "skip-existing-path", - "path": str(path.relative_to(repo_root)), - "reason": "Existing non-symlink path must be reviewed manually.", - } - ) - continue - _ensure_parent(path) - if path.is_symlink(): - path.unlink() - os.symlink(target, path) - actions.append({"action": "create-symlink", "path": str(path.relative_to(repo_root)), "target": target}) - created_paths.append(str(path.relative_to(repo_root))) - return actions, created_paths - - -def build_report(repo_root: Path, plugin_name: str, run_mode: str, findings: list[Finding], apply_actions: list[dict[str, str]], created_paths: list[str], errors: list[str]) -> dict[str, object]: - return { - "run_context": {"repo_root": str(repo_root), "plugin_name": plugin_name, "run_mode": run_mode}, - "findings": [asdict(item) for item in findings], - "apply_actions": apply_actions, - "created_paths": created_paths, - "errors": errors, - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--repo-root", required=True) - parser.add_argument("--run-mode", choices=("check-only", "apply"), required=True) - parser.add_argument("--plugin-name") - parser.add_argument("--print-md", action="store_true") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - repo_root = Path(args.repo_root).resolve() - if not repo_root.exists() or not repo_root.is_dir(): - print("Repository root does not exist or is not a directory.", file=sys.stderr) - return 1 - plugin_name = infer_plugin_name(repo_root, args.plugin_name) - errors: list[str] = [] - findings = audit_repo(repo_root, plugin_name) - apply_actions: list[dict[str, str]] = [] - created_paths: list[str] = [] - if args.run_mode == "apply": - apply_actions, created_paths = apply_repo(repo_root, plugin_name) - findings = audit_repo(repo_root, plugin_name) - report = build_report(repo_root, plugin_name, args.run_mode, findings, apply_actions, created_paths, errors) - if args.print_md and not findings and not apply_actions and not errors: - print(EXACT_NO_FINDINGS) - else: - print(json.dumps(report, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/bootstrap-xcode-workspace/SKILL.md b/skills/bootstrap-xcode-workspace/SKILL.md index a4316d7ff..ffc253855 100644 --- a/skills/bootstrap-xcode-workspace/SKILL.md +++ b/skills/bootstrap-xcode-workspace/SKILL.md @@ -13,7 +13,7 @@ materialized as the generated root `.xcodeproj`. `Apps/` contains platform-speci SwiftPM executables. This is the entrypoint for app-first, service-first, and combined products. -Run `scripts/run_workflow.py` before creating files. It generates the root +Run `scripts/run-workflow.fsx` before creating files. It generates the root XcodeGen project, creates the workspace wrapper, initializes the first local Swift package with SwiftPM, and installs the `xcode-workspace` maintenance profile through `repository-skills`. @@ -79,7 +79,7 @@ project migration entrypoint. ## Single-Path Workflow 1. Apply the Apple documentation gate through `explore-apple-swift-docs`. -2. Run `scripts/run_workflow.py --name <Name> --file-prefix <ABC>`. +2. Run `scripts/run-workflow.fsx --name <Name> --file-prefix <ABC>`. The default creates iOS and macOS targets, their Swift Testing and XCUITest bundles, plus `<Name>Core`. Start package-first with `--component-kind library` or service-first with @@ -141,7 +141,7 @@ project migration entrypoint. ## Guards and Stop Conditions - For an existing canonical workspace, run - `scripts/run_workflow.py --operation align --repo-root <root>` instead of + `scripts/run-workflow.fsx --operation align --repo-root <root>` instead of using a separate sync skill. It preserves local documentation and Justfile content outside Socket-managed markers. - Stop when a create destination product root is non-empty. @@ -171,7 +171,7 @@ project migration entrypoint. - When Xcode-only state is required, use the root workspace and Xcode workflows; otherwise run the nearest package operation directly. -## Customization +## Fixed Policy This skill intentionally has no independent customization template. Its explicit CLI inputs define product identity and component creation, while the generated diff --git a/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx b/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx new file mode 100644 index 000000000..33b7b0956 --- /dev/null +++ b/skills/bootstrap-xcode-workspace/scripts/run-workflow.fsx @@ -0,0 +1,72 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let has flag = args |> Array.contains flag +let value flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let operation = value "--operation" |> Option.defaultValue "create" +let name = value "--name" |> Option.orElseWith (fun () -> value "--component-name") +let repo = + match value "--repo-root", name with + | Some path, _ -> Path.GetFullPath path + | None, Some product -> Path.GetFullPath(Path.Combine(value "--destination" |> Option.defaultValue ".", product)) + | _ -> Path.GetFullPath "." +let dryRun = has "--dry-run" +let blocked message = + printfn "%s" (JsonSerializer.Serialize({| status = "blocked"; operation = operation; workspace_root = repo; error = message |}, JsonSerializerOptions(WriteIndented = true))) + exit 2 +let write (relative: string) (content: string) = + let path = Path.Combine(repo, relative) + Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore + File.WriteAllText(path, content.Replace("\r\n", "\n")) +let copyManaged (asset: string) (relative: string) = + let source = Path.Combine(__SOURCE_DIRECTORY__, "..", "assets", "managed-guidance", asset) + let target = Path.Combine(repo, relative) + Directory.CreateDirectory(Path.GetDirectoryName target) |> ignore + File.Copy(source, target, true) +let ensureCanonicalRoot () = + [ "Apps"; "Packages"; "Services"; "Configurations"; "docs"; "Scripts"; ".github/workflows" ] |> List.iter (fun path -> Directory.CreateDirectory(Path.Combine(repo, path)) |> ignore) + let product = name |> Option.defaultValue (DirectoryInfo(repo).Name) + write "project.yml" $"name: {product}\noptions:\n bundleIdPrefix: com.galewilliams\nconfigs:\n Debug: debug\n Staging: release\n Release: release\n AppStore: release\n DirectDistribution: release\n AltStore: release\ninclude:\n - path: Apps/apps-shared.yml\n - path: Packages/packages-shared.yml\n - path: Services/services-shared.yml\n" + write "Apps/apps-shared.yml" "targets: {}\n" + write "Packages/packages-shared.yml" "packages: {}\n" + write "Services/services-shared.yml" "packages: {}\n" + write $"{product}.xcworkspace/contents.xcworkspacedata" $"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace version=\"1.0\"><FileRef location=\"group:{product}.xcodeproj\"></FileRef></Workspace>\n" + copyManaged "AGENTS-root.md" "AGENTS.md" + copyManaged "AGENTS-apps.md" "Apps/AGENTS.md" + copyManaged "AGENTS-packages.md" "Packages/AGENTS.md" + copyManaged "AGENTS-services.md" "Services/AGENTS.md" + copyManaged "CONTRIBUTING.md" "CONTRIBUTING.md" + copyManaged "pre-commit" ".git/hooks/pre-commit" + write "justfile" "setup:\n xcodegen generate\n\nalign:\n dotnet fsi .socket/repo-maintenance/repo-maintenance.fsx sync\n xcodegen generate\n" +let addComponent () = + let componentName = value "--component-name" |> Option.defaultWith (fun () -> blocked "--component-name is required for add-component.") + let kind = value "--component-kind" |> Option.defaultWith (fun () -> blocked "--component-kind is required for add-component.") + match kind with + | "library" | "service" -> + let rootName = if kind = "library" then "Packages" else "Services" + let target = Path.Combine(rootName, componentName) + Directory.CreateDirectory(Path.Combine(repo, target, "Sources", componentName)) |> ignore + write (Path.Combine(target, "Package.swift")) $"// swift-tools-version: 6.2\nimport PackageDescription\nlet package = Package(name: \"{componentName}\", platforms: [.macOS(.v15)], products: [.library(name: \"{componentName}\", targets: [\"{componentName}\"])], targets: [.target(name: \"{componentName}\")])\n" + | "app" | "extension" -> + let target = Path.Combine("Apps", componentName) + Directory.CreateDirectory(Path.Combine(repo, target, "Sources")) |> ignore + let platform = value "--platform" |> Option.defaultValue "iOS" + write (Path.Combine(target, "target.yml")) $"targets:\n {componentName}:\n type: application\n platform: {platform}\n sources: [Sources]\n" + | other -> blocked $"Unsupported component kind: {other}" + +if operation = "create" && name.IsNone then blocked "--name is required for create." +if operation <> "create" && not (Directory.Exists repo) then blocked $"Repository does not exist: {repo}" +if operation = "create" && Directory.Exists repo && Directory.EnumerateFileSystemEntries(repo) |> Seq.isEmpty |> not then blocked $"Create destination is not empty: {repo}" +if operation = "adopt" && not (has "--apply") then + let components = Directory.GetFiles(repo, "Package.swift", SearchOption.AllDirectories) |> Array.map (fun path -> Path.GetRelativePath(repo, Path.GetDirectoryName path)) |> Array.sort + printfn "%s" (JsonSerializer.Serialize({| status = "success"; operation = operation; workspace_root = repo; components = components; migration_required = true; next_step = "Review the inventory, then rerun with --apply and an approved adoption map." |}, JsonSerializerOptions(WriteIndented = true))) +elif dryRun then + printfn "%s" (JsonSerializer.Serialize({| status = "success"; operation = operation; workspace_root = repo; dry_run = true; policy = "fixed-gale-workspace" |}, JsonSerializerOptions(WriteIndented = true))) +else + if operation = "create" || operation = "align" || operation = "adopt" then ensureCanonicalRoot () + if operation = "add-component" then addComponent () + printfn "%s" (JsonSerializer.Serialize({| status = "success"; operation = operation; workspace_root = repo; policy = "fixed-gale-workspace"; next_step = "Run just setup, then use just align for managed refreshes." |}, JsonSerializerOptions(WriteIndented = true))) diff --git a/skills/bootstrap-xcode-workspace/scripts/run_workflow.py b/skills/bootstrap-xcode-workspace/scripts/run_workflow.py deleted file mode 100755 index 959f2f4c6..000000000 --- a/skills/bootstrap-xcode-workspace/scripts/run_workflow.py +++ /dev/null @@ -1,1200 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# /// -"""Create one Apple product workspace with a root XcodeGen project.""" - -from __future__ import annotations - -import argparse -import json -import re -import shutil -import subprocess -from dataclasses import asdict, dataclass, field -from pathlib import Path -from typing import Any - -SUPPORTED_PLATFORMS = { - "ios": "iOS", - "macos": "macOS", - "tvos": "tvOS", - "watchos": "watchOS", - "visionos": "visionOS", -} -CONFIGURATIONS = ("Debug", "Staging", "Release", "AppStore", "DirectDistribution", "AltStore") -XCODE_PRODUCT_TYPES = { - "com.apple.product-type.application": "app", - "com.apple.product-type.app-extension": "extension", - "com.apple.product-type.extensionkit-extension": "extension", - "com.apple.product-type.bundle.unit-test": "test", - "com.apple.product-type.bundle.ui-testing": "ui-test", -} - - -@dataclass -class Component: - """One concrete repository component; never a whole-repository classification.""" - - name: str - kind: str - current_owner: str - proposed_destination: str - evidence: list[str] = field(default_factory=list) - dependencies: list[str] = field(default_factory=list) - host_target: str | None = None - platform: str | None = None - product_type: str | None = None - extension_point_identifier: str | None = None - owned_paths: list[str] = field(default_factory=list) - unresolved: list[str] = field(default_factory=list) - - -def write(path: Path, content: str, executable: bool = False) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - if executable: - path.chmod(0o755) - - -def version_sort_key(path: Path) -> tuple[int, ...]: - return tuple(int(part) if part.isdigit() else -1 for part in path.name.split(".")) - - -def maintain_project_repo_runner() -> Path: - candidates: list[Path] = [] - seen: set[Path] = set() - for root in Path(__file__).resolve().parents: - paths = [root / "repository-skills" / "skills" / "maintain-project-repo" / "scripts" / "run_workflow.py"] - version_root = root / "repository-skills" - if version_root.is_dir(): - paths.extend( - version / "skills" / "maintain-project-repo" / "scripts" / "run_workflow.py" - for version in sorted(version_root.iterdir(), key=version_sort_key, reverse=True) - ) - for path in paths: - resolved = path.resolve() - if resolved not in seen: - candidates.append(resolved) - seen.add(resolved) - for candidate in candidates: - if candidate.is_file(): - return candidate - searched = "\n".join(f"- {candidate}" for candidate in candidates) - raise RuntimeError( - "bootstrap-xcode-workspace needs repository-skills/maintain-project-repo to install " - f"workspace maintenance files. Searched:\n{searched}" - ) - - -def server_component_runner() -> Path: - candidates: list[Path] = [] - for parent in Path(__file__).resolve().parents: - plugin_root = parent / "server-side-swift" - candidates.append(plugin_root / "skills" / "workspace-service-component" / "scripts" / "run_workflow.py") - if plugin_root.is_dir(): - candidates.extend( - version / "skills" / "workspace-service-component" / "scripts" / "run_workflow.py" - for version in sorted(plugin_root.iterdir(), key=version_sort_key, reverse=True) - if version.is_dir() - ) - for candidate in candidates: - if candidate.is_file(): - return candidate - searched = "\n".join(f"- {candidate}" for candidate in candidates) - raise RuntimeError(f"Adding a service requires the server-side-swift workspace-service-component adapter from the Socket marketplace. Searched:\n{searched}") - - -def parser() -> argparse.ArgumentParser: - result = argparse.ArgumentParser(description=__doc__) - result.add_argument("--name") - result.add_argument("--file-prefix", default="APP") - result.add_argument("--destination", default=".") - result.add_argument("--platforms", default="ios,macos") - result.add_argument("--org-identifier", default="com.galewilliams") - result.add_argument("--development-team", default="BC73766F69") - result.add_argument("--dry-run", action="store_true") - result.add_argument("--skip-validation", action="store_true") - result.add_argument("--repo-root", help="Adopt, add to, or align an existing Swift repository.") - result.add_argument("--operation", choices=("create", "adopt", "add-component", "align"), default="create") - result.add_argument("--component-kind", choices=("app", "extension", "library", "service")) - result.add_argument("--component-name") - result.add_argument("--platform", choices=tuple(SUPPORTED_PLATFORMS)) - result.add_argument("--framework", choices=("hummingbird", "vapor")) - result.add_argument("--host-target", help="Containing application target for an extension component.") - result.add_argument("--extension-product-type", choices=("app-extension", "extensionkit-extension")) - result.add_argument("--extension-point-identifier", help="Documented NSExtensionPointIdentifier for an extension component.") - result.add_argument("--adoption-map", help="Reviewed adoption-map JSON to apply after the read-only adopt inventory.") - result.add_argument("--apply", action="store_true", help="Apply --adoption-map. Adopt is read-only without this flag.") - return result - - -def blocked(message: str, inputs: dict[str, object]) -> int: - print(json.dumps({"status": "blocked", "path_type": "primary", "normalized_inputs": inputs, "stderr": message}, indent=2, sort_keys=True)) - return 1 - - -MANAGED_BEGIN = "<!-- socket-managed:begin" -MANAGED_END = "<!-- socket-managed:end" -JUST_BEGIN = "# socket-managed:begin just-recipes" -JUST_END = "# socket-managed:end just-recipes" - - -def marker_state(content: str, begin: str = MANAGED_BEGIN, end: str = MANAGED_END) -> str: - begins, ends = content.count(begin), content.count(end) - if begins == 0 and ends == 0: - return "absent" - if begins == 1 and ends == 1 and content.index(begin) < content.index(end): - return "valid" - return "invalid" - - -def workspace_findings(root: Path, allow_missing_services: bool = False) -> list[str]: - findings: list[str] = [] - if len(list(root.glob("*.xcworkspace"))) != 1: - findings.append("Expected exactly one root .xcworkspace.") - if len(list(root.glob("*.xcodeproj"))) != 1: - findings.append("Expected exactly one generated root .xcodeproj.") - required = ["project.yml", "Apps/apps-shared.yml", "Apps/Apps-shared.xcconfig", "Packages/packages-shared.yml"] - if not allow_missing_services: - required.append("Services/services-shared.yml") - findings.extend(f"Expected {path}." for path in required if not (root / path).is_file()) - components = list((root / "Apps").glob("**/target.y*ml")) + list((root / "Packages").glob("**/Package.swift")) + list((root / "Services").glob("**/Package.swift")) - if not components: - findings.append("Expected at least one component under Apps/, Packages/, or Services/.") - return findings - - -def managed_recipe_block() -> str: - return "\n".join(( - "# socket-managed:begin just-recipes", - "# Socket owns this bounded setup/alignment contract. Add project recipes outside it.", - "setup:", " sh .socket/managed/setup.sh", "align:", " sh .socket/managed/align.sh", - "# socket-managed:end just-recipes", "", - )) - - -def setup_script() -> str: - return "\n".join(( - "#!/usr/bin/env sh", "set -eu", - 'for tool in git just swift xcodegen xcodebuild; do command -v "$tool" >/dev/null 2>&1 || { echo "Missing required tool: $tool" >&2; exit 1; }; done', - "git config core.hooksPath .githooks", "", - )) - - -def align_script() -> str: - return "\n".join(( - "#!/usr/bin/env sh", "set -eu", - "base=${SOCKET_TEMPLATE_BASE_URL:-https://raw.githubusercontent.com/gaelic-ghost/socket/main/plugins/apple-dev-skills/skills/bootstrap-xcode-workspace/assets/managed-guidance}", - "tmp=$(mktemp -d)", "trap 'rm -r \"$tmp\"' EXIT HUP INT TERM", - "for file in AGENTS-root.md AGENTS-apps.md AGENTS-packages.md AGENTS-services.md CONTRIBUTING.md pre-commit; do curl --fail --silent --show-error \"$base/$file\" -o \"$tmp/$file\"; done", - "for file in AGENTS-root.md AGENTS-apps.md AGENTS-packages.md AGENTS-services.md CONTRIBUTING.md; do [ \"$(grep -c 'socket-managed:begin' \"$tmp/$file\")\" -eq 1 ] && [ \"$(grep -c 'socket-managed:end' \"$tmp/$file\")\" -eq 1 ] || { echo \"just align: remote $file has invalid managed markers; no files were changed.\" >&2; exit 1; }; done", - "[ -s \"$tmp/pre-commit\" ] || { echo \"just align: remote pre-commit hook is empty; no files were changed.\" >&2; exit 1; }", - "for file in AGENTS.md Apps/AGENTS.md Packages/AGENTS.md Services/AGENTS.md CONTRIBUTING.md; do [ \"$(grep -c 'socket-managed:begin' \"$file\")\" -eq 1 ] && [ \"$(grep -c 'socket-managed:end' \"$file\")\" -eq 1 ] || { echo \"just align: $file has invalid managed markers; no files were changed.\" >&2; exit 1; }; done", - "replace() { source=$1; destination=$2; awk -v replacement=\"$source\" '/<!-- socket-managed:begin/ { while ((getline line < replacement) > 0) { print line; if (line ~ /<!-- socket-managed:end/) break }; in_managed=1; next } in_managed { if (/<!-- socket-managed:end/) in_managed=0; next } { print }' \"$destination\" > \"$tmp/out\"; mv \"$tmp/out\" \"$destination\"; }", - "replace \"$tmp/AGENTS-root.md\" AGENTS.md", "replace \"$tmp/AGENTS-apps.md\" Apps/AGENTS.md", "replace \"$tmp/AGENTS-packages.md\" Packages/AGENTS.md", "replace \"$tmp/AGENTS-services.md\" Services/AGENTS.md", "replace \"$tmp/CONTRIBUTING.md\" CONTRIBUTING.md", - "cp \"$tmp/pre-commit\" .githooks/pre-commit", "chmod +x .githooks/pre-commit", "git config core.hooksPath .githooks", "xcodegen generate --spec project.yml", "", - )) - - -def managed_document(source: Path, existing: str | None) -> str: - template = source.read_text(encoding="utf-8") - if existing is None: - return template - state = marker_state(existing) - if state == "invalid": - raise RuntimeError(f"{source.name} has malformed Socket managed markers.") - return existing + ("" if existing.endswith("\n") else "\n") + "\n" + template if state == "absent" else existing - - -def install_alignment_runtime(root: Path, dry_run: bool = False) -> list[str]: - assets = Path(__file__).resolve().parents[1] / "assets" / "managed-guidance" - docs = (("AGENTS-root.md", root / "AGENTS.md"), ("AGENTS-apps.md", root / "Apps/AGENTS.md"), ("AGENTS-packages.md", root / "Packages/AGENTS.md"), ("AGENTS-services.md", root / "Services/AGENTS.md"), ("CONTRIBUTING.md", root / "CONTRIBUTING.md")) - planned: dict[Path, tuple[str, bool]] = {} - for source_name, destination in docs: - if destination.exists() and not destination.is_file(): - raise RuntimeError(f"{destination.relative_to(root)} exists but is not a regular file.") - existing = destination.read_text(encoding="utf-8") if destination.exists() else None - planned[destination] = (managed_document(assets / source_name, existing), False) - justfile = root / "Justfile" - if justfile.exists() and not justfile.is_file(): - raise RuntimeError("Justfile exists but is not a regular file.") - existing = justfile.read_text(encoding="utf-8") if justfile.exists() else 'set shell := ["sh", "-eu", "-c"]\n' - state = marker_state(existing, JUST_BEGIN, JUST_END) - if state == "invalid": - raise RuntimeError("Justfile has malformed Socket managed recipe markers.") - planned[justfile] = ((existing.rstrip() + "\n\n" + managed_recipe_block()) if state == "absent" else existing, False) - for path, content in ((root / ".socket/managed/setup.sh", setup_script()), (root / ".socket/managed/align.sh", align_script())): - if path.exists() and path.read_text(encoding="utf-8") != content: - raise RuntimeError(f"{path.relative_to(root)} conflicts with the Socket-managed alignment helper.") - planned[path] = (content, True) - hook = root / ".githooks/pre-commit" - if hook.exists() and not hook.is_file(): - raise RuntimeError(".githooks/pre-commit exists but is not a regular file.") - planned[hook] = ((assets / "pre-commit").read_text(encoding="utf-8"), True) - if not dry_run: - for destination, (content, executable) in planned.items(): - write(destination, content, executable) - subprocess.run(["git", "config", "core.hooksPath", ".githooks"], cwd=root, check=False) - return [f"install Socket-managed alignment surface at {path.relative_to(root)}" for path in planned] - - -def root_spec(name: str, platforms: list[str]) -> str: - includes = [" - path: Apps/apps-shared.yml\n relativePaths: false", " - path: Packages/packages-shared.yml\n relativePaths: false", " - path: Services/services-shared.yml\n relativePaths: false"] - includes.extend(f" - path: Apps/{name}{SUPPORTED_PLATFORMS[platform]}/target.yml\n relativePaths: false" for platform in platforms) - return "\n".join([ - f"name: {name}", "include:", *includes, "options:", - " minimumXcodeGenVersion: 2.46.0", " projectFormat: xcode16_3", " defaultConfig: Debug", - " defaultSourceDirectoryType: syncedFolder", " schemePathPrefix: ../", " localPackagesGroup: Packages", - " deploymentTarget:", " iOS: \"26.1\"", " macOS: \"26.1\"", " tvOS: \"26.1\"", " watchOS: \"26.1\"", " visionOS: \"26.1\"", - "configs:", *(f" {config}: {'debug' if config == 'Debug' else 'release'}" for config in CONFIGURATIONS), "configFiles:", - *(f" {config}: Configurations/{config}.xcconfig" for config in CONFIGURATIONS), - "fileGroups:", " - Apps", " - Packages", " - Services", " - Configurations", " - Scripts", " - docs", "", - ]) - - -def app_shared_spec() -> str: - return """targetTemplates: - SwiftUIApp: - type: application - settings: - base: - GENERATE_INFOPLIST_FILE: NO - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - preBuildScripts: - - name: SwiftFormat and SwiftLint - script: | - if command -v swiftformat >/dev/null 2>&1; then - swiftformat --lint --config "${SRCROOT}/.swiftformat" "${SRCROOT}/Apps/${TARGET_NAME}" - else - echo "warning: SwiftFormat is not installed; skipping lint." - fi - if command -v swiftlint >/dev/null 2>&1; then - swiftlint lint --config "${SRCROOT}/.swiftlint.yml" --force-exclude "${SRCROOT}/Apps/${TARGET_NAME}" - else - echo "warning: SwiftLint is not installed; skipping lint." - fi - SwiftTesting: - type: bundle.unit-test - settings: - base: - GENERATE_INFOPLIST_FILE: YES - preBuildScripts: - - name: SwiftFormat and SwiftLint - script: | - if command -v swiftformat >/dev/null 2>&1; then swiftformat --lint --config "${SRCROOT}/.swiftformat" "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftFormat is not installed; skipping lint."; fi - if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --config "${SRCROOT}/.swiftlint.yml" --force-exclude "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftLint is not installed; skipping lint."; fi - SwiftUIAutomation: - type: bundle.ui-testing - settings: - base: - GENERATE_INFOPLIST_FILE: YES - preBuildScripts: - - name: SwiftFormat and SwiftLint - script: | - if command -v swiftformat >/dev/null 2>&1; then swiftformat --lint --config "${SRCROOT}/.swiftformat" "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftFormat is not installed; skipping lint."; fi - if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --config "${SRCROOT}/.swiftlint.yml" --force-exclude "${SRCROOT}/Apps/${TARGET_NAME}"; else echo "warning: SwiftLint is not installed; skipping lint."; fi -schemeTemplates: - AppScheme: - run: { config: Debug } - test: - config: Debug - gatherCoverageData: true - archive: { config: Staging } - management: { shared: true } -""" - - -def package_spec(name: str) -> str: - return f"""packages: - {name}Core: - path: Packages/{name}Core -""" - - -def target_spec(name: str, platform: str, prefix: str, org: str, team: str, core_package: str | None = None) -> str: - display = SUPPORTED_PLATFORMS[platform] - target = f"{name}{display}" - suffix = {"ios": "ios", "macos": "mac", "tvos": "tv", "watchos": "watch", "visionos": "vision"}[platform] - spec = f"""targets: - {target}: - templates: [SwiftUIApp] - platform: {display} - sources: - - path: Apps/{target}/Sources - type: syncedFolder - - path: Apps/{target}/Resources - type: syncedFolder - info: - path: Apps/{target}/Resources/Info.plist - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()}.{suffix} - DEVELOPMENT_TEAM: {team} - CODE_SIGN_STYLE: Automatic - CODE_SIGN_ENTITLEMENTS: Apps/{target}/Resources/{target}.entitlements - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor - configFiles: - Debug: Apps/{target}/Configurations/Debug.xcconfig - Staging: Apps/{target}/Configurations/Staging.xcconfig - Release: Apps/{target}/Configurations/Release.xcconfig - AppStore: Apps/{target}/Configurations/AppStore.xcconfig - DirectDistribution: Apps/{target}/Configurations/DirectDistribution.xcconfig - AltStore: Apps/{target}/Configurations/AltStore.xcconfig - dependencies: - - package: {core_package or name + 'Core'} - {target}Tests: - templates: [SwiftTesting] - platform: {display} - sources: - - path: Apps/{target}Tests/Sources - type: syncedFolder - dependencies: - - target: {target} - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()}.{suffix}.tests - DEVELOPMENT_TEAM: {team} - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor -schemes: - {target}: - templates: [AppScheme] - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: - {target}: all - test: - targets: - - name: {target}Tests - parallelizable: true -""" - if platform != "watchos": - spec = spec.replace("schemes:\n", f""" {target}UITests: - templates: [SwiftUIAutomation] - platform: {display} - sources: - - path: Apps/{target}UITests/Sources - type: syncedFolder - dependencies: - - target: {target} - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()}.{suffix}.uitests - DEVELOPMENT_TEAM: {team} - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor -schemes: - {target} UI Tests: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - test: - config: Debug - targets: - - name: {target}UITests - parallelizable: true -""") - channels = [("Staging", "Staging"), ("App Store", "AppStore")] - if platform in {"ios", "visionos"}: - channels.append(("AltStore", "AltStore")) - if platform == "macos": - channels.append(("Direct Distribution", "DirectDistribution")) - for title, config in channels: - spec += f""" {target} {title}: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - archive: {{ config: {config} }} -""" - spec += f""" {target} Unit Tests: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - test: - config: Debug - targets: - - name: {target}Tests - parallelizable: true - {target} All Tests: - preActions: - - name: Increment build number - script: 'sh "${{SRCROOT}}/Scripts/increment-build-version.sh" "${{TARGET_NAME}}" "${{CONFIGURATION}}"' - settingsTarget: {target} - build: - targets: {{ {target}: all }} - test: - config: Debug - targets: - - name: {target}Tests - parallelizable: true -""" - if platform != "watchos": - spec += f""" - name: {target}UITests - parallelizable: true -""" - return spec - - -def workspace_name(root: Path) -> str: - match = re.search(r"^name:\s*([A-Za-z][A-Za-z0-9]*)\s*$", (root / "project.yml").read_text(encoding="utf-8"), re.MULTILINE) - if not match: - raise RuntimeError("project.yml does not declare a canonical alphanumeric workspace name.") - return match.group(1) - - -def add_root_include(root: Path, relative_path: str) -> None: - project = root / "project.yml" - content = project.read_text(encoding="utf-8") - if f"path: {relative_path}" in content: - return - anchor = "options:\n" - if anchor not in content: - raise RuntimeError("project.yml is missing the options section used as the managed include boundary.") - include = f" - path: {relative_path}\n relativePaths: false\n" - write(project, content.replace(anchor, include + anchor, 1)) - - -def ensure_services_surface(root: Path, dry_run: bool = False) -> list[str]: - actions: list[str] = [] - shared = root / "Services/services-shared.yml" - if not shared.is_file(): - actions.append("create Services/services-shared.yml") - if not dry_run: - write(shared, "packages: {}\n") - project = (root / "project.yml").read_text(encoding="utf-8") - if "path: Services/services-shared.yml" not in project: - actions.append("register Services/services-shared.yml in project.yml") - if not dry_run: - add_root_include(root, "Services/services-shared.yml") - return actions - - -def add_package_mapping(root: Path, group: str, name: str) -> None: - shared = root / group / f"{group.lower()}-shared.yml" - content = shared.read_text(encoding="utf-8") - entry = f" {name}:\n path: {group}/{name}\n" - if f" {name}:\n" in content: - return - if content.strip() == "packages: {}": - content = "packages:\n" - elif not content.endswith("\n"): - content += "\n" - write(shared, content + entry) - - -def create_library_component(root: Path, name: str) -> None: - component = root / "Packages" / name - if component.exists(): - raise RuntimeError(f"Packages/{name} already exists.") - component.mkdir(parents=True) - subprocess.run(["swift", "package", "init", "--type", "library", "--name", name, "--enable-swift-testing"], cwd=component, check=True, capture_output=True, text=True) - add_package_mapping(root, "Packages", name) - - -def create_app_component(root: Path, product_name: str, component_name: str, platform: str, prefix: str, org: str, team: str) -> None: - display = SUPPORTED_PLATFORMS[platform] - target = f"{component_name}{display}" - app_root = root / "Apps" / target - if app_root.exists(): - raise RuntimeError(f"Apps/{target} already exists.") - add_root_include(root, f"Apps/{target}/target.yml") - write(app_root / "target.yml", target_spec(component_name, platform, prefix, org, team, f"{product_name}Core")) - write(app_root / "Configurations/App.xcconfig", '#include "../../Apps-shared.xcconfig"\nMARKETING_VERSION = 0.0.1\nCODE_SIGN_STYLE = Automatic\n') - write(app_root / "Configurations/Version.xcconfig", "DEBUG_BUILD_NUMBER = 1\nRELEASE_BUILD_NUMBER = 1\n") - for config in CONFIGURATIONS: - build_number = "$(DEBUG_BUILD_NUMBER)" if config == "Debug" else "$(RELEASE_BUILD_NUMBER)" - content = '#include "App.xcconfig"\n#include "Version.xcconfig"\nCURRENT_PROJECT_VERSION = ' + build_number + "\n" - if config == "Debug": - content += "ONLY_ACTIVE_ARCH = YES\n" - else: - content += "SWIFT_OPTIMIZATION_LEVEL = -O\n" - write(app_root / f"Configurations/{config}.xcconfig", content) - write(app_root / f"Sources/{prefix}App.swift", f'import SwiftUI\n\n@main\nstruct {prefix}{display}App: App {{\n var body: some Scene {{ WindowGroup {{ Text("{target}") }} }}\n}}\n') - for folder in ("Views", "Datamodels", "Services"): - write(app_root / f"Sources/{folder}/.gitkeep", "") - write(app_root / "Resources/Info.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string><key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string></dict></plist>\n') - write(app_root / f"Resources/{target}.entitlements", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>\n') - write(app_root / "Resources/Assets.xcassets/Contents.json", '{"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", '{"images":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AccentColor.colorset/Contents.json", '{"colors":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Localizable.xcstrings", '{"sourceLanguage":"en","strings":{},"version":"1.0"}\n') - tests_root = root / "Apps" / f"{target}Tests" - write(tests_root / f"Sources/{target}Tests.swift", f'import Testing\n@testable import {target}\n\n@Test func example() {{ #expect(true) }}\n') - if platform != "watchos": - ui_root = root / "Apps" / f"{target}UITests" - write(ui_root / f"Sources/{target}UITests.swift", f'import XCTest\n\nfinal class {target}UITests: XCTestCase {{\n func testLaunch() {{}}\n}}\n') - - -def find_target_spec(root: Path, target_name: str) -> Path | None: - declaration = re.compile(rf"^ {re.escape(target_name)}:\s*$", re.MULTILINE) - for path in sorted((root / "Apps").glob("*/target.y*ml")): - if declaration.search(path.read_text(encoding="utf-8")): - return path - return None - - -def embed_extension_in_host(root: Path, host_target: str, extension_target: str) -> None: - spec = find_target_spec(root, host_target) - if spec is None: - raise RuntimeError(f"Host application target {host_target!r} was not found under Apps/.") - content = spec.read_text(encoding="utf-8") - if f"- target: {extension_target}" in content: - return - lines = content.splitlines() - target_start = next((index for index, line in enumerate(lines) if line == f" {host_target}:"), None) - if target_start is None: - raise RuntimeError(f"Could not locate the {host_target!r} target declaration in {spec.relative_to(root)}.") - target_end = next((index for index in range(target_start + 1, len(lines)) if re.match(r"^ \S.*:\s*$", lines[index])), len(lines)) - dependencies = next((index for index in range(target_start + 1, target_end) if lines[index] == " dependencies:"), None) - entry = [f" - target: {extension_target}", " embed: true"] - if dependencies is None: - lines[target_end:target_end] = [" dependencies:", *entry] - else: - dependency_end = next((index for index in range(dependencies + 1, target_end) if re.match(r"^ \S.*:\s*$", lines[index])), target_end) - lines[dependency_end:dependency_end] = entry - write(spec, "\n".join(lines) + "\n") - - -def extension_target_spec( - name: str, - platform: str, - org: str, - team: str, - product_type: str, -) -> str: - display = SUPPORTED_PLATFORMS[platform] - xcodegen_type = "app-extension" if product_type == "app-extension" else "extensionkit-extension" - return f"""targets: - {name}: - type: {xcodegen_type} - platform: {display} - sources: - - path: Apps/{name}/Sources - type: syncedFolder - - path: Apps/{name}/Resources - type: syncedFolder - info: - path: Apps/{name}/Resources/Info.plist - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: {org}.{name.lower()} - DEVELOPMENT_TEAM: {team} - CODE_SIGN_STYLE: Automatic - CODE_SIGN_ENTITLEMENTS: Apps/{name}/Resources/{name}.entitlements - configFiles: - Debug: Apps/{name}/Configurations/Debug.xcconfig - Staging: Apps/{name}/Configurations/Staging.xcconfig - Release: Apps/{name}/Configurations/Release.xcconfig - AppStore: Apps/{name}/Configurations/AppStore.xcconfig - DirectDistribution: Apps/{name}/Configurations/DirectDistribution.xcconfig - AltStore: Apps/{name}/Configurations/AltStore.xcconfig -""" - - -def create_extension_component( - root: Path, - name: str, - platform: str, - host_target: str, - product_type: str, - extension_point_identifier: str, - org: str, - team: str, -) -> None: - extension_root = root / "Apps" / name - if extension_root.exists(): - raise RuntimeError(f"Apps/{name} already exists.") - add_root_include(root, f"Apps/{name}/target.yml") - write(extension_root / "target.yml", extension_target_spec(name, platform, org, team, product_type)) - write(extension_root / "Configurations/Extension.xcconfig", '#include "../../Apps-shared.xcconfig"\nMARKETING_VERSION = 0.0.1\nCODE_SIGN_STYLE = Automatic\n') - for config in CONFIGURATIONS: - write(extension_root / f"Configurations/{config}.xcconfig", '#include "Extension.xcconfig"\n') - write(extension_root / "Sources/Extension.swift", "import Foundation\n\n// Implement the documented extension-point entry type here.\n") - write( - extension_root / "Resources/Info.plist", - '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>NSExtension</key><dict><key>NSExtensionPointIdentifier</key><string>' - + extension_point_identifier - + "</string></dict></dict></plist>\n", - ) - write(extension_root / f"Resources/{name}.entitlements", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>\n') - embed_extension_in_host(root, host_target, name) - - -def relative(path: Path, root: Path) -> str: - try: - return str(path.relative_to(root)) - except ValueError: - return str(path) - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8", errors="replace") - except OSError: - return "" - - -def pbx_target_records(text: str) -> list[tuple[str, str | None]]: - records: list[tuple[str, str | None]] = [] - for match in re.finditer(r"isa = PBXNativeTarget;(?P<body>[\s\S]{0,1800}?)\s*};", text): - body = match.group("body") - name_match = re.search(r"\bname = (?P<name>[^;]+);", body) - product_match = re.search(r"\bproductType = (?P<type>[^;]+);", body) - if name_match: - records.append((name_match.group("name").strip().strip('"'), product_match.group("type").strip().strip('"') if product_match else None)) - return records - - -def pbx_extension_hosts(text: str, app_names: list[str], extension_names: list[str]) -> dict[str, str]: - hosts: dict[str, str] = {} - for extension in extension_names: - if re.search(rf"\b{re.escape(extension)}\.appex in Embed App Extensions\b", text) and len(app_names) == 1: - hosts[extension] = app_names[0] - return hosts - - -def xcodegen_target_records(text: str) -> list[tuple[str, str | None, str | None, str | None]]: - records: list[tuple[str, str | None, str | None, str | None]] = [] - targets_match = re.search(r"^targets:\s*$", text, re.MULTILINE) - if not targets_match: - return records - tail = text[targets_match.end():] - section_end = re.search(r"^\S[^:]*:\s*$", tail, re.MULTILINE) - section = tail[:section_end.start()] if section_end else tail - matches = list(re.finditer(r"^ (?P<name>[^\s][^:]*):\s*$", section, re.MULTILINE)) - for index, match in enumerate(matches): - body_end = matches[index + 1].start() if index + 1 < len(matches) else len(section) - body = section[match.end():body_end] - type_match = re.search(r"^ type:\s*([^\s#]+)", body, re.MULTILINE) - platform_match = re.search(r"^ platform:\s*([^\s#]+)", body, re.MULTILINE) - dependency_match = re.search(r"^ - target:\s*([^\s#]+)[\s\S]{0,120}?^ embed:\s*true", body, re.MULTILINE) - records.append((match.group("name").strip().strip('"'), type_match.group(1) if type_match else None, platform_match.group(1).lower() if platform_match else None, dependency_match.group(1) if dependency_match else None)) - return records - - -def manifest_component(manifest: Path, root: Path) -> Component: - text = read_text(manifest) - name_match = re.search(r"Package\s*\(\s*name:\s*\"([^\"]+)\"", text) - name = name_match.group(1) if name_match else manifest.parent.name - executable = bool(re.search(r"\.(?:executable|executableTarget)\s*\(", text)) - framework = "hummingbird" if re.search(r"Hummingbird", text, re.IGNORECASE) else "vapor" if re.search(r"\bVapor\b", text) else None - kind = "service" if executable else "library" - destination = f"{'Services' if kind == 'service' else 'Packages'}/{name}" - owner = relative(manifest.parent, root) or "." - paths = [owner] if owner != "." else [relative(manifest, root)] - if owner == ".": - for child in ("Sources", "Tests", "Plugins"): - candidate = manifest.parent / child - if candidate.exists(): - paths.append(relative(candidate, root)) - evidence = [f"SwiftPM manifest {relative(manifest, root)}", "executable product or target" if executable else "library package"] - if framework: - evidence.append(f"{framework} dependency") - unresolved = [] if name_match else ["Package.swift does not expose a literal package name"] - return Component(name, kind, owner, destination, evidence, product_type=framework, owned_paths=paths, unresolved=unresolved) - - -def inventory_components(root: Path) -> tuple[list[Component], dict[str, Any]]: - projects = sorted(path for path in root.rglob("*.xcodeproj") if ".build" not in path.parts) - workspaces = sorted(path for path in root.rglob("*.xcworkspace") if ".build" not in path.parts) - specs = sorted(path for path in root.rglob("project.y*ml") if ".build" not in path.parts) - manifests = sorted(path for path in root.rglob("Package.swift") if ".build" not in path.parts) - components = [manifest_component(path, root) for path in manifests] - pbx_settings: set[str] = set() - target_records: list[tuple[str, str | None]] = [] - pbx_texts: list[str] = [] - for project in projects: - text = read_text(project / "project.pbxproj") - pbx_texts.append(text) - target_records.extend(pbx_target_records(text)) - pbx_settings.update(re.findall(r"\b(?:PRODUCT_BUNDLE_IDENTIFIER|CODE_SIGN_ENTITLEMENTS|DEVELOPMENT_TEAM|INFOPLIST_FILE|MARKETING_VERSION|CURRENT_PROJECT_VERSION|SWIFT_VERSION)\s*=", text)) - sdk_roots = {match.lower() for text in pbx_texts for match in re.findall(r"\bSDKROOT\s*=\s*([^;\s]+)", text)} - inferred_platform = "ios" if sdk_roots and sdk_roots <= {"iphoneos"} else "macos" if sdk_roots and sdk_roots <= {"macosx"} else None - app_names = [name for name, product in target_records if XCODE_PRODUCT_TYPES.get(product or "") == "app"] - extension_names = [name for name, product in target_records if XCODE_PRODUCT_TYPES.get(product or "") == "extension"] - hosts: dict[str, str] = {} - for text in pbx_texts: - hosts.update(pbx_extension_hosts(text, app_names, extension_names)) - flat_owned = [name for name in ("Sources", "Resources", "Tests", "Configurations", "Shared", "Extensions") if (root / name).exists()] - for name, product in target_records: - kind = XCODE_PRODUCT_TYPES.get(product or "") - if kind is None: - components.append(Component(name, "unsupported", ".", "", [f"PBX product type {product or 'missing'}"], product_type=product, unresolved=["unsupported or missing Xcode product type"])) - continue - destination = f"Apps/{name}" - host = hosts.get(name) - unresolved: list[str] = [] - if kind in {"app", "extension", "test", "ui-test"} and not inferred_platform: - unresolved.append("target platform requires reviewed mapping evidence") - if kind == "extension" and not host: - unresolved.append("extension host target is not explicit or is ambiguous") - owned = flat_owned if kind == "app" and len(app_names) == 1 else [] - components.append(Component(name, kind, ".", destination, [f"PBX native target product type {product}"], host_target=host, platform=inferred_platform, product_type=product, owned_paths=owned, unresolved=unresolved)) - discovered_names = {component.name for component in components} - xcodegen_records = [record for spec in specs for record in xcodegen_target_records(read_text(spec))] - xcodegen_apps = [name for name, product, _, _ in xcodegen_records if product == "application"] - xcodegen_hosts = {dependency: name for name, product, _, dependency in xcodegen_records if product == "application" and dependency} - for name, product, platform, _ in xcodegen_records: - if name in discovered_names: - continue - kind = {"application": "app", "app-extension": "extension", "extensionkit-extension": "extension", "bundle.unit-test": "test", "bundle.ui-testing": "ui-test"}.get(product or "") - unresolved: list[str] = [] - if kind is None: - components.append(Component(name, "unsupported", ".", "", [f"XcodeGen target type {product or 'missing'}"], product_type=product, unresolved=["unsupported or missing XcodeGen target type"])) - continue - normalized_platform = {"ios": "ios", "macos": "macos", "tvos": "tvos", "watchos": "watchos", "visionos": "visionos"}.get(platform or "") - if not normalized_platform: - unresolved.append("target platform requires reviewed mapping evidence") - host = xcodegen_hosts.get(name) - if kind == "extension" and not host: - unresolved.append("extension host target is not explicit or is ambiguous") - owned = flat_owned if kind == "app" and len(xcodegen_apps) == 1 else [] - canonical_product = "com.apple.product-type.app-extension" if product == "app-extension" else "com.apple.product-type.extensionkit-extension" if product == "extensionkit-extension" else product - components.append(Component(name, kind, ".", f"Apps/{name}", [f"XcodeGen target type {product}"], host_target=host, platform=normalized_platform, product_type=canonical_product, owned_paths=owned, unresolved=unresolved)) - inventory = { - "workspaces": [relative(path, root) for path in workspaces], - "projects": [relative(path, root) for path in projects], - "xcodegen_specs": [relative(path, root) for path in specs], - "swift_manifests": [relative(path, root) for path in manifests], - "xcconfigs": [relative(path, root) for path in sorted(root.rglob("*.xcconfig"))], - "entitlements": [relative(path, root) for path in sorted(root.rglob("*.entitlements"))], - "info_plists": [relative(path, root) for path in sorted(root.rglob("Info.plist"))], - "asset_catalogs": [relative(path, root) for path in sorted(root.rglob("*.xcassets"))], - "schemes": [relative(path, root) for path in sorted(root.rglob("*.xcscheme"))], - "test_plans": [relative(path, root) for path in sorted(root.rglob("*.xctestplan"))], - "pbx_settings_to_promote": sorted(item.removesuffix(" =") for item in pbx_settings), - "cloud_inputs": [relative(path, root) for pattern in ("Dockerfile*", "fly.toml") for path in sorted(root.rglob(pattern))], - } - return components, inventory - - -def proposed_adoption_map(root: Path, components: list[Component]) -> dict[str, Any]: - name = next((path.stem for path in sorted(root.glob("*.xcworkspace"))), None) or next((path.stem for path in sorted(root.glob("*.xcodeproj"))), None) or (components[0].name if components else None) or re.sub(r"[^A-Za-z0-9]", "", root.name.title()) or "Product" - return {"schema_version": 1, "workspace_name": name, "components": [asdict(component) for component in components]} - - -def validate_adoption_map(root: Path, mapping: dict[str, Any]) -> list[str]: - errors: list[str] = [] - if mapping.get("schema_version") != 1: - errors.append("adoption map schema_version must be 1") - name = mapping.get("workspace_name") - if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name): - errors.append("workspace_name must be an alphanumeric Xcode identifier") - components = mapping.get("components") - if not isinstance(components, list) or not components: - errors.append("adoption map must contain at least one component") - return errors - destinations: set[str] = set() - owned: dict[str, str] = {} - app_names = {item.get("name") for item in components if isinstance(item, dict) and item.get("kind") == "app"} - for index, item in enumerate(components): - label = f"components[{index}]" - if not isinstance(item, dict): - errors.append(f"{label} must be an object") - continue - kind, component_name, destination = item.get("kind"), item.get("name"), item.get("proposed_destination") - if kind not in {"app", "extension", "test", "ui-test", "library", "service"}: - errors.append(f"{label}.kind is unsupported: {kind!r}") - if not isinstance(component_name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", component_name): - errors.append(f"{label}.name must be an alphanumeric target or package name") - expected_prefix = "Apps/" if kind in {"app", "extension", "test", "ui-test"} else "Packages/" if kind == "library" else "Services/" - if not isinstance(destination, str) or not destination.startswith(expected_prefix) or ".." in Path(destination).parts: - errors.append(f"{label}.proposed_destination must be under {expected_prefix}") - elif destination in destinations: - errors.append(f"duplicate component destination: {destination}") - else: - destinations.add(destination) - if kind in {"app", "extension", "test", "ui-test"} and item.get("platform") not in SUPPORTED_PLATFORMS: - errors.append(f"{label}.platform requires explicit ios, macos, tvos, watchos, or visionos evidence") - if kind == "extension": - if item.get("host_target") not in app_names: - errors.append(f"{label}.host_target must name one mapped application target") - if item.get("product_type") not in {"com.apple.product-type.app-extension", "com.apple.product-type.extensionkit-extension"}: - errors.append(f"{label}.product_type must be a supported documented extension product type") - if not item.get("extension_point_identifier"): - errors.append(f"{label}.extension_point_identifier is required") - for source in item.get("owned_paths") or []: - if not isinstance(source, str) or Path(source).is_absolute() or ".." in Path(source).parts: - errors.append(f"{label}.owned_paths contains an unsafe path") - continue - if source in owned: - errors.append(f"{source} is assigned to both {owned[source]} and {component_name}") - owned[source] = str(component_name) - if not (root / source).exists(): - errors.append(f"mapped source does not exist: {source}") - if item.get("unresolved"): - errors.append(f"{label} still has unresolved evidence: {', '.join(item['unresolved'])}") - return errors - - -def move_owned_path(root: Path, source_name: str, destination_root: Path) -> None: - source = root / source_name - if source.name in {"Package.swift", "Sources", "Tests", "Plugins"}: - destination = destination_root / source.name - elif source.is_dir() and source_name.count("/") > 0: - destination = destination_root - else: - destination = destination_root / source.name - if destination.exists(): - raise RuntimeError(f"Adoption destination already exists: {relative(destination, root)}") - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(source), str(destination)) - - -def adopted_native_target_spec(item: dict[str, Any], org: str, team: str) -> str: - name, kind, platform = item["name"], item["kind"], item["platform"] - display = SUPPORTED_PLATFORMS[platform] - if kind == "app": - product_type = "application" - elif kind == "extension": - product_type = "app-extension" if item["product_type"] == "com.apple.product-type.app-extension" else "extensionkit-extension" - elif kind == "test": - product_type = "bundle.unit-test" - else: - product_type = "bundle.ui-testing" - destination = item["proposed_destination"] - lines = [ - "targets:", f" {name}:", f" type: {product_type}", f" platform: {display}", " sources:", - f" - path: {destination}/Sources", " type: syncedFolder", " optional: true", - f" - path: {destination}/Resources", " type: syncedFolder", " optional: true", - " settings:", " base:", f" PRODUCT_BUNDLE_IDENTIFIER: {item.get('bundle_identifier') or org + '.' + name.lower()}", - f" DEVELOPMENT_TEAM: {item.get('development_team') or team}", " CODE_SIGN_STYLE: Automatic", - ] - if kind == "extension": - lines.extend([" info:", f" path: {destination}/Resources/Info.plist"]) - dependencies = item.get("dependencies") or [] - if kind in {"test", "ui-test"} and item.get("host_target"): - dependencies = [*dependencies, item["host_target"]] - if dependencies: - lines.append(" dependencies:") - lines.extend(f" - target: {dependency}" for dependency in dependencies) - return "\n".join(lines) + "\n" - - -def stage_adoption(root: Path, mapping: dict[str, Any], org: str, team: str) -> dict[str, Any]: - snapshot = root / ".socket/adoption/original-inventory.json" - if snapshot.exists(): - raise RuntimeError("An adoption is already staged; review or revert .socket/adoption before applying another map.") - components, inventory = inventory_components(root) - write(snapshot, json.dumps({"inventory": inventory, "components": [asdict(item) for item in components]}, indent=2, sort_keys=True) + "\n") - original_spec = root / "project.yml" - if original_spec.exists(): - write(root / ".socket/adoption/original-project.yml", original_spec.read_text(encoding="utf-8")) - for directory in ("Apps", "Packages", "Services", "Configurations", "Scripts", "docs"): - (root / directory).mkdir(exist_ok=True) - name = mapping["workspace_name"] - write(root / "project.yml", root_spec(name, [])) - write(root / "Apps/apps-shared.yml", app_shared_spec()) - write(root / "Apps/Apps-shared.xcconfig", '#include "../Configurations/Project.xcconfig"\n') - write(root / "Packages/packages-shared.yml", "packages: {}\n") - write(root / "Services/services-shared.yml", "packages: {}\n") - write(root / "Configurations/Project.xcconfig", "SWIFT_VERSION = 6.0\nSWIFT_STRICT_CONCURRENCY = complete\n") - for config in CONFIGURATIONS: - write(root / f"Configurations/{config}.xcconfig", '#include "Project.xcconfig"\n') - native_items = [item for item in mapping["components"] if item["kind"] in {"app", "extension", "test", "ui-test"}] - for item in mapping["components"]: - destination = root / item["proposed_destination"] - for source in item.get("owned_paths") or []: - move_owned_path(root, source, destination) - if item["kind"] in {"library", "service"}: - add_package_mapping(root, "Packages" if item["kind"] == "library" else "Services", item["name"]) - else: - add_root_include(root, f"{item['proposed_destination']}/target.yml") - write(destination / "target.yml", adopted_native_target_spec(item, org, team)) - if item["kind"] == "extension": - info = destination / "Resources/Info.plist" - if not info.exists(): - write(info, '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>NSExtension</key><dict><key>NSExtensionPointIdentifier</key><string>' + item["extension_point_identifier"] + '</string></dict></dict></plist>\n') - for item in native_items: - if item["kind"] == "extension": - embed_extension_in_host(root, item["host_target"], item["name"]) - candidate = root / ".socket/adoption-candidate" - candidate.mkdir(parents=True, exist_ok=True) - generated = subprocess.run(["xcodegen", "generate", "--spec", "project.yml", "--project", str(candidate), "--project-root", str(root)], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"candidate XcodeGen generation failed:\n{generated.stderr}") - candidate_pbx = read_text(candidate / f"{name}.xcodeproj/project.pbxproj") - generated_targets = {target for target, _ in pbx_target_records(candidate_pbx)} - expected_targets = {item["name"] for item in native_items} - missing = sorted(expected_targets - generated_targets) - report = { - "expected_native_targets": sorted(expected_targets), - "generated_native_targets": sorted(generated_targets), - "missing_native_targets": missing, - "candidate_project": relative(candidate / f"{name}.xcodeproj", root), - "preserved_inventory": relative(snapshot, root), - } - write(root / ".socket/adoption/equivalence-report.json", json.dumps(report, indent=2, sort_keys=True) + "\n") - if missing: - raise RuntimeError("Candidate equivalence failed; missing native targets: " + ", ".join(missing)) - return report - - -def install(root: Path, name: str, prefix: str, platforms: list[str], org: str, team: str) -> None: - write(root / "project.yml", root_spec(name, platforms)) - write(root / "Apps/apps-shared.yml", app_shared_spec()) - write(root / "Apps/Apps-shared.xcconfig", "#include \"../Configurations/Project.xcconfig\"\nASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES\nLOCALIZATION_PREFERS_STRING_CATALOGS = YES\nSTRING_CATALOG_GENERATE_SYMBOLS = YES\n") - write(root / "Packages/packages-shared.yml", package_spec(name)) - write(root / "Services/services-shared.yml", "packages: {}\n") - write(root / "Configurations/Project.xcconfig", "SWIFT_VERSION = 6.0\nSWIFT_STRICT_CONCURRENCY = complete\nSWIFT_APPROACHABLE_CONCURRENCY = YES\nDEAD_CODE_STRIPPING = YES\nENABLE_USER_SCRIPT_SANDBOXING = NO\n") - for config in CONFIGURATIONS: - settings = '#include "Project.xcconfig"\n' - if config == "Debug": - settings += "SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited)\n" - else: - settings += "SWIFT_COMPILATION_MODE = wholemodule\nSWIFT_OPTIMIZATION_LEVEL = -O\n" - write(root / f"Configurations/{config}.xcconfig", settings) - write(root / ".gitignore", "Build/\nDerivedData/\nxcuserdata/\n*.xcuserstate\n") - write(root / f"{name}.xcworkspace/contents.xcworkspacedata", f'<?xml version="1.0" encoding="UTF-8"?>\n<Workspace version="1.0">\n <FileRef location="group:{name}.xcodeproj"/>\n</Workspace>\n') - (root / "docs").mkdir() - install_alignment_runtime(root) - write(root / "Justfile", (root / "Justfile").read_text(encoding="utf-8") + "\nvalidate:\n sh Scripts/validate.sh\npackage-test:\n for manifest in Packages/*/Package.swift Services/*/Package.swift; do [ -f \"$manifest\" ] || continue; (cd \"$(dirname \"$manifest\")\" && swift test); done\ntest target:\n xcodebuild -workspace *.xcworkspace -scheme \"{{target}}\" test\narchive target channel:\n sh Scripts/release.sh \"{{target}}\" \"{{channel}}\"\napp-store target:\n sh Scripts/release.sh \"{{target}}\" app-store\naltstore target:\n sh Scripts/release.sh \"{{target}}\" altstore\ndirect-distribution target:\n sh Scripts/release.sh \"{{target}}\" direct-distribution\n") - write(root / "Scripts/increment-build-version.sh", "#!/usr/bin/env sh\nset -eu\ntarget=${1:?target required}; configuration=${2:?configuration required}; label=$(printf '%s' \"$configuration\" | tr '[:upper:]' '[:lower:]')\nfile=\"Apps/$target/Configurations/Version.xcconfig\"\ngit rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo \"Build counter requires a Git repository.\" >&2; exit 1; }\n[ \"$configuration\" = Debug ] && key=DEBUG_BUILD_NUMBER || key=RELEASE_BUILD_NUMBER\nvalue=$(awk -F ' = ' -v key=\"$key\" '$1 == key { print $2 }' \"$file\")\n[ -n \"$value\" ] || { echo \"Missing $key in $file\" >&2; exit 1; }\nawk -F ' = ' -v key=\"$key\" -v next=$((value + 1)) 'BEGIN { OFS = \" = \" } $1 == key { $2 = next } { print }' \"$file\" > \"$file.tmp\" && mv \"$file.tmp\" \"$file\"\nstaged=false; unstaged=false; git diff --cached --quiet || staged=true; git diff --quiet || unstaged=true\nif $staged && $unstaged; then git add \"$file\"; echo \"warning: staged build counter update; commit it manually as soon as possible.\" >&2; exit 0; fi\nif $staged; then patch=$(mktemp); git diff --cached --binary > \"$patch\"; git restore --staged :/; git add \"$file\"; git commit -m \"build: increment $target $label build\"; git apply --cached \"$patch\"; rm -f \"$patch\"; else git add \"$file\"; git commit -m \"build: increment $target $label build\"; fi\n", True) - write(root / "Scripts/validate.sh", "#!/usr/bin/env sh\nset -eu\nswiftformat --lint --config .swiftformat Apps Packages Services\nswiftlint lint --config .swiftlint.yml --force-exclude Apps Packages Services\nxcodegen generate --spec project.yml\nworkspace=$(find . -maxdepth 1 -type d -name '*.xcworkspace' -print -quit)\nxcodebuild -list -workspace \"$workspace\"\nfor manifest in Packages/*/Package.swift Services/*/Package.swift; do [ -f \"$manifest\" ] || continue; (cd \"$(dirname \"$manifest\")\" && swift test); done\n", True) - write(root / "Scripts/release.sh", "#!/usr/bin/env sh\nset -eu\ntarget=${1:?target required}; channel=${2:?channel required}\nworkspace=$(find . -maxdepth 1 -type d -name '*.xcworkspace' -print -quit)\ncase \"$channel\" in\n staging) scheme=\"$target Staging\"; config=Staging ;;\n app-store) scheme=\"$target App Store\"; config=AppStore ;;\n altstore) scheme=\"$target AltStore\"; config=AltStore ;;\n direct-distribution) scheme=\"$target Direct Distribution\"; config=DirectDistribution ;;\n *) echo \"Unknown release channel: $channel\" >&2; exit 1 ;;\nesac\narchive=\"Build/$target-$channel.xcarchive\"\nxcodebuild -workspace \"$workspace\" -scheme \"$scheme\" -configuration \"$config\" -archivePath \"$archive\" archive\ncase \"$channel\" in\n app-store) xcodebuild -exportArchive -archivePath \"$archive\" -exportPath \"Build/$target-app-store\" -exportOptionsPlist Scripts/ExportOptions/AppStore.plist; artifact=$(find \"Build/$target-app-store\" -type f \\( -name '*.ipa' -o -name '*.pkg' \\) -print -quit); [ -n \"$artifact\" ] || { echo \"App Store export produced no IPA or PKG.\" >&2; exit 1; }; case \"$target\" in *macOS) type=osx ;; *) type=ios ;; esac; xcrun altool --upload-app -f \"$artifact\" -t \"$type\" ;;\n altstore) xcodebuild -exportArchive -archivePath \"$archive\" -exportPath \"Build/$target-altstore\" -exportOptionsPlist Scripts/ExportOptions/AltStore.plist ;;\n direct-distribution) xcodebuild -exportArchive -archivePath \"$archive\" -exportPath \"Build/$target-direct\" -exportOptionsPlist Scripts/ExportOptions/DirectDistribution.plist; app=$(find \"Build/$target-direct\" -type d -name '*.app' -print -quit); [ -n \"$app\" ] || { echo \"Direct export produced no app bundle.\" >&2; exit 1; }; dmg=\"Build/$target-direct/$target.dmg\"; hdiutil create -volname \"$target\" -srcfolder \"$app\" -ov -format UDZO \"$dmg\"; xcrun notarytool submit \"$dmg\" --keychain-profile notarytool --wait; xcrun stapler staple \"$dmg\" ;;\nesac\n", True) - write(root / "Scripts/ExportOptions/AppStore.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>method</key><string>app-store</string></dict></plist>\n') - write(root / "Scripts/ExportOptions/AltStore.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>method</key><string>development</string></dict></plist>\n') - write(root / "Scripts/ExportOptions/DirectDistribution.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>method</key><string>developer-id</string></dict></plist>\n') - write(root / "ROADMAP.md", "# Roadmap\n\n## Managed workspace expansion\n\n- [ ] Extend `just align` ownership to additional documentation, templates, and repository scripts when each surface has a safe managed boundary.\n") - for platform in platforms: - display = SUPPORTED_PLATFORMS[platform] - target = f"{name}{display}" - app_root = root / "Apps" / target - write(app_root / "target.yml", target_spec(name, platform, prefix, org, team)) - write(app_root / "Configurations/App.xcconfig", '#include "../../Apps-shared.xcconfig"\nMARKETING_VERSION = 0.0.1\nCODE_SIGN_STYLE = Automatic\n') - write(app_root / "Configurations/Version.xcconfig", "DEBUG_BUILD_NUMBER = 1\nRELEASE_BUILD_NUMBER = 1\n") - for config in CONFIGURATIONS: - build_number = "$(DEBUG_BUILD_NUMBER)" if config == "Debug" else "$(RELEASE_BUILD_NUMBER)" - content = '#include "App.xcconfig"\n#include "Version.xcconfig"\nCURRENT_PROJECT_VERSION = ' + build_number + "\n" - if config == "Debug": - content += "ONLY_ACTIVE_ARCH = YES\n" - else: - content += "SWIFT_OPTIMIZATION_LEVEL = -O\n" - write(app_root / f"Configurations/{config}.xcconfig", content) - write(app_root / f"Sources/{prefix}App.swift", f'import SwiftUI\n\n@main\nstruct {prefix}{display}App: App {{\n var body: some Scene {{ WindowGroup {{ Text("{target}") }} }}\n}}\n') - write(app_root / "Sources/Views/.gitkeep", "") - write(app_root / "Sources/Datamodels/.gitkeep", "") - write(app_root / "Sources/Services/.gitkeep", "") - write(app_root / "Resources/Info.plist", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string><key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string></dict></plist>\n') - write(app_root / f"Resources/{target}.entitlements", '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>\n') - write(app_root / "Resources/Assets.xcassets/Contents.json", '{"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", '{"images":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Assets.xcassets/AccentColor.colorset/Contents.json", '{"colors":[],"info":{"author":"xcode","version":1}}\n') - write(app_root / "Resources/Localizable.xcstrings", '{"sourceLanguage":"en","strings":{},"version":"1.0"}\n') - tests_root = root / "Apps" / f"{target}Tests" - write(tests_root / f"Sources/{target}Tests.swift", f'import Testing\n@testable import {target}\n\n@Test func example() {{ #expect(true) }}\n') - if platform != "watchos": - ui_root = root / "Apps" / f"{target}UITests" - write(ui_root / f"Sources/{target}UITests.swift", f'import XCTest\n\nfinal class {target}UITests: XCTestCase {{\n func testLaunch() {{}}\n}}\n') - package_root = root / "Packages" / f"{name}Core" - package_root.mkdir(parents=True) - subprocess.run(["swift", "package", "init", "--type", "library", "--name", f"{name}Core", "--enable-swift-testing"], cwd=package_root, check=True, capture_output=True, text=True) - write(package_root / "Package.swift", f'''// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "{name}Core", - platforms: [.iOS(.v26), .macOS(.v26), .tvOS(.v26), .watchOS(.v26), .visionOS(.v26)], - products: [.library(name: "{name}Core", targets: ["{name}Core"])], - targets: [ - .target(name: "{name}Domain"), - .target(name: "{name}UI", dependencies: ["{name}Domain"]), - .target(name: "{name}Services", dependencies: ["{name}Domain"]), - .target(name: "{name}Core", dependencies: ["{name}Domain", "{name}UI", "{name}Services"]), - .testTarget(name: "{name}DomainTests", dependencies: ["{name}Domain"]), - .testTarget(name: "{name}UITests", dependencies: ["{name}UI"]), - .testTarget(name: "{name}ServicesTests", dependencies: ["{name}Services"]), - .testTarget(name: "{name}CoreTests", dependencies: ["{name}Core"]), - ] -) -''') - write(package_root / f"Sources/{name}Core/{name}Core.swift", f"@_exported import {name}Domain\n@_exported import {name}UI\n@_exported import {name}Services\n") - for module, folders in ((f"{name}Domain", ("Datamodels", "Actions")), (f"{name}UI", ("Components", "Styles")), (f"{name}Services", ("Clients", "DTOs"))): - for folder in folders: - write(package_root / f"Sources/{module}/{folder}/.gitkeep", "") - write(package_root / f"Sources/{module}/{module}.swift", f"public enum {module} {{}}\n") - for module in (f"{name}Core", f"{name}Domain", f"{name}UI", f"{name}Services"): - write(package_root / f"Tests/{module}Tests/{module}Tests.swift", f"import Testing\n@testable import {module}\n\n@Test func example() {{ #expect(true) }}\n") - - -def main() -> int: - args = parser().parse_args() - platforms = [item.strip().lower() for item in args.platforms.split(",") if item.strip()] - root = Path(args.repo_root).expanduser().resolve() if args.operation in {"adopt", "align", "add-component"} and args.repo_root else ((Path(args.destination).expanduser() / args.name).resolve() if args.name else Path(args.destination).expanduser().resolve()) - inputs = {"operation": args.operation, "name": args.name, "file_prefix": args.file_prefix, "destination": args.destination, "repo_root": args.repo_root, "platforms": platforms, "component_kind": args.component_kind, "component_name": args.component_name, "platform": args.platform, "framework": args.framework, "host_target": args.host_target, "extension_product_type": args.extension_product_type, "extension_point_identifier": args.extension_point_identifier, "adoption_map": args.adoption_map, "apply": args.apply, "org_identifier": args.org_identifier, "development_team": args.development_team, "dry_run": args.dry_run, "skip_validation": args.skip_validation} - if args.operation == "adopt": - if not args.repo_root: - return blocked("--repo-root is required with --operation adopt.", inputs) - if not root.is_dir(): - return blocked("The requested adoption root is not a directory.", inputs) - if not workspace_findings(root): - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "components": [], "migration_required": False, "next_step": "The repository is already canonical; use --operation align."}, indent=2, sort_keys=True)) - return 0 - components, inventory = inventory_components(root) - mapping = proposed_adoption_map(root, components) - if not components: - return blocked("No SwiftPM manifest or Xcode native target evidence was found to adopt.", inputs) - if not args.apply: - unresolved = [f"{item.name}: {reason}" for item in components for reason in item.unresolved] - payload = {"status": "blocked" if unresolved else "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "inventory": inventory, "components": [asdict(item) for item in components], "adoption_map": mapping, "migration_required": True, "unresolved": unresolved, "next_step": "Review the adoption_map, add required explicit ownership/host/platform/extension-point evidence, save it as JSON, then rerun with --adoption-map <path> --apply."} - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 if unresolved else 0 - if not args.adoption_map: - return blocked("--adoption-map is required with --operation adopt --apply.", inputs) - map_path = Path(args.adoption_map).expanduser().resolve() - if not map_path.is_file(): - return blocked("The reviewed --adoption-map file does not exist.", inputs) - try: - reviewed = json.loads(map_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return blocked(f"Could not read the reviewed adoption map: {exc}", inputs) - errors = validate_adoption_map(root, reviewed) - if errors: - return blocked("Reviewed adoption map is not safe to apply: " + "; ".join(errors), inputs) - if not shutil.which("xcodegen"): - return blocked("XcodeGen is required to generate the adoption candidate project.", inputs) - try: - report = stage_adoption(root, reviewed, args.org_identifier, args.development_team) - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "components": reviewed["components"], "equivalence": report, "migration_required": True, "next_step": "Review .socket/adoption/equivalence-report.json and candidate project before finalizing removal of superseded project files; no original project was deleted."}, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - return blocked(str(exc), inputs) - if args.operation == "add-component": - if not args.repo_root or not args.component_kind or not args.component_name: - return blocked("--repo-root, --component-kind, and --component-name are required with --operation add-component.", inputs) - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", args.component_name): - return blocked("--component-name must be an alphanumeric Swift identifier beginning with a letter.", inputs) - findings = workspace_findings(root, allow_missing_services=True) if root.is_dir() else ["The requested workspace root is not a directory."] - if findings: - return blocked(" ".join(findings), inputs) - if args.component_kind == "app" and not args.platform: - return blocked("--platform is required when adding an app component.", inputs) - if args.component_kind == "app" and not re.fullmatch(r"[A-Z]{3}", args.file_prefix): - return blocked("--file-prefix must contain exactly three uppercase ASCII letters.", inputs) - if args.component_kind == "extension" and (not args.platform or not args.host_target or not args.extension_product_type or not args.extension_point_identifier): - return blocked("--platform, --host-target, --extension-product-type, and --extension-point-identifier are required when adding an extension component.", inputs) - if args.component_kind == "service" and not args.framework: - return blocked("--framework is required when adding a service component.", inputs) - if not args.dry_run and not shutil.which("xcodegen"): - return blocked("XcodeGen is required to regenerate the workspace after adding a component.", inputs) - actions = ensure_services_surface(root, dry_run=True) + [f"add {args.component_kind} component {args.component_name}", "regenerate the root XcodeGen project"] - if args.dry_run: - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "actions": actions}, indent=2, sort_keys=True)) - return 0 - try: - ensure_services_surface(root) - product = workspace_name(root) - if args.component_kind == "library": - create_library_component(root, args.component_name) - elif args.component_kind == "app": - create_app_component(root, product, args.component_name, args.platform, args.file_prefix, args.org_identifier, args.development_team) - elif args.component_kind == "extension": - create_extension_component(root, args.component_name, args.platform, args.host_target, args.extension_product_type, args.extension_point_identifier, args.org_identifier, args.development_team) - else: - adapter = subprocess.run([str(server_component_runner()), "--repo-root", str(root), "--name", args.component_name, "--framework", args.framework], capture_output=True, text=True, check=False) - if adapter.returncode != 0: - raise RuntimeError(f"server component adapter failed:\n{adapter.stdout}\n{adapter.stderr}") - generated = subprocess.run(["xcodegen", "generate", "--spec", "project.yml"], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"xcodegen generate failed:\n{generated.stderr}") - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "actions": actions, "next_step": "Open the existing root workspace; the new component is part of the same product entrypoint."}, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - return blocked(str(exc), inputs) - if args.operation == "align": - if not args.repo_root: - return blocked("--repo-root is required with --operation align.", inputs) - if not args.dry_run and not shutil.which("xcodegen"): - return blocked("XcodeGen is required to regenerate an aligned workspace.", inputs) - findings = workspace_findings(root, allow_missing_services=True) if root.is_dir() else ["The requested workspace root is not a directory."] - if findings: - return blocked(" ".join(findings), inputs) - try: - actions = ensure_services_surface(root, args.dry_run) + install_alignment_runtime(root, args.dry_run) - if not args.dry_run: - generated = subprocess.run(["xcodegen", "generate", "--spec", "project.yml"], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"xcodegen generate failed:\n{generated.stderr}") - print(json.dumps({"status": "success", "path_type": "primary", "workspace_root": str(root), "normalized_inputs": inputs, "actions": actions + ["regenerate the root XcodeGen project"], "next_step": "Run just setup once, then use just align as the single managed-guidance refresh command."}, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - return blocked(str(exc), inputs) - if not args.name: - return blocked("--name is required when creating a new workspace.", inputs) - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", args.name): - return blocked("--name must be an alphanumeric Swift/Xcode identifier beginning with a letter.", inputs) - if not re.fullmatch(r"[A-Z]{3}", args.file_prefix): - return blocked("--file-prefix must contain exactly three uppercase ASCII letters.", inputs) - service_first = args.component_kind == "service" - library_first = args.component_kind == "library" - component_first = service_first or library_first - if component_first: - platforms = [] - inputs["platforms"] = platforms - if service_first and not args.framework: - return blocked("--framework is required when creating a service-first workspace.", inputs) - if (not component_first and not platforms) or any(platform not in SUPPORTED_PLATFORMS for platform in platforms): - return blocked("--platforms must be a comma-separated subset of ios,macos,tvos,watchos,visionos.", inputs) - if root.exists() and (not root.is_dir() or any(root.iterdir())): - return blocked("The product root already contains files; use --operation align --repo-root <existing-root> for a canonical workspace.", inputs) - xcodegen = shutil.which("xcodegen") - if not xcodegen: - return blocked("XcodeGen is required to create the root generated project.", inputs) - actions = ["create one root XcodeGen project", "create Apps/, Packages/, and Services/ component roots", "create Packages/ local Swift package", "create root workspace wrapper"] - if service_first: - actions.append(f"create Services/{args.component_name or args.name + 'API'} with the {args.framework} workspace adapter") - elif library_first: - actions.append(f"create Packages/{args.component_name or args.name + 'Core'} as the first product component") - payload: dict[str, object] = {"status": "success", "path_type": "primary", "workspace_root": str(root), "workspace_path": str(root / f"{args.name}.xcworkspace"), "project_path": str(root / f"{args.name}.xcodeproj"), "normalized_inputs": inputs, "actions": actions} - if args.dry_run: - payload["validation_result"] = "skipped (--dry-run)" - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - root.mkdir(parents=True) - try: - install(root, args.name, args.file_prefix, platforms, args.org_identifier, args.development_team) - if library_first and args.component_name and args.component_name != f"{args.name}Core": - create_library_component(root, args.component_name) - if service_first: - adapter_command = [str(server_component_runner()), "--repo-root", str(root), "--name", args.component_name or f"{args.name}API", "--framework", args.framework] - if args.skip_validation: - adapter_command.append("--skip-validation") - adapter = subprocess.run(adapter_command, capture_output=True, text=True, check=False) - if adapter.returncode != 0: - raise RuntimeError(f"server component adapter failed:\n{adapter.stdout}\n{adapter.stderr}") - generated = subprocess.run([xcodegen, "generate", "--spec", "project.yml"], cwd=root, capture_output=True, text=True, check=False) - if generated.returncode != 0: - raise RuntimeError(f"xcodegen generate failed:\n{generated.stderr}") - runner = maintain_project_repo_runner() - maintenance = subprocess.run([str(runner), "--repo-root", str(root), "--operation", "install", "--profile", "xcode-workspace"], capture_output=True, text=True, check=False) - if maintenance.returncode != 0: - raise RuntimeError(f"maintain-project-repo install failed:\n{maintenance.stdout}\n{maintenance.stderr}") - validation = "skipped (--skip-validation)" - if not args.skip_validation: - check = subprocess.run(["xcodebuild", "-list", "-workspace", f"{args.name}.xcworkspace"], cwd=root, capture_output=True, text=True, check=False) - if check.returncode != 0: - raise RuntimeError(f"xcodebuild -list failed:\n{check.stderr}") - validation = "passed (xcodebuild -list -workspace)" - payload["validation_result"] = validation - payload["next_step"] = "Open the root workspace; edit project.yml, included target specs, .xcconfig files, and Package.swift—not generated project data." - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - payload.update(status="failed", stderr=str(exc), next_step="Fix the reported bootstrap prerequisite or generated-spec error and rerun the workflow.") - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/build-python-agent-service/SKILL.md b/skills/build-python-agent-service/SKILL.md deleted file mode 100644 index 5f8b575ee..000000000 --- a/skills/build-python-agent-service/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: build-python-agent-service -description: Build a local-first Python agent service with typed tools, exact model capability checks, evaluation fixtures, and safe promotion gates. Use for OpenAI Agents SDK, LangGraph, LlamaIndex, Pydantic AI, Google ADK Python, AutoGen, or CrewAI. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients building uv-managed Python agent services on macOS with local or remote model endpoints, typed tool contracts, and explicit validation. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-agent-service -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# Build Python Agent Service - -## Purpose - -Build one bounded Python agent application without treating the model server, -agent framework, tool executor, and durable state as one inseparable stack. -Start from the smallest useful read-only agent and promote only after the exact -model, tools, evaluation fixtures, and side-effect boundary have been proved. - -## When To Use - -- Use for a new or existing uv-managed Python agent service. -- Use when the framework is OpenAI Agents SDK, LangGraph, LlamaIndex, Pydantic - AI, Google ADK Python, AutoGen, or CrewAI. -- Use after `design-agent-automation-workflow` has established that a - code-owned Python agent service is the right surface. -- Do not use for a visual integration workflow; hand off n8n work to the - owning integration project after the planning skill selects it. -- Do not use for model benchmarking itself; hand off local model capability and - tool-loop measurement to `model-lab-skills:evaluate-tool-calling-model`. - -## Source Check - -Before selecting or updating a framework, inspect the repository and use -official current documentation for the exact framework and model adapter: - -- OpenAI Agents SDK: <https://developers.openai.com/api/docs/guides/agents> -- LangGraph: <https://docs.langchain.com/oss/python/langgraph/overview> -- LangChain Ollama: <https://docs.langchain.com/oss/python/integrations/chat/ollama/> -- LlamaIndex agents: <https://docs.llamaindex.ai/en/latest/understanding/agent/structured_output/> -- Pydantic AI: <https://pydantic.dev/docs/ai/overview/> -- Pydantic AI Ollama: <https://pydantic.dev/docs/ai/models/ollama/> -- Google ADK: <https://adk.dev/> -- AutoGen models: <https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/models.html> -- CrewAI: <https://docs.crewai.com/> -- uv: <https://docs.astral.sh/uv/> - -State which source changed the implementation decision. Do not rely on a -framework's claimed OpenAI-compatible endpoint as proof that a local model -supports tool calls or structured output correctly. - -## Implementation Sequence - -1. Inspect the current project shape, its `pyproject.toml`, existing model - client, tools, state store, tests, and deployment configuration. Prefer an - existing service/package boundary over adding a second agent host. -2. Write the non-agent baseline: trigger, inputs, expected typed output, - deterministic alternative, no-op behavior, and the reason planning/tool use - is necessary. -3. Select one framework for the real workflow: - - OpenAI Agents SDK for an application-owned agent loop with tools, - handoffs, guardrails, and traces. - - LangGraph when persisted transitions, pause/resume, retries, or explicit - routing are first-class behavior. - - LlamaIndex when ingestion, retrieval, citations, and RAG quality are the - core product problem. - - Pydantic AI for a compact typed Python service with validated tool and - result models. - - Google ADK Python when Google/Gemini, A2A/MCP, or ADK's runtime model is a - concrete product requirement. - - AutoGen or CrewAI only when a measured multi-agent design beats a - single-agent baseline on the same fixed task set. -4. Declare model endpoint, exact model name and revision/tag, authentication, - requested capabilities, context/latency limits, and model lifecycle. Keep - local server configuration out of committed secrets and machine-local paths. -5. Run a capability gate against the exact endpoint and model before attaching - write-capable tools: valid tool-call JSON, schema-conforming structured - output, no-call behavior, malformed-call recovery, maximum-step stop, and a - read-only task set resembling the intended application. -6. Implement one agent with typed input/output and narrow read-only tools. - Tool functions must validate their own authorization, inputs, timeout, and - result shape; model output cannot grant a capability. -7. Add durable state only when the user-visible workflow needs a restart-safe - session, checkpoint, task queue, or approval resume point. Name the store, - retention, migration, replay, and recovery contract. -8. Add the smallest test set: fake-tool unit cases, structured-output cases, - model-adapter integration smoke tests, denied-write cases, and regression - fixtures. Run live write tests only in an explicit disposable or draft mode. -9. Promote from report/draft to external writes only through - `auto-with-escalation`: name the exact recipient, target, action, evidence, - rollback/no-op behavior, and human approval point. - -## Framework Boundaries - -Do not add a framework wrapper merely to make framework names interchangeable. -Keep application domain behavior independent from the selected framework where -that boundary has a real caller: typed domain input/output, tool interfaces, -and persistence adapter. Let framework-specific orchestration stay at the -application edge. - -Do not introduce LangGraph persistence, vector retrieval, multi-agent teams, -or a background queue unless the selected workflow requires its concrete -behavior. A single request/response tool loop should remain a small service or -CLI. - -## Validation - -At minimum, run the repository's configured quality checks. In a standard uv -project that means: - -```bash -uv sync --dev -uv run pytest -uv run ruff check . -uv run mypy . -``` - -Report separately: - -1. fake-tool contract results; -2. exact local/remote model capability-gate results; -3. structured result validity; -4. attempted versus executed side effects; -5. state/resume behavior, when state exists; -6. the exact approval or no-op result for write-capable tools. - -## Output Shape - -Return: - -1. `Framework`: selected framework and the concrete requirement it serves. -2. `Model contract`: server, exact model, capabilities proven, and limitations. -3. `Tool boundary`: tool schemas, permissions, and denied-action behavior. -4. `State`: absent or explicit persistence/recovery contract. -5. `Evaluation`: fixture, fake-tool, and live-integration evidence. -6. `Promotion gate`: exact condition for an external write. -7. `Validation`: commands run and results. - -## Guardrails - -- Do not install several frameworks for a comparison unless the experiment is - explicitly requested and has one fixed evaluation set. -- Do not run an unattended local background service, scheduler, or external - write workflow without an explicit user request and a recovery plan. -- Do not store model API keys, local endpoint credentials, or private prompt - data in source control, fixtures, traces, or error output. -- Do not call a local model private merely because it runs on macOS; document - every connected tool, remote endpoint, trace sink, and data store. -- Do not claim a model supports tools, structured output, or a context size - until the exact server/model combination passes the capability gate. diff --git a/skills/build-python-agent-service/agents/openai.yaml b/skills/build-python-agent-service/agents/openai.yaml deleted file mode 100644 index 720eea0b7..000000000 --- a/skills/build-python-agent-service/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "Build Python Agent Service" - short_description: "Build a tested local-first Python agent service." - brand_color: "#4F46E5" - default_prompt: "Use $build-python-agent-service to select one Python agent framework for this bounded workflow, separate its inference server and exact model from its tool/state boundaries, prove read-only tool calling and structured output first, and name the exact approval gate before any external write." - -policy: - allow_implicit_invocation: true diff --git a/skills/choose-macos-virtualization-shape/SKILL.md b/skills/choose-macos-virtualization-shape/SKILL.md index db8f26ae3..774f15bda 100644 --- a/skills/choose-macos-virtualization-shape/SKILL.md +++ b/skills/choose-macos-virtualization-shape/SKILL.md @@ -63,10 +63,6 @@ Choose one boundary from evidence about fidelity, persistence, portability, host - Use `cybersecurity-skills:select-analysis-isolation` and `prepare-isolated-analysis-lab` for untrusted material. - Escalate to a physical Mac with the unresolved gap stated when VM fidelity is insufficient. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [Virtualization shape record](references/virtualization-shape-record.md) diff --git a/skills/choose-macos-virtualization-shape/references/customization-flow.md b/skills/choose-macos-virtualization-shape/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/skills/choose-macos-virtualization-shape/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/skills/choose-macos-virtualization-shape/references/customization.template.yaml b/skills/choose-macos-virtualization-shape/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/choose-macos-virtualization-shape/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/choose-macos-virtualization-shape/scripts/customization_config.py b/skills/choose-macos-virtualization-shape/scripts/customization_config.py deleted file mode 100755 index 805ecef72..000000000 --- a/skills/choose-macos-virtualization-shape/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "choose-macos-virtualization-shape" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/compare-model-checkpoints/SKILL.md b/skills/compare-model-checkpoints/SKILL.md index 0275b474d..83f44ebd8 100644 --- a/skills/compare-model-checkpoints/SKILL.md +++ b/skills/compare-model-checkpoints/SKILL.md @@ -11,7 +11,7 @@ Identify the exact model and tokenizer revisions, chat template, adapter/merge s ## Workflow -1. Preserve every source artifact as immutable, snapshot its provenance with `scripts/snapshot_model_provenance.py`, and write the snapshot outside the artifact directory. +1. Preserve every source artifact as immutable, snapshot its provenance with `scripts/snapshot-model-provenance.fsx`, and write the snapshot outside the artifact directory. 2. Verify that every comparison artifact can be loaded and produces output on the same smoke cases. 3. Use `evaluate-language-model` for paired quality and behavior evidence. 4. Use `benchmark-model-runtime` when deployment properties affect the decision. @@ -30,4 +30,4 @@ Identify the exact model and tokenizer revisions, chat template, adapter/merge s - `assets/model-comparison-report.md`: selection report. - `references/checkpoint-provenance.md`: provenance field guide. -- `scripts/snapshot_model_provenance.py`: deterministic local artifact inventory. +- `scripts/snapshot-model-provenance.fsx`: deterministic local artifact inventory. diff --git a/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx b/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx new file mode 100644 index 000000000..175d17043 --- /dev/null +++ b/skills/compare-model-checkpoints/scripts/snapshot-model-provenance.fsx @@ -0,0 +1,57 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Security.Cryptography +open System.Text +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let valueAfter flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let positional = args |> Array.filter (fun value -> not (value.StartsWith("--")) && not (args |> Array.exists (fun flag -> (flag = "--model-id" || flag = "--revision" || flag = "--output") && valueAfter flag = Some value))) +if positional.Length <> 1 then + eprintfn "Usage: snapshot-model-provenance.fsx <artifact> [--model-id ID] [--revision REVISION] [--output PATH]" + exit 2 + +let artifact = Path.GetFullPath(positional[0]) +if not (File.Exists artifact || Directory.Exists artifact) then + eprintfn "Model artifact does not exist: %s" artifact + exit 2 + +let output = valueAfter "--output" |> Option.map Path.GetFullPath +match output with +| Some path when path = artifact || (Directory.Exists artifact && path.StartsWith(artifact + string Path.DirectorySeparatorChar, StringComparison.Ordinal)) -> + eprintfn "Provenance output must not overwrite or be inside the model artifact: %s" path + exit 2 +| _ -> () + +let digest path = + use stream = File.OpenRead path + SHA256.HashData(stream) |> Convert.ToHexString |> fun value -> value.ToLowerInvariant() + +let files = + if File.Exists artifact then [| artifact |] + else Directory.GetFiles(artifact, "*", SearchOption.AllDirectories) |> Array.sort +let entries = + files + |> Array.map (fun path -> + let name = if File.Exists artifact then Path.GetFileName path else Path.GetRelativePath(artifact, path) + {| path = name; bytes = FileInfo(path).Length; sha256 = digest path |}) +let aggregateText = entries |> Array.map (fun entry -> $"{entry.path}\000{entry.sha256}\n") |> String.concat "" +let aggregate = SHA256.HashData(Encoding.UTF8.GetBytes aggregateText) |> Convert.ToHexString |> fun value -> value.ToLowerInvariant() +let payload = + {| artifact = artifact + kind = if File.Exists artifact then "file" else "directory" + model_id = valueAfter "--model-id" + revision = valueAfter "--revision" + file_count = entries.Length + total_bytes = entries |> Array.sumBy _.bytes + inventory_sha256 = aggregate + files = entries |} +let rendered = JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true)) + "\n" +match output with +| Some path -> + Directory.CreateDirectory(Path.GetDirectoryName path) |> ignore + File.WriteAllText(path, rendered) + printfn "Wrote model provenance snapshot: %s" path +| None -> printf "%s" rendered diff --git a/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py b/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py deleted file mode 100644 index 18f578e4e..000000000 --- a/skills/compare-model-checkpoints/scripts/snapshot_model_provenance.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -"""Create a deterministic provenance snapshot for a local model artifact.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import sys -from pathlib import Path - - -def digest(path: Path) -> str: - value = hashlib.sha256() - with path.open("rb") as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - value.update(chunk) - return value.hexdigest() - - -def validate_output_path(artifact: Path, output: Path | None) -> None: - if output is None: - return - resolved_output = output.resolve() - same_artifact_file = ( - output.exists() and artifact.is_file() and output.samefile(artifact) - ) - if artifact.is_file() and (resolved_output == artifact or same_artifact_file): - raise ValueError( - f"Provenance output would overwrite the model artifact: {resolved_output}" - ) - if artifact.is_dir() and ( - resolved_output == artifact or artifact in resolved_output.parents - ): - raise ValueError( - "Provenance output must be outside the model artifact directory so the " - f"snapshot cannot hash or overwrite itself: {resolved_output}" - ) - if artifact.is_dir() and output.exists(): - for artifact_file in artifact.rglob("*"): - if artifact_file.is_file() and output.samefile(artifact_file): - raise ValueError( - "Provenance output is a hard-link alias of a file inside the model " - f"artifact directory: {artifact_file}" - ) - - -def write_output(path: Path, rendered: str) -> None: - try: - path.write_text(rendered, encoding="utf-8") - except OSError as error: - raise ValueError( - f"Model provenance snapshot could not write output to {path}: {error}" - ) from error - - -def build_snapshot( - artifact: Path, model_id: str | None, revision: str | None -) -> dict[str, object]: - files = ( - [artifact] - if artifact.is_file() - else sorted(path for path in artifact.rglob("*") if path.is_file()) - ) - entries = [ - { - "path": path.name - if artifact.is_file() - else str(path.relative_to(artifact)), - "bytes": path.stat().st_size, - "sha256": digest(path), - } - for path in files - ] - aggregate = hashlib.sha256() - for entry in entries: - aggregate.update(f"{entry['path']}\0{entry['sha256']}\n".encode()) - return { - "artifact": str(artifact), - "kind": "file" if artifact.is_file() else "directory", - "model_id": model_id, - "revision": revision, - "file_count": len(entries), - "total_bytes": sum(path.stat().st_size for path in files), - "inventory_sha256": aggregate.hexdigest(), - "files": entries, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("artifact", type=Path) - parser.add_argument("--model-id") - parser.add_argument("--revision") - parser.add_argument("--output", type=Path) - arguments = parser.parse_args() - artifact = arguments.artifact.resolve() - if not artifact.exists(): - print(f"Model artifact does not exist: {artifact}", file=sys.stderr) - return 2 - try: - validate_output_path(artifact, arguments.output) - except ValueError as error: - print( - f"Model provenance snapshot rejected its output path: {error}", - file=sys.stderr, - ) - return 2 - payload = build_snapshot(artifact, arguments.model_id, arguments.revision) - rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" - if arguments.output: - try: - write_output(arguments.output, rendered) - except ValueError as error: - print(error, file=sys.stderr) - return 2 - print(f"Wrote model provenance snapshot: {arguments.output}") - else: - print(rendered, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/design-model-experiment/SKILL.md b/skills/design-model-experiment/SKILL.md index 8846d4db4..3be68267c 100644 --- a/skills/design-model-experiment/SKILL.md +++ b/skills/design-model-experiment/SKILL.md @@ -19,10 +19,10 @@ Produce an experiment manifest that another operator can run, audit, and compare 6. Define primary metrics, guardrail metrics, uncertainty treatment, and failure thresholds before the run. 7. Estimate compute, storage, time, and paid cost. Set smoke-test and full-run stop conditions. 8. Define raw and derived artifacts, retention, and sensitive-data handling. -9. Copy `assets/experiment-manifest.yaml`, fill it, then run: +9. Copy `assets/experiment-manifest.json`, fill it, then run: ```bash -python3 scripts/validate_experiment_manifest.py path/to/experiment.yaml +dotnet fsi scripts/validate-experiment-manifest.fsx path/to/experiment.json ``` 10. Run the smallest experiment capable of detecting configuration or pipeline failure before spending the full budget. @@ -33,6 +33,6 @@ Keep configuration validation, smoke-run evidence, and final experimental eviden ## Resources -- `assets/experiment-manifest.yaml`: portable experiment template. +- `assets/experiment-manifest.json`: managed experiment template. - `references/experiment-design.md`: field semantics and comparison rules. -- `scripts/validate_experiment_manifest.py`: deterministic structural validation. +- `scripts/validate-experiment-manifest.fsx`: deterministic structural validation. diff --git a/skills/design-model-experiment/assets/experiment-manifest.json b/skills/design-model-experiment/assets/experiment-manifest.json new file mode 100644 index 000000000..218bfc0a7 --- /dev/null +++ b/skills/design-model-experiment/assets/experiment-manifest.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "experiment": { "id": "replace-with-stable-id", "title": "Replace with a concise title", "hypothesis": "Replace with a falsifiable statement", "decision": "Replace with the decision this run informs", "owner": "replace-with-owner" }, + "provenance": { + "code_revision": "replace-with-commit", + "model": { "id": "replace-with-model-id", "revision": "replace-with-model-revision", "license": "replace-with-license" }, + "tokenizer": { "id": "replace-with-tokenizer-id", "revision": "replace-with-tokenizer-revision" }, + "dataset": { "id": "replace-with-dataset-id", "revision": "replace-with-dataset-revision" }, + "environment": { "lockfile": "replace-with-lockfile", "hardware": "replace-with-hardware" } + }, + "method": { "controlled_variable": "replace-with-one-primary-variable", "baseline": "replace-with-baseline", "treatment": "replace-with-treatment", "seed": 42, "generation_parameters": {} }, + "evaluation": { "primary_metrics": ["replace-with-primary-metric"], "guardrail_metrics": ["replace-with-guardrail-metric"], "failure_thresholds": { "replace-with-metric": "replace-with-threshold" } }, + "budget": { "smoke_run": "replace-with-limit", "full_run": "replace-with-limit", "maximum_cost_usd": 0, "stop_conditions": ["replace-with-stop-condition"] }, + "artifacts": { "raw_results": "artifacts/raw", "derived_results": "artifacts/derived", "report": "artifacts/report.md", "sensitive_data": false } +} diff --git a/skills/design-model-experiment/assets/experiment-manifest.yaml b/skills/design-model-experiment/assets/experiment-manifest.yaml deleted file mode 100644 index 19684bfda..000000000 --- a/skills/design-model-experiment/assets/experiment-manifest.yaml +++ /dev/null @@ -1,46 +0,0 @@ -schema_version: 1 -experiment: - id: replace-with-stable-id - title: Replace with a concise title - hypothesis: Replace with a falsifiable statement - decision: Replace with the decision this run informs - owner: replace-with-owner -provenance: - code_revision: replace-with-commit - model: - id: replace-with-model-id - revision: replace-with-model-revision - license: replace-with-license - tokenizer: - id: replace-with-tokenizer-id - revision: replace-with-tokenizer-revision - dataset: - id: replace-with-dataset-id - revision: replace-with-dataset-revision - environment: - lockfile: replace-with-lockfile - hardware: replace-with-hardware -method: - controlled_variable: replace-with-one-primary-variable - baseline: replace-with-baseline - treatment: replace-with-treatment - seed: 42 - generation_parameters: {} -evaluation: - primary_metrics: - - replace-with-primary-metric - guardrail_metrics: - - replace-with-guardrail-metric - failure_thresholds: - replace-with-metric: replace-with-threshold -budget: - smoke_run: replace-with-limit - full_run: replace-with-limit - maximum_cost_usd: 0 - stop_conditions: - - replace-with-stop-condition -artifacts: - raw_results: artifacts/raw - derived_results: artifacts/derived - report: artifacts/report.md - sensitive_data: false diff --git a/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx b/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx new file mode 100644 index 000000000..213622767 --- /dev/null +++ b/skills/design-model-experiment/scripts/validate-experiment-manifest.fsx @@ -0,0 +1,40 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +if args.Length <> 1 then eprintfn "Usage: validate-experiment-manifest.fsx <manifest.json>"; exit 2 +let required = + [ "schema_version"; "experiment.id"; "experiment.title"; "experiment.hypothesis"; "experiment.decision"; "experiment.owner" + "provenance.code_revision"; "provenance.model.id"; "provenance.model.revision"; "provenance.model.license" + "provenance.tokenizer.id"; "provenance.tokenizer.revision"; "provenance.dataset.id"; "provenance.dataset.revision" + "provenance.environment.lockfile"; "provenance.environment.hardware"; "method.controlled_variable"; "method.baseline" + "method.treatment"; "method.seed"; "method.generation_parameters"; "evaluation.primary_metrics"; "evaluation.guardrail_metrics" + "evaluation.failure_thresholds"; "budget.smoke_run"; "budget.full_run"; "budget.maximum_cost_usd"; "budget.stop_conditions" + "artifacts.raw_results"; "artifacts.derived_results"; "artifacts.report"; "artifacts.sensitive_data" ] +let tryAt (root: JsonElement) (path: string) = + ((Some root), path.Split('.')) ||> Array.fold (fun state name -> + state |> Option.bind (fun value -> let mutable child = Unchecked.defaultof<JsonElement> in if value.TryGetProperty(name, &child) then Some child else None)) +let empty (value: JsonElement) = + value.ValueKind = JsonValueKind.Null || value.ValueKind = JsonValueKind.Undefined || + (value.ValueKind = JsonValueKind.String && String.IsNullOrWhiteSpace(value.GetString())) || + (value.ValueKind = JsonValueKind.Array && value.GetArrayLength() = 0) +let placeholder (value: JsonElement) = value.ToString().ToLowerInvariant().Contains("replace-with") || value.ToString().ToLowerInvariant().Contains("replace with") +try + use document = JsonDocument.Parse(File.ReadAllText args[0]) + let root = document.RootElement + let errors = ResizeArray<string>() + for path in required do + match tryAt root path with + | None -> errors.Add($"Required field `{path}` is missing or empty.") + | Some value when empty value -> errors.Add($"Required field `{path}` is missing or empty.") + | Some value when placeholder value -> errors.Add($"Required field `{path}` still contains a template placeholder.") + | _ -> () + match tryAt root "schema_version" with Some value when value.ValueKind = JsonValueKind.Number && value.GetInt32() = 1 -> () | _ -> errors.Add("`schema_version` must be the integer 1.") + match tryAt root "method.seed" with Some value when value.ValueKind = JsonValueKind.Number -> () | _ -> errors.Add("`method.seed` must be an integer.") + match tryAt root "artifacts.sensitive_data" with Some value when value.ValueKind = JsonValueKind.True || value.ValueKind = JsonValueKind.False -> () | _ -> errors.Add("`artifacts.sensitive_data` must be a boolean.") + if errors.Count > 0 then errors |> Seq.iter (eprintfn "%s"); exit 1 + printfn "Experiment manifest is structurally valid: %s" args[0] +with error -> eprintfn "Experiment manifest is not valid JSON: %s" error.Message; exit 2 diff --git a/skills/design-model-experiment/scripts/validate_experiment_manifest.py b/skills/design-model-experiment/scripts/validate_experiment_manifest.py deleted file mode 100644 index 51340578e..000000000 --- a/skills/design-model-experiment/scripts/validate_experiment_manifest.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the required structure of a Model Lab experiment manifest.""" - -from __future__ import annotations - -import argparse -import math -import sys -from pathlib import Path -from typing import Any - -try: - import yaml -except ImportError as error: - raise SystemExit( - "Experiment manifest validation requires PyYAML. Install the repository development dependencies with `uv sync --dev`." - ) from error - -REQUIRED_PATHS = ( - "schema_version", - "experiment.id", - "experiment.title", - "experiment.hypothesis", - "experiment.decision", - "experiment.owner", - "provenance.code_revision", - "provenance.model.id", - "provenance.model.revision", - "provenance.model.license", - "provenance.tokenizer.id", - "provenance.tokenizer.revision", - "provenance.dataset.id", - "provenance.dataset.revision", - "provenance.environment.lockfile", - "provenance.environment.hardware", - "method.controlled_variable", - "method.baseline", - "method.treatment", - "method.seed", - "method.generation_parameters", - "evaluation.primary_metrics", - "evaluation.guardrail_metrics", - "evaluation.failure_thresholds", - "budget.smoke_run", - "budget.full_run", - "budget.maximum_cost_usd", - "budget.stop_conditions", - "artifacts.raw_results", - "artifacts.derived_results", - "artifacts.report", - "artifacts.sensitive_data", -) - - -def value_at(document: dict[str, Any], dotted_path: str) -> Any: - value: Any = document - for component in dotted_path.split("."): - if not isinstance(value, dict) or component not in value: - return None - value = value[component] - return value - - -def contains_placeholder(value: Any) -> bool: - if isinstance(value, str): - normalized = value.lower() - return normalized.startswith("replace with") or normalized.startswith( - "replace-with" - ) - if isinstance(value, list): - return any(contains_placeholder(item) for item in value) - if isinstance(value, dict): - return any( - contains_placeholder(key) or contains_placeholder(item) - for key, item in value.items() - ) - return False - - -def validate(document: Any) -> list[str]: - if not isinstance(document, dict): - return ["The manifest root must be a YAML mapping."] - errors = [] - for path in REQUIRED_PATHS: - value = value_at(document, path) - if value is None or value == "" or value == []: - errors.append(f"Required field `{path}` is missing or empty.") - elif contains_placeholder(value): - errors.append( - f"Required field `{path}` still contains a template placeholder." - ) - if document.get("schema_version") != 1: - errors.append("`schema_version` must be the integer 1.") - for path in ( - "evaluation.primary_metrics", - "evaluation.guardrail_metrics", - "budget.stop_conditions", - ): - value = value_at(document, path) - if ( - not isinstance(value, list) - or not value - or not all(isinstance(item, str) and item.strip() for item in value) - ): - errors.append(f"`{path}` must be a non-empty list of strings.") - if not isinstance(value_at(document, "method.generation_parameters"), dict): - errors.append("`method.generation_parameters` must be a mapping.") - thresholds = value_at(document, "evaluation.failure_thresholds") - if not isinstance(thresholds, dict) or not thresholds: - errors.append("`evaluation.failure_thresholds` must be a non-empty mapping.") - seed = value_at(document, "method.seed") - if not isinstance(seed, int) or isinstance(seed, bool): - errors.append("`method.seed` must be an integer.") - maximum_cost = value_at(document, "budget.maximum_cost_usd") - if ( - not isinstance(maximum_cost, (int, float)) - or isinstance(maximum_cost, bool) - or not math.isfinite(maximum_cost) - or maximum_cost < 0 - ): - errors.append("`budget.maximum_cost_usd` must be a finite non-negative number.") - if not isinstance(value_at(document, "artifacts.sensitive_data"), bool): - errors.append("`artifacts.sensitive_data` must be a boolean.") - return errors - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("manifest", type=Path) - arguments = parser.parse_args() - try: - document = yaml.safe_load(arguments.manifest.read_text(encoding="utf-8")) - except FileNotFoundError: - print( - f"Experiment manifest does not exist: {arguments.manifest}", file=sys.stderr - ) - return 2 - except yaml.YAMLError as error: - print(f"Experiment manifest is not valid YAML: {error}", file=sys.stderr) - return 2 - errors = validate(document) - if errors: - for validation_error in errors: - print(validation_error, file=sys.stderr) - return 1 - print(f"Experiment manifest is structurally valid: {arguments.manifest}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/diagnose-apple-entitlements/SKILL.md b/skills/diagnose-apple-entitlements/SKILL.md index ba3f92cc2..37aa13636 100644 --- a/skills/diagnose-apple-entitlements/SKILL.md +++ b/skills/diagnose-apple-entitlements/SKILL.md @@ -58,10 +58,6 @@ Trace one desired behavior through tracked project source, developer-account/pro - Use `macos-privacy-permissions-workflow` for user or managed privacy authorization. - Use `audit-apple-signing-and-containment` for forensic artifact audit and `research-macos-security-control` for private entitlement or exact-build enforcement research. -## Customization - -Use `references/customization-flow.md`. The workflow has no runtime knobs; comparison states and evidence levels may not be skipped. - ## References - `references/five-state-entitlement-comparison.md` diff --git a/skills/diagnose-apple-entitlements/references/customization-flow.md b/skills/diagnose-apple-entitlements/references/customization-flow.md deleted file mode 100644 index 11717804d..000000000 --- a/skills/diagnose-apple-entitlements/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# Diagnose Apple Entitlements Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; the five-state comparison and final-artifact validation remain mandatory. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/skills/diagnose-apple-entitlements/references/customization.template.yaml b/skills/diagnose-apple-entitlements/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/diagnose-apple-entitlements/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/diagnose-apple-entitlements/scripts/customization_config.py b/skills/diagnose-apple-entitlements/scripts/customization_config.py deleted file mode 100755 index 158abb46b..000000000 --- a/skills/diagnose-apple-entitlements/scripts/customization_config.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist entitlement-diagnosis customization state.""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "diagnose-apple-entitlements" -ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def read(path: Path, required: bool = False) -> dict: - if not path.exists(): - if required: - fail(f"Missing YAML file: {path}") - return {} - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return value - - -def validate(value: dict, partial: bool = False) -> None: - if set(value) - KEYS: - fail(f"Unknown top-level keys: {', '.join(sorted(set(value) - KEYS))}") - if not partial and set(value) != KEYS: - fail("State must define schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - for key, item in value["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(item, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def destination() -> Path: - root = Path(os.environ.get("APPLE_DEV_SKILLS_CONFIG_HOME", ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def current() -> dict: - template = read(Path(__file__).resolve().parents[1] / "references/customization.template.yaml", True) - saved = read(destination()) - validate(template) - if saved: - validate(saved) - return {"schemaVersion": 1, "isCustomized": saved.get("isCustomized", False), "settings": {**template["settings"], **saved.get("settings", {})}} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage entitlement-diagnosis customization") - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("path") - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - target = destination() - if args.command == "path": - print(target) - elif args.command == "effective": - print(yaml.safe_dump(current(), sort_keys=False), end="") - elif args.command == "reset": - if target.exists(): - target.unlink() - print(target) - else: - incoming = read(Path(args.input), True) - validate(incoming, partial=True) - updated = {"schemaVersion": 1, "isCustomized": True, "settings": {**current()["settings"], **incoming.get("settings", {})}} - validate(updated) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(yaml.safe_dump(updated, sort_keys=False), encoding="utf-8") - print(target) - - -if __name__ == "__main__": - main() diff --git a/skills/evaluate-language-model/SKILL.md b/skills/evaluate-language-model/SKILL.md index f321140f7..2b0824cdc 100644 --- a/skills/evaluate-language-model/SKILL.md +++ b/skills/evaluate-language-model/SKILL.md @@ -39,4 +39,4 @@ State the population, task, model artifact, prompt/template, decoding settings, - `assets/eval-cases.jsonl`: starter case schema. - `assets/evaluation-report.md`: comparison report template. - `references/evaluation-methods.md`: grader and uncertainty rules. -- `scripts/compare_eval_runs.py`: paired JSONL comparison. +- `scripts/compare-eval-runs.fsx`: paired JSONL comparison. diff --git a/skills/evaluate-language-model/scripts/compare-eval-runs.fsx b/skills/evaluate-language-model/scripts/compare-eval-runs.fsx new file mode 100644 index 000000000..027d551d0 --- /dev/null +++ b/skills/evaluate-language-model/scripts/compare-eval-runs.fsx @@ -0,0 +1,65 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Collections.Generic +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 +let valueAfter flag = args |> Array.tryFindIndex ((=) flag) |> Option.bind (fun index -> if index + 1 < args.Length then Some args[index + 1] else None) +let optionValues = [ valueAfter "--output" ] |> List.choose id |> Set.ofList +let positional = args |> Array.filter (fun value -> not (value.StartsWith("--")) && not (optionValues.Contains value)) +if positional.Length <> 2 then + eprintfn "Usage: compare-eval-runs.fsx <baseline.jsonl> <treatment.jsonl> [--allow-partial] [--output PATH]" + exit 2 + +let load path = + let values = Dictionary<string, float>() + File.ReadLines(path) + |> Seq.iteri (fun index line -> + if not (String.IsNullOrWhiteSpace line) then + use document = JsonDocument.Parse line + let root = document.RootElement + let mutable idElement = Unchecked.defaultof<JsonElement> + let mutable scoreElement = Unchecked.defaultof<JsonElement> + if not (root.TryGetProperty("id", &idElement)) || idElement.ValueKind <> JsonValueKind.String || String.IsNullOrWhiteSpace(idElement.GetString()) then + failwith $"{path}:{index + 1} requires a non-empty string `id`." + if not (root.TryGetProperty("score", &scoreElement)) || scoreElement.ValueKind <> JsonValueKind.Number then + failwith $"{path}:{index + 1} requires a finite numeric `score`." + let identifier = idElement.GetString() + let score = scoreElement.GetDouble() + if not (Double.IsFinite score) then failwith $"{path}:{index + 1} requires a finite numeric `score`." + if values.ContainsKey identifier then failwith $"{path}:{index + 1} repeats evaluation id `{identifier}`." + values.Add(identifier, score)) + if values.Count = 0 then failwith $"{path} contains no evaluation results." + values + +try + let baseline = load positional[0] + let treatment = load positional[1] + let baselineIds = baseline.Keys |> Set.ofSeq + let treatmentIds = treatment.Keys |> Set.ofSeq + let shared = Set.intersect baselineIds treatmentIds |> Set.toArray |> Array.sort + let partial = baselineIds <> treatmentIds + if shared.Length = 0 then failwith "Evaluation comparison found no shared case ids." + if partial && not (args |> Array.contains "--allow-partial") then failwith "Evaluation runs must contain identical case ids; pass --allow-partial only for a diagnostic comparison." + let cases = shared |> Array.map (fun id -> let delta = treatment[id] - baseline[id] in {| id = id; baseline = baseline[id]; treatment = treatment[id]; delta = delta |}) + let payload = + {| baseline_count = baseline.Count + treatment_count = treatment.Count + paired_count = shared.Length + partial_comparison = partial + baseline_only = Set.difference baselineIds treatmentIds |> Set.toArray |> Array.sort + treatment_only = Set.difference treatmentIds baselineIds |> Set.toArray |> Array.sort + mean_paired_delta = cases |> Array.averageBy _.delta + improved = cases |> Array.filter (fun item -> item.delta > 0.0) |> Array.length + unchanged = cases |> Array.filter (fun item -> item.delta = 0.0) |> Array.length + regressed = cases |> Array.filter (fun item -> item.delta < 0.0) |> Array.length + cases = cases |} + let rendered = JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true)) + "\n" + match valueAfter "--output" with + | Some path -> File.WriteAllText(path, rendered); printfn "Wrote paired evaluation comparison: %s" path + | None -> printf "%s" rendered +with error -> + eprintfn "Evaluation comparison could not load its inputs: %s" error.Message + exit 2 diff --git a/skills/evaluate-language-model/scripts/compare_eval_runs.py b/skills/evaluate-language-model/scripts/compare_eval_runs.py deleted file mode 100644 index e325365af..000000000 --- a/skills/evaluate-language-model/scripts/compare_eval_runs.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -"""Compare paired Model Lab JSONL evaluation results.""" - -from __future__ import annotations - -import argparse -import json -import math -import statistics -import sys -from pathlib import Path -from typing import Any - - -def load_results(path: Path) -> dict[str, dict[str, Any]]: - results: dict[str, dict[str, Any]] = {} - for line_number, line in enumerate( - path.read_text(encoding="utf-8").splitlines(), 1 - ): - if not line.strip(): - continue - try: - item = json.loads(line) - except json.JSONDecodeError as error: - raise ValueError( - f"{path}:{line_number} is not valid JSON: {error}" - ) from error - identifier = item.get("id") - score = item.get("score") - if not isinstance(identifier, str) or not identifier: - raise ValueError(f"{path}:{line_number} requires a non-empty string `id`.") - if identifier in results: - raise ValueError( - f"{path}:{line_number} repeats evaluation id `{identifier}`." - ) - if ( - not isinstance(score, (int, float)) - or isinstance(score, bool) - or not math.isfinite(score) - ): - raise ValueError(f"{path}:{line_number} requires a finite numeric `score`.") - results[identifier] = item - if not results: - raise ValueError(f"{path} contains no evaluation results.") - return results - - -def paired_ids( - baseline: dict[str, dict[str, Any]], - treatment: dict[str, dict[str, Any]], - allow_partial: bool, -) -> list[str]: - baseline_ids = set(baseline) - treatment_ids = set(treatment) - if baseline_ids != treatment_ids and not allow_partial: - baseline_only = sorted(baseline_ids - treatment_ids) - treatment_only = sorted(treatment_ids - baseline_ids) - raise ValueError( - "Evaluation runs must contain identical case ids for a paired comparison. " - f"Baseline-only ids: {baseline_only}; treatment-only ids: {treatment_only}. " - "Use --allow-partial only for an explicitly labeled diagnostic comparison." - ) - shared = sorted(baseline_ids & treatment_ids) - if not shared: - raise ValueError("Evaluation comparison found no shared case ids.") - return shared - - -def validate_output_path(output: Path | None, *inputs: Path) -> None: - if output is None: - return - resolved_output = output.resolve() - for input_path in inputs: - same_existing_file = output.exists() and output.samefile(input_path) - if resolved_output == input_path.resolve() or same_existing_file: - raise ValueError( - f"Evaluation comparison output would overwrite an input file: {resolved_output}" - ) - - -def write_output(path: Path, rendered: str) -> None: - try: - path.write_text(rendered, encoding="utf-8") - except OSError as error: - raise ValueError( - f"Evaluation comparison could not write output to {path}: {error}" - ) from error - - -def build_comparison( - baseline: dict[str, dict[str, Any]], - treatment: dict[str, dict[str, Any]], - allow_partial: bool = False, -) -> dict[str, Any]: - shared = paired_ids(baseline, treatment, allow_partial) - deltas = [ - float(treatment[key]["score"]) - float(baseline[key]["score"]) for key in shared - ] - return { - "baseline_count": len(baseline), - "treatment_count": len(treatment), - "paired_count": len(shared), - "partial_comparison": set(baseline) != set(treatment), - "baseline_only": sorted(set(baseline) - set(treatment)), - "treatment_only": sorted(set(treatment) - set(baseline)), - "mean_paired_delta": statistics.fmean(deltas), - "improved": sum(delta > 0 for delta in deltas), - "unchanged": sum(delta == 0 for delta in deltas), - "regressed": sum(delta < 0 for delta in deltas), - "cases": [ - { - "id": key, - "baseline": baseline[key]["score"], - "treatment": treatment[key]["score"], - "delta": delta, - } - for key, delta in zip(shared, deltas) - ], - } - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("baseline", type=Path) - parser.add_argument("treatment", type=Path) - parser.add_argument("--output", type=Path) - parser.add_argument( - "--allow-partial", - action="store_true", - help="Compare only shared ids and retain missing-id lists for diagnostic use.", - ) - arguments = parser.parse_args() - try: - validate_output_path(arguments.output, arguments.baseline, arguments.treatment) - baseline = load_results(arguments.baseline) - treatment = load_results(arguments.treatment) - payload = build_comparison(baseline, treatment, arguments.allow_partial) - except (OSError, ValueError) as error: - print( - f"Evaluation comparison could not load its inputs: {error}", file=sys.stderr - ) - return 2 - rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" - if arguments.output: - try: - write_output(arguments.output, rendered) - except ValueError as error: - print(error, file=sys.stderr) - return 2 - print(f"Wrote paired evaluation comparison: {arguments.output}") - else: - print(rendered, end="") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/evaluate-tool-calling-model/SKILL.md b/skills/evaluate-tool-calling-model/SKILL.md index 92587def4..c76648a0b 100644 --- a/skills/evaluate-tool-calling-model/SKILL.md +++ b/skills/evaluate-tool-calling-model/SKILL.md @@ -40,9 +40,6 @@ Evaluate these stages separately: whether a tool is needed, which tool is select This skill evaluates a model plus harness interface. Use `agent-engineering-skills` when the primary artifact is an agent skill or plugin package, and `agent-portability-skills` when the question is host compatibility rather than behavioral quality. -Use `python-skills:build-python-agent-service` when the primary work is a -Python implementation rather than measurement. - ## References Read `references/tool-evaluation-matrix.md` for minimum cases and metrics. diff --git a/skills/fastapi-service-workflow/SKILL.md b/skills/fastapi-service-workflow/SKILL.md deleted file mode 100644 index 1210141da..000000000 --- a/skills/fastapi-service-workflow/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: fastapi-service-workflow -description: Maintain existing uv-managed FastAPI services, including route and dependency composition, typed settings, lifespan, async and integration testing, OpenAPI review, deployment-readiness handoff, and service-specific diagnostics. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients maintaining FastAPI services on macOS with uv, typed configuration, async Python, and the repository's existing deployment tools. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-fastapi -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# FastAPI Service Workflow - -## Purpose - -Maintain an existing FastAPI service without turning routing, application -lifecycle, domain logic, deployment, and MCP integration into one layer. Keep -HTTP adapters thin around typed domain behavior and make startup, shutdown, -configuration, and public API changes explicit. - -## Workflow - -1. Inspect `pyproject.toml`, app entrypoint, routers, dependencies, settings, - lifespan, tests, OpenAPI output, CI, and deployment configuration. -2. Classify the requested change as route composition, request/response model, - dependency, settings, lifecycle, async boundary, public OpenAPI contract, or - deployment-readiness work. -3. Keep route handlers focused on HTTP translation. Put reusable behavior in - domain modules or existing service boundaries rather than duplicating it - across routes, CLI commands, or MCP tools. -4. Keep settings typed and injectable. Store safe defaults separately from - machine-local or deployment secrets; use dependency overrides in tests. -5. Use one lifespan contract for resources such as pools, clients, queues, and - background workers. Combine lifespans deliberately when mounting another - ASGI application instead of silently replacing startup or shutdown work. -6. Review the OpenAPI effect of public routes, models, status codes, operation - IDs, security requirements, and deprecations. Treat incompatible changes as - an API compatibility decision. -7. Run focused HTTP and async tests, then the repository's configured checks: - ```bash - uv run pytest - uv run ruff check . - uv run mypy . - ``` -8. Report deployment readiness separately: configuration source, migrations, - health endpoint, logs, timeouts, workers, and external dependencies. Do not - deploy unless the user asks for that operation. - -## Testing And Diagnostics - -Use dependency overrides for paid, privileged, or nondeterministic services and -clear them after each test. Use an async client for async behavior, and make -lifespan execution explicit when tests depend on startup resources. - -Diagnose service failures in this order: import or app factory, settings, -lifespan, route/dependency resolution, response validation, async boundary, -then external integration. Hand generic lockfile, package, CI, or tool failures -to their existing Python workflows. - -## Handoffs - -- New service scaffolding: `bootstrap-python-service`. -- Generic implementation and package structure: `build-python-project`. -- FastAPI plus FastMCP in one codebase: `integrate-fastapi-fastmcp`. -- MCP service maintenance: `fastmcp-service-workflow`. -- Package, CI, testing, tooling, and upgrade work: their corresponding Python - workflows. - -## Output Shape - -Return the service boundary changed, HTTP/OpenAPI impact, settings and -lifespan effect, tests and commands run, deployment-readiness evidence, and -residual risk. - -## Guardrails - -- Do not add a repository, manager, or service wrapper when a route can call an - existing typed domain boundary directly. -- Do not run a service, migration, external write, or deployment merely to - validate static guidance without user approval. -- Do not change public OpenAPI behavior silently. - -## References - -- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/) -- [FastAPI settings](https://fastapi.tiangolo.com/advanced/settings/) -- [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) -- [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/) diff --git a/skills/fastapi-service-workflow/agents/openai.yaml b/skills/fastapi-service-workflow/agents/openai.yaml deleted file mode 100644 index 7abdbf506..000000000 --- a/skills/fastapi-service-workflow/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "FastAPI Service Workflow" - short_description: "Maintain FastAPI routes, settings, lifespan, OpenAPI, and service tests." - brand_color: "#0F766E" - default_prompt: "Use $fastapi-service-workflow to inspect this existing FastAPI service, preserve its typed settings and lifespan contract, implement the requested route or dependency change, review the OpenAPI impact, and run focused uv validation." - -policy: - allow_implicit_invocation: true diff --git a/skills/fastmcp-service-workflow/SKILL.md b/skills/fastmcp-service-workflow/SKILL.md deleted file mode 100644 index 7b6e91416..000000000 --- a/skills/fastmcp-service-workflow/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: fastmcp-service-workflow -description: Maintain existing uv-managed FastMCP servers, including transport and lifespan behavior, tool/resource/prompt curation, authorization and input boundaries, client integration tests, generated-surface review, and upgrade diagnostics. -license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients maintaining FastMCP servers on macOS with uv, the installed FastMCP version, and the repository's existing transport and deployment tools. -metadata: - owner: gaelic-ghost - repo: python-skills - category: python-fastmcp -allowed-tools: Read Bash(rg:*) Bash(git:*) Bash(uv:*) ---- - -# FastMCP Service Workflow - -## Purpose - -Maintain a curated FastMCP server as an application surface, not a mechanical -mirror of HTTP routes. Keep tool, resource, and prompt design user-oriented; -make transport, authentication, authorization, lifespan, and side-effect -boundaries explicit. - -## Workflow - -1. Inspect the installed FastMCP version, `pyproject.toml`, server entrypoint, - component definitions, transport, lifespan, auth configuration, tests, and - deployment configuration before changing behavior. -2. Classify each public capability: - - a tool for an action or bounded computation; - - a resource or template for read-oriented data; - - a prompt for a reusable message workflow. -3. Keep implementation logic in existing typed domain boundaries. Do not expose - transport-centric route names, broad autogenerated APIs, secrets, or raw - infrastructure controls as MCP capabilities. -4. Choose transport deliberately. STDIO clients own the server process and its - environment; HTTP is the normal production transport. Make every required - configuration value explicit for the selected transport. -5. Define authorization at the component or server boundary when the HTTP - deployment needs it. Do not infer identity or permissions from a model's - request, and do not claim HTTP OAuth checks protect STDIO transport. -6. Treat `FastMCP.from_fastapi(...)` and OpenAPI imports as review inputs. - Curate names, parameter shapes, errors, and capability boundaries before - keeping generated output as a public server surface. -7. Test through an in-memory FastMCP client first, then add transport and auth - integration tests only where those are part of the deployment contract. -8. Run the repository's configured checks and report any untested transport, - authorization, or external-write boundary separately. - -## Version And Documentation Discipline - -Use the installed FastMCP version and its release notes for implementation -decisions. The public FastMCP documentation tracks `main` and can describe -unreleased behavior. Use a host-provided `fastmcp_docs` tool only when one is -already configured; this plugin does not package it. - -## Handoffs - -- New MCP scaffold: `bootstrap-python-mcp-service`. -- FastAPI/FastMCP coexistence or mounting: `integrate-fastapi-fastmcp`. -- FastAPI service maintenance: `fastapi-service-workflow`. -- Generic testing, package, CI, tooling, and upgrade work: the corresponding - Python workflows. - -## Output Shape - -Return the component and transport boundary changed, installed FastMCP version, -authorization effect, test commands and results, deployment-readiness effect, -and residual risk. - -## Guardrails - -- Do not add a generated route mirror as a long-term MCP API without curation. -- Do not expose write-capable tools without explicit authorization, input, - timeout, idempotency, and approval behavior. -- Do not run a production transport, deploy a server, or execute live writes - only to validate the skill. - -## References - -- [FastMCP client testing](https://gofastmcp.com/servers/testing) -- [FastMCP transports](https://gofastmcp.com/clients/transports) -- [FastMCP authorization](https://gofastmcp.com/servers/authorization) -- [FastMCP CLI](https://gofastmcp.com/cli/overview) diff --git a/skills/fastmcp-service-workflow/agents/openai.yaml b/skills/fastmcp-service-workflow/agents/openai.yaml deleted file mode 100644 index da01051c8..000000000 --- a/skills/fastmcp-service-workflow/agents/openai.yaml +++ /dev/null @@ -1,8 +0,0 @@ -interface: - display_name: "FastMCP Service Workflow" - short_description: "Maintain FastMCP components, transports, authorization, and client tests." - brand_color: "#1D4ED8" - default_prompt: "Use $fastmcp-service-workflow to inspect this existing FastMCP server and installed version, curate the requested tool, resource, or prompt change, preserve transport and authorization boundaries, and validate it with a focused uv client test." - -policy: - allow_implicit_invocation: true diff --git a/skills/file-provider-and-finder-sync-workflow/SKILL.md b/skills/file-provider-and-finder-sync-workflow/SKILL.md index 8cff50204..d85255e71 100644 --- a/skills/file-provider-and-finder-sync-workflow/SKILL.md +++ b/skills/file-provider-and-finder-sync-workflow/SKILL.md @@ -93,12 +93,6 @@ This skill owns that decision, File Provider synchronization mechanics, and Find - Recommend `explore-apple-swift-docs` for current File Provider or Finder Sync API confirmation. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable File Provider/Finder Sync target structure guidance. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the common customization-file contract. Synchronization ownership, conflict policy, and monitored-directory scope remain product evidence, not opaque defaults. - ## References ### Workflow References @@ -106,7 +100,6 @@ Use `references/customization-flow.md`. - `references/file-provider-synchronization.md` - `references/finder-sync-boundaries.md` - `references/privacy-validation-and-recovery.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -119,5 +112,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project guidance for File Provider and Finder Sync targets. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md b/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md deleted file mode 100644 index a71d6f199..000000000 --- a/skills/file-provider-and-finder-sync-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# File Provider and Finder Sync Workflow Customization Contract - -## Purpose - -Preserve the common customization-file contract without hiding synchronization authority, conflict policy, or monitored-folder scope in unmanaged defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` provides the shared configuration shape. -- The workflow ignores persisted settings because remote identity, destructive behavior, and Finder scope require product-specific validation. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after documenting its synchronization and privacy effects. -3. Validate YAML before applying it. diff --git a/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml b/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/file-provider-and-finder-sync-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py b/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py deleted file mode 100755 index 27ef917b5..000000000 --- a/skills/file-provider-and-finder-sync-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-extension-control-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/hermes-agent-compatibility/SKILL.md b/skills/hermes-agent-compatibility/SKILL.md index f105c3c07..d84db6a99 100644 --- a/skills/hermes-agent-compatibility/SKILL.md +++ b/skills/hermes-agent-compatibility/SKILL.md @@ -40,8 +40,7 @@ export manually. 3. Add `metadata.hermes.category` and `metadata.hermes.tags` when they improve Hermes discovery without changing the skill's meaning. 4. Add the skill name to the relevant root `skills.sh.json` grouping. -5. Run `uv run scripts/export_hermes_skills.py` and - `uv run scripts/validate_hermes_compatibility.py`. +5. Run `just repo-sync` and `just repo-validate` from the Socket root. 6. Review the generated root `skills/` diff with the authored source. The validator requires an exact mirror so a GitHub tap installs the reviewed content. diff --git a/skills/linux-development-vm-workflow/SKILL.md b/skills/linux-development-vm-workflow/SKILL.md index 2ef428959..bbfc7e173 100644 --- a/skills/linux-development-vm-workflow/SKILL.md +++ b/skills/linux-development-vm-workflow/SKILL.md @@ -62,10 +62,6 @@ Prepare one persistent Linux development environment whose lifecycle, host integ - Use `xcode-build-run-workflow`, `swift-package-build-run-workflow`, or stack-specific skills after the guest is ready. - Use `prepare-isolated-analysis-lab` for disposable hostile-workload controls. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [Linux development guest matrix](references/linux-development-guest-matrix.md) diff --git a/skills/linux-development-vm-workflow/references/customization-flow.md b/skills/linux-development-vm-workflow/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/skills/linux-development-vm-workflow/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/skills/linux-development-vm-workflow/references/customization.template.yaml b/skills/linux-development-vm-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/linux-development-vm-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/linux-development-vm-workflow/scripts/customization_config.py b/skills/linux-development-vm-workflow/scripts/customization_config.py deleted file mode 100755 index e814a9867..000000000 --- a/skills/linux-development-vm-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "linux-development-vm-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/macos-development-vm-workflow/SKILL.md b/skills/macos-development-vm-workflow/SKILL.md index 7c2386dcd..b7809efa9 100644 --- a/skills/macos-development-vm-workflow/SKILL.md +++ b/skills/macos-development-vm-workflow/SKILL.md @@ -58,10 +58,6 @@ Prepare a reproducible macOS guest while keeping restore images, identity, disks - Use `prepare-isolated-analysis-lab` before executing untrusted content. - Use a spare physical Mac when hardware, recoveryOS, Secure Enclave, device, performance, or anti-VM fidelity is required. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [macOS VM artifact lifecycle](references/macos-vm-artifact-lifecycle.md) diff --git a/skills/macos-development-vm-workflow/references/customization-flow.md b/skills/macos-development-vm-workflow/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/skills/macos-development-vm-workflow/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/skills/macos-development-vm-workflow/references/customization.template.yaml b/skills/macos-development-vm-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/macos-development-vm-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/macos-development-vm-workflow/scripts/customization_config.py b/skills/macos-development-vm-workflow/scripts/customization_config.py deleted file mode 100755 index 163086d49..000000000 --- a/skills/macos-development-vm-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "macos-development-vm-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/macos-privacy-permissions-workflow/SKILL.md b/skills/macos-privacy-permissions-workflow/SKILL.md index c54cd9ec2..ea1ae4625 100644 --- a/skills/macos-privacy-permissions-workflow/SKILL.md +++ b/skills/macos-privacy-permissions-workflow/SKILL.md @@ -59,10 +59,6 @@ Identify the protected operation, responsible executable, and current public aut - Use `research-macos-security-control` for private TCC symbols, database schemas, daemon behavior, or exact-build implementation research. - Use Cybersecurity Skills for suspicious prompts, unexplained grants, Gatekeeper/XProtect alerts, or host compromise questions. -## Customization - -Use `references/customization-flow.md`. The workflow has no runtime knobs; permission behavior must remain tied to the recorded identity, OS build, public API, and user or managed decision. - ## References - `references/permission-class-matrix.md` diff --git a/skills/macos-privacy-permissions-workflow/references/customization-flow.md b/skills/macos-privacy-permissions-workflow/references/customization-flow.md deleted file mode 100644 index b05416944..000000000 --- a/skills/macos-privacy-permissions-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# macOS Privacy Permissions Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; permission conclusions must remain derived from current documentation, stable code identity, exact host state, and the recorded user or managed decision. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/skills/macos-privacy-permissions-workflow/references/customization.template.yaml b/skills/macos-privacy-permissions-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/macos-privacy-permissions-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/macos-privacy-permissions-workflow/scripts/customization_config.py b/skills/macos-privacy-permissions-workflow/scripts/customization_config.py deleted file mode 100755 index 8474f269a..000000000 --- a/skills/macos-privacy-permissions-workflow/scripts/customization_config.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist macOS privacy workflow customization state.""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "macos-privacy-permissions-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def load(path: Path, *, required: bool) -> dict: - if not path.exists(): - if required: - fail(f"Missing YAML file: {path}") - return {} - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if not isinstance(data, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return data - - -def validate(data: dict, *, partial: bool) -> None: - unknown = set(data) - ALLOWED_KEYS - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial and set(data) != ALLOWED_KEYS: - fail("Customization state must define schemaVersion, isCustomized, and settings") - if "schemaVersion" in data and data["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in data and not isinstance(data["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in data: - if not isinstance(data["settings"], dict): - fail("settings must be a mapping") - for key, value in data["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def effective() -> dict: - base = load(template_path(), required=True) - saved = load(durable_path(), required=False) - validate(base, partial=False) - validate(saved, partial=False) if saved else None - merged = { - "schemaVersion": saved.get("schemaVersion", base["schemaVersion"]), - "isCustomized": saved.get("isCustomized", base["isCustomized"]), - "settings": {**base["settings"], **saved.get("settings", {})}, - } - validate(merged, partial=False) - return merged - - -def render(data: dict) -> str: - return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) - - -def command_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def command_effective(_: argparse.Namespace) -> None: - print(render(effective()), end="") - - -def command_apply(args: argparse.Namespace) -> None: - incoming = load(Path(args.input), required=True) - validate(incoming, partial=True) - current = effective() - updated = { - "schemaVersion": 1, - "isCustomized": True, - "settings": {**current["settings"], **incoming.get("settings", {})}, - } - validate(updated, partial=False) - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(render(updated), encoding="utf-8") - print(target) - - -def command_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage macOS privacy workflow customization") - commands = parser.add_subparsers(dest="command", required=True) - path_parser = commands.add_parser("path") - path_parser.set_defaults(func=command_path) - effective_parser = commands.add_parser("effective") - effective_parser.set_defaults(func=command_effective) - apply_parser = commands.add_parser("apply") - apply_parser.add_argument("--input", required=True) - apply_parser.set_defaults(func=command_apply) - reset_parser = commands.add_parser("reset") - reset_parser.set_defaults(func=command_reset) - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/macos-sandbox-file-access-workflow/SKILL.md b/skills/macos-sandbox-file-access-workflow/SKILL.md index 0e91e6bb0..f96362102 100644 --- a/skills/macos-sandbox-file-access-workflow/SKILL.md +++ b/skills/macos-sandbox-file-access-workflow/SKILL.md @@ -59,10 +59,6 @@ Preserve user intent and access lifetime while selecting the smallest supported - Use `app-extension-architecture-workflow` for extension lifecycle/IPC design and Xcode workflows for project edits. - Use `research-macos-security-control` for private sandbox profiles, extensions, or exact-build Seatbelt behavior. -## Customization - -Use `references/customization-flow.md`. The workflow has no runtime knobs; access scope must follow the recorded feature, process, resource, and lifetime. - ## References - `references/sandbox-and-filesystem-control-map.md` diff --git a/skills/macos-sandbox-file-access-workflow/references/customization-flow.md b/skills/macos-sandbox-file-access-workflow/references/customization-flow.md deleted file mode 100644 index 8f5781c8b..000000000 --- a/skills/macos-sandbox-file-access-workflow/references/customization-flow.md +++ /dev/null @@ -1,5 +0,0 @@ -# macOS Sandbox File Access Customization Contract - -The first version defines no runtime-enforced knobs. `scripts/customization_config.py` preserves the shared Apple Dev Skills customization contract; access scope must be selected from the concrete resource, process, operation, distribution, and persistence need. - -Inspect settings with `scripts/customization_config.py effective`, persist a documented change with `apply --input <yaml-file>`, and verify the effective result afterward. diff --git a/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml b/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/macos-sandbox-file-access-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py b/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py deleted file mode 100755 index 7c8b797e1..000000000 --- a/skills/macos-sandbox-file-access-workflow/scripts/customization_config.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist sandbox file-access workflow customization state.""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "macos-sandbox-file-access-workflow" -ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def read(path: Path, required: bool = False) -> dict: - if not path.exists(): - if required: - fail(f"Missing YAML file: {path}") - return {} - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return value - - -def validate(value: dict, partial: bool = False) -> None: - if set(value) - KEYS: - fail(f"Unknown top-level keys: {', '.join(sorted(set(value) - KEYS))}") - if not partial and set(value) != KEYS: - fail("State must define schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - for key, item in value["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(item, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def destination() -> Path: - root = Path(os.environ.get("APPLE_DEV_SKILLS_CONFIG_HOME", ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def current() -> dict: - template = read(Path(__file__).resolve().parents[1] / "references/customization.template.yaml", True) - saved = read(destination()) - validate(template) - if saved: - validate(saved) - return {"schemaVersion": 1, "isCustomized": saved.get("isCustomized", False), "settings": {**template["settings"], **saved.get("settings", {})}} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage sandbox file-access customization") - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("path") - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - target = destination() - if args.command == "path": - print(target) - elif args.command == "effective": - print(yaml.safe_dump(current(), sort_keys=False), end="") - elif args.command == "reset": - if target.exists(): - target.unlink() - print(target) - else: - incoming = read(Path(args.input), True) - validate(incoming, partial=True) - updated = {"schemaVersion": 1, "isCustomized": True, "settings": {**current()["settings"], **incoming.get("settings", {})}} - validate(updated) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(yaml.safe_dump(updated, sort_keys=False), encoding="utf-8") - print(target) - - -if __name__ == "__main__": - main() diff --git a/skills/mailkit-workflow/SKILL.md b/skills/mailkit-workflow/SKILL.md index b55e3a75b..33927619d 100644 --- a/skills/mailkit-workflow/SKILL.md +++ b/skills/mailkit-workflow/SKILL.md @@ -97,19 +97,12 @@ It does not own a mail server, IMAP/SMTP transport, account provisioning, genera - Recommend `explore-apple-swift-docs` when current MailKit symbols or capability behavior need source-specific confirmation. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable containing-app and extension-target project guidance. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the common customization-file contract. Mail handler choice, message policy, and security decisions remain project-specific and must not be converted into opaque persistent defaults. - ## References ### Workflow References - `references/mailkit-capabilities-and-handler-boundaries.md` - `references/privacy-security-and-validation.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -122,5 +115,3 @@ Use `references/customization-flow.md`. - Recommend `references/snippets/apple-xcode-project-core.md` for reusable Xcode project guidance for MailKit app and extension targets. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/skills/mailkit-workflow/references/customization-flow.md b/skills/mailkit-workflow/references/customization-flow.md deleted file mode 100644 index 7c9fa5c04..000000000 --- a/skills/mailkit-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# MailKit Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without persisting mail-access, action, header, or message-security policy as hidden defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` provides the shared configuration shape. -- The workflow ignores persisted settings because handler declarations and mail-data policy must be explicit for each product. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after documenting its MailKit capability, user impact, and privacy boundary. -3. Validate YAML before applying it. diff --git a/skills/mailkit-workflow/references/customization.template.yaml b/skills/mailkit-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/mailkit-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/mailkit-workflow/scripts/customization_config.py b/skills/mailkit-workflow/scripts/customization_config.py deleted file mode 100755 index 27ef917b5..000000000 --- a/skills/mailkit-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-extension-control-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md b/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md index 0ad49ca90..b32b63051 100644 --- a/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md +++ b/skills/maintain-project-contributing/assets/CONTRIBUTING.template.md @@ -46,11 +46,9 @@ Describe the terminology, casing, and naming patterns contributors should match ### Accessibility Expectations -Contributors must keep changes aligned with the project's accessibility contract in [`ACCESSIBILITY.md`](./ACCESSIBILITY.md). - -If a change affects UI semantics, input behavior, focus flow, labels, announcements, motion, contrast, zoom behavior, content structure, or assistive-technology compatibility, verify the affected surface against the documented accessibility standards before asking for review. - -If a change introduces a new accessibility limitation, exception, or remediation plan, update `ACCESSIBILITY.md` in the same pass unless maintainers have explicitly agreed on a different tracking path. +Keep commands, logs, headings, links, errors, and user-facing behavior readable +and actionable. Record product-specific accessibility requirements beside the +surface that owns them; do not create a separate root accessibility contract. ### Verification diff --git a/skills/operate-acp-agent-integration/SKILL.md b/skills/operate-acp-agent-integration/SKILL.md index 724517bff..fb0b311d2 100644 --- a/skills/operate-acp-agent-integration/SKILL.md +++ b/skills/operate-acp-agent-integration/SKILL.md @@ -20,7 +20,7 @@ for the current connection and failure map. Check the capabilities negotiated by the actual pair; do not infer wire compatibility from SDK package versions or implement a draft RFD as stable. 3. Check the canonical ACP Registry with - `scripts/check_acp_registry.py <agent-id>`. + the managed ACP registry checker. 4. If the agent is missing, use its official local executable only when the client supports custom agents. Keep registry absence distinct from missing ACP support. diff --git a/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx b/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx new file mode 100644 index 000000000..eb1f03429 --- /dev/null +++ b/skills/operate-acp-agent-integration/scripts/check-acp-registry.fsx @@ -0,0 +1,41 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Net.Http +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 |> Array.toList +let optionValue name = args |> List.tryFindIndex ((=) name) |> Option.bind (fun index -> args |> List.tryItem(index + 1)) +let positional = args |> List.filter (fun value -> not (value.StartsWith("--")) && Some value <> optionValue "--registry-url" && Some value <> optionValue "--format") +let query = positional |> List.tryHead |> Option.defaultWith (fun () -> failwith "Pass an exact ACP agent id or display name.") +let url = optionValue "--registry-url" |> Option.defaultValue "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json" +let format = optionValue "--format" |> Option.defaultValue "text" + +let client = new HttpClient(Timeout = TimeSpan.FromSeconds(15.0)) +client.DefaultRequestHeaders.UserAgent.ParseAdd("socket-acp-registry-check/1") +let content = client.GetStringAsync(url).GetAwaiter().GetResult() +let document = JsonDocument.Parse(content) +let root = document.RootElement +let mutable agents = Unchecked.defaultof<JsonElement> +if not (root.TryGetProperty("agents", &agents)) || agents.ValueKind <> JsonValueKind.Array then + failwith $"The ACP registry response from {url} does not contain an agents array." +let matches = + agents.EnumerateArray() + |> Seq.filter (fun (agent: JsonElement) -> + let exact (property: string) = + let mutable value = Unchecked.defaultof<JsonElement> + agent.TryGetProperty(property, &value) && value.ValueKind = JsonValueKind.String && String.Equals(value.GetString(), query, StringComparison.OrdinalIgnoreCase) + exact "id" || exact "name") + |> Seq.toArray +let version = let mutable value = Unchecked.defaultof<JsonElement> in if root.TryGetProperty("version", &value) then value.ToString() else "" +if format = "json" then + let serializedMatches = matches |> Array.map (fun item -> JsonSerializer.Deserialize<JsonElement>(item.GetRawText())) + let payload = {| query = query; registry_url = url; registry_version = version; present = not (Array.isEmpty matches); matches = serializedMatches |} + printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +elif Array.isEmpty matches then + printfn "ACP Registry does not currently contain an exact id or name match for '%s'." query +else + for agent in matches do + let field (name: string) fallback = let mutable value = Unchecked.defaultof<JsonElement> in if agent.TryGetProperty(name, &value) then value.ToString() else fallback + printfn "ACP Registry contains %s (%s) at version %s." (field "name" "(unnamed)") (field "id" "(no id)") (field "version" "(unknown)") +if Array.isEmpty matches then exit 1 diff --git a/skills/operate-acp-agent-integration/scripts/check_acp_registry.py b/skills/operate-acp-agent-integration/scripts/check_acp_registry.py deleted file mode 100755 index e56056e58..000000000 --- a/skills/operate-acp-agent-integration/scripts/check_acp_registry.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -"""Check the canonical ACP Registry for an exact agent identifier or name.""" - -from __future__ import annotations - -import argparse -import json -import sys -from typing import Any -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - - -DEFAULT_REGISTRY = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json" - - -def load_registry(url: str) -> dict[str, Any]: - request = Request(url, headers={"User-Agent": "socket-acp-registry-check/1"}) - try: - with urlopen(request, timeout=15) as response: # noqa: S310 - caller controls reviewed registry URL - payload = json.load(response) - except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error: - raise RuntimeError( - f"The ACP registry could not be read from {url}: {error}. " - "Check network access and the registry URL before changing client configuration." - ) from error - if not isinstance(payload, dict) or not isinstance(payload.get("agents"), list): - raise RuntimeError( - f"The ACP registry response from {url} does not contain an agents array. " - "The registry schema may have changed or the URL may not be canonical." - ) - return payload - - -def find_agents(payload: dict[str, Any], query: str) -> list[dict[str, Any]]: - folded = query.casefold() - matches: list[dict[str, Any]] = [] - for value in payload["agents"]: - if not isinstance(value, dict): - continue - identifier = value.get("id") - name = value.get("name") - if (isinstance(identifier, str) and identifier.casefold() == folded) or ( - isinstance(name, str) and name.casefold() == folded - ): - matches.append(value) - return matches - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Check whether an agent is currently published in the canonical ACP Registry." - ) - parser.add_argument("query", help="Exact ACP agent id or display name") - parser.add_argument("--registry-url", default=DEFAULT_REGISTRY) - parser.add_argument("--format", choices=("text", "json"), default="text") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - try: - payload = load_registry(args.registry_url) - matches = find_agents(payload, args.query) - except RuntimeError as error: - print(str(error), file=sys.stderr) - return 2 - - result = { - "query": args.query, - "registry_url": args.registry_url, - "registry_version": payload.get("version"), - "present": bool(matches), - "matches": matches, - } - if args.format == "json": - print(json.dumps(result, indent=2, sort_keys=True)) - elif matches: - for match in matches: - print( - f"ACP Registry contains {match.get('name', '(unnamed)')} " - f"({match.get('id', '(no id)')}) at version {match.get('version', '(unknown)')}." - ) - else: - print( - f"ACP Registry does not currently contain an exact id or name match for {args.query!r}. " - "Use an official custom launch command only if the client supports one." - ) - return 0 if matches else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/python-testing-workflow/SKILL.md b/skills/python-testing-workflow/SKILL.md index 9b0857470..69b1949d6 100644 --- a/skills/python-testing-workflow/SKILL.md +++ b/skills/python-testing-workflow/SKILL.md @@ -2,7 +2,7 @@ name: python-testing-workflow description: Set up, run, and improve Python tests in uv projects and workspaces. Use for pytest configuration, focused and package-targeted runs, fixtures, parametrization, async and integration tests, coverage, CI parity, or failure triage. license: Apache-2.0 -compatibility: Designed for Codex and compatible Agent Skills clients on macOS with uv-managed Python projects, pytest, and shell access for the bundled setup and execution scripts. +compatibility: Designed for Codex and compatible Agent Skills clients with uv-managed Python projects and pytest. metadata: owner: gaelic-ghost repo: python-skills @@ -16,8 +16,7 @@ allowed-tools: Bash(uv:*) Read Make Python tests describe behavior, run through `uv`, and give a focused failure signal. Preserve the repository's existing test framework and markers; -use the bundled scripts only for pytest setup or repeatable package-targeted -runs. +use the repository's own checked-in commands for setup and execution. ## Workflow @@ -47,22 +46,6 @@ runs. validation commands. Add coverage only when the user or repository has a concrete coverage threshold or reporting need. -## Setup And Execution Scripts - -For a new pytest setup or a repeatable workspace command, use the existing -scripts: - -```bash -scripts/bootstrap_pytest_uv.sh --workspace-root <repo> -scripts/bootstrap_pytest_uv.sh --workspace-root <repo> --package <member-name> -scripts/run_pytest_uv.sh --workspace-root <repo> --package <member-name> -scripts/run_pytest_uv.sh --workspace-root <repo> --path tests/integration -- -m integration -``` - -Use `--with-cov` only when the requested test contract needs `pytest-cov`. -Profiles use the `python-testing-workflow` name and should remain optional; -ordinary repositories should work from their checked-in `pyproject.toml` alone. - ## FastAPI And FastMCP Boundaries For FastAPI, override external dependencies with @@ -117,8 +100,6 @@ Return: - `references/pytest-workflow.md` - `references/uv-workspace-testing.md` -- `references/customization.md` -- `references/interactive-customization.md` - [pytest documentation](https://docs.pytest.org/en/stable/) - [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/) - [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) diff --git a/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml b/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml deleted file mode 100644 index 318287547..000000000 --- a/skills/python-testing-workflow/assets/profiles/bootstrap_pytest_uv.config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# python-testing-workflow bootstrap config profile -# Use with: scripts/bootstrap_pytest_uv.sh --config <this-file> - -workspace_root: "." -package: "" -with_cov: false -dry_run: false diff --git a/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml b/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml deleted file mode 100644 index 8d57f2f01..000000000 --- a/skills/python-testing-workflow/assets/profiles/run_pytest_uv.config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# python-testing-workflow run config profile -# Use with: scripts/run_pytest_uv.sh --config <this-file> - -workspace_root: "." -package: "" -path: "" diff --git a/skills/python-testing-workflow/references/customization.md b/skills/python-testing-workflow/references/customization.md deleted file mode 100644 index 44fe163dc..000000000 --- a/skills/python-testing-workflow/references/customization.md +++ /dev/null @@ -1,18 +0,0 @@ -# Customization Guide - -Use this reference when you need to change the defaults shipped by `python-testing-workflow`. - -## High-Impact Knobs - -- baseline `tool.pytest.ini_options` content -- coverage behavior and optional dependency installation -- package-targeted run expectations for workspaces -- test path and marker conventions -- CI-oriented command examples - -## Audit Checklist After Changes - -- dry-run and real bootstrap flows still match the docs -- root-project and `--package` command examples still work -- the troubleshooting order still reflects the real intended workflow -- repo-level validation still passes after doc and metadata updates diff --git a/skills/python-testing-workflow/references/interactive-customization.md b/skills/python-testing-workflow/references/interactive-customization.md deleted file mode 100644 index 4d5c2c23e..000000000 --- a/skills/python-testing-workflow/references/interactive-customization.md +++ /dev/null @@ -1,44 +0,0 @@ -# Interactive Customization - -## Checklist - -1. Choose script mode: -- bootstrap script: `bootstrap_pytest_uv.sh` -- run script: `run_pytest_uv.sh` -2. Gather `workspace_root` and optional `package`. -3. For bootstrap mode, confirm `with_cov` and `dry_run`. -4. For run mode, gather optional `path` and optional pytest passthrough args. -5. Return both YAML profile and exact command. - -## Schema - -Bootstrap script keys: -- `workspace_root` (string, default current directory) -- `package` (string, optional) -- `with_cov` (bool, default `false`) -- `dry_run` (bool, default `false`) - -Run script keys: -- `workspace_root` (string, default current directory) -- `package` (string, optional) -- `path` (string, optional) - -## Source Precedence - -1. CLI flags -2. `--config` file -3. Repo profile: `.codex/profiles/python-testing-workflow/customization.yaml` -4. Global profile: `~/.config/gaelic-ghost/python-skills/python-testing-workflow/customization.yaml` -5. Script defaults - -## Reset and Cleanup - -- `--bypassing-all-profiles`: ignore global and repo profile for this run. -- `--bypassing-repo-profile`: ignore only repo profile for this run. -- `--deleting-repo-profile`: delete repo profile before running. - -## Troubleshooting - -- Unknown key in YAML: script exits with an error naming the key. -- Missing explicit config file with `--config`: script exits with an error. -- Ensure `--` is used for pytest passthrough args in run mode. diff --git a/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh b/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh deleted file mode 100755 index 11abfb633..000000000 --- a/skills/python-testing-workflow/scripts/bootstrap_pytest_uv.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -WORKSPACE_ROOT="$(pwd)" -PACKAGE_NAME="" -WITH_COV=0 -DRY_RUN=0 - -SKILL_NAME="python-testing-workflow" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -usage() { - cat <<'USAGE' -Usage: bootstrap_pytest_uv.sh [--workspace-root PATH] [--package NAME] [--with-cov] [--dry-run] [--config PATH] - -Options: - --workspace-root PATH Repository root containing pyproject.toml (default: cwd) - --package NAME Workspace member package name for package-scoped install - --with-cov Also install pytest-cov and add coverage defaults when creating config - --dry-run Print planned commands and file changes without mutating files - --config PATH Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - -h, --help Show this help -USAGE -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: required command not found: $1" >&2 - exit 1 - fi -} - -fail() { - echo "error: $*" >&2 - exit 1 -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -bool_to_int() { - local value - value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" - case "$value" in - 1|true|yes|on) printf '1\n' ;; - 0|false|no|off) printf '0\n' ;; - *) fail "invalid boolean value '$1'" ;; - esac -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - workspace_root) WORKSPACE_ROOT="$value" ;; - package) PACKAGE_NAME="$value" ;; - with_cov) WITH_COV="$(bool_to_int "$value")" ;; - dry_run) DRY_RUN="$(bool_to_int "$value")" ;; - *) fail "unknown config key '$key'" ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "invalid config line at $path:$lineno" - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "empty config key at $path:$lineno" - apply_config_value "$key" "$value" - done < "$path" -} - -run_cmd() { - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "[dry-run] $*" - else - "$@" - fi -} - -append_pytest_config_if_missing() { - local pyproject_path="$1" - local addopts_value="-ra" - - if [[ "$WITH_COV" -eq 1 ]]; then - addopts_value="-ra --cov --cov-report=term-missing" - fi - - if rg -n "^\[tool\.pytest\.ini_options\]" "$pyproject_path" >/dev/null 2>&1; then - echo "info: [tool.pytest.ini_options] already exists in $pyproject_path; leaving config unchanged" - return 0 - fi - - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "[dry-run] append baseline [tool.pytest.ini_options] to $pyproject_path" - return 0 - fi - - cat >>"$pyproject_path" <<EOF_CFG - -[tool.pytest.ini_options] -addopts = "$addopts_value" -testpaths = ["tests"] -python_files = ["test_*.py", "*_test.py"] -EOF_CFG - - echo "info: added [tool.pytest.ini_options] to $pyproject_path" -} - -ORIGINAL_ARGS=("$@") - -while [[ $# -gt 0 ]]; do - case "$1" in - --config) - CONFIG_PATH="${2:-}" - [[ -n "$CONFIG_PATH" ]] || fail "--config requires a value" - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - --workspace-root|--package) - [[ $# -ge 2 ]] || fail "$1 requires a value" - shift 2 - ;; - --with-cov|--dry-run) - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -set -- "${ORIGINAL_ARGS[@]}" -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace-root) - WORKSPACE_ROOT="$2" - shift 2 - ;; - --package) - PACKAGE_NAME="$2" - shift 2 - ;; - --with-cov) - WITH_COV=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --config) - shift 2 - ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -require_cmd uv -require_cmd rg - -if [[ ! -d "$WORKSPACE_ROOT" ]]; then - echo "error: workspace root does not exist: $WORKSPACE_ROOT" >&2 - exit 1 -fi - -PYPROJECT_PATH="$WORKSPACE_ROOT/pyproject.toml" -if [[ ! -f "$PYPROJECT_PATH" ]]; then - echo "error: missing pyproject.toml at $PYPROJECT_PATH" >&2 - exit 1 -fi - -cd "$WORKSPACE_ROOT" - -typeset -a deps -if [[ "$WITH_COV" -eq 1 ]]; then - deps=(pytest pytest-cov) -else - deps=(pytest) -fi - -if [[ -n "$PACKAGE_NAME" ]]; then - run_cmd uv add --package "$PACKAGE_NAME" --dev "${deps[@]}" -else - run_cmd uv add --dev "${deps[@]}" -fi - -append_pytest_config_if_missing "$PYPROJECT_PATH" - -echo "info: bootstrap complete" diff --git a/skills/python-testing-workflow/scripts/run_pytest_uv.sh b/skills/python-testing-workflow/scripts/run_pytest_uv.sh deleted file mode 100755 index 255e247db..000000000 --- a/skills/python-testing-workflow/scripts/run_pytest_uv.sh +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env zsh -emulate -L zsh -set -euo pipefail - -WORKSPACE_ROOT="$(pwd)" -PACKAGE_NAME="" -TEST_PATH="" - -SKILL_NAME="python-testing-workflow" -SCRIPT_DIR="${0:A:h}" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -GLOBAL_PROFILE="$HOME/.config/gaelic-ghost/python-skills/$SKILL_NAME/customization.yaml" -REPO_PROFILE="$REPO_ROOT/.codex/profiles/$SKILL_NAME/customization.yaml" - -CONFIG_PATH="" -BYPASS_ALL_PROFILES=0 -BYPASS_REPO_PROFILE=0 -DELETE_REPO_PROFILE=0 - -usage() { - cat <<'USAGE' -Usage: run_pytest_uv.sh [--workspace-root PATH] [--package NAME] [--path TEST_PATH] [--config PATH] [-- <pytest args>] - -Options: - --workspace-root PATH Repository root containing pyproject.toml (default: cwd) - --package NAME Workspace member package name for package-scoped run - --path TEST_PATH Optional test path selector (e.g., tests/unit) - --config PATH Explicit YAML config path - --bypassing-all-profiles Ignore global and repo profile files for this run - --bypassing-repo-profile Ignore repo-local profile file for this run - --deleting-repo-profile Delete repo-local profile file before execution - -- Pass remaining args directly to pytest - -h, --help Show this help -USAGE -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "error: required command not found: $1" >&2 - exit 1 - fi -} - -fail() { - echo "error: $*" >&2 - exit 1 -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -strip_quotes() { - local value="$1" - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - printf '%s' "$value" -} - -apply_config_value() { - local key="$1" - local value="$2" - - case "$key" in - workspace_root) WORKSPACE_ROOT="$value" ;; - package) PACKAGE_NAME="$value" ;; - path) TEST_PATH="$value" ;; - *) fail "unknown config key '$key'" ;; - esac -} - -load_config_file() { - local path="$1" - local required="$2" - - if [[ ! -f "$path" ]]; then - [[ "$required" -eq 1 ]] && fail "config file not found: $path" - return 0 - fi - - local line - local lineno=0 - while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="$(trim "$line")" - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" == *:* ]] || fail "invalid config line at $path:$lineno" - - local key="${line%%:*}" - local value="${line#*:}" - key="$(trim "$key")" - value="${value%%#*}" - value="$(trim "$value")" - value="$(strip_quotes "$value")" - - [[ -n "$key" ]] || fail "empty config key at $path:$lineno" - apply_config_value "$key" "$value" - done < "$path" -} - -ORIGINAL_ARGS=("$@") - -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace-root|--package|--path|--config) - [[ $# -ge 2 ]] || fail "$1 requires a value" - if [[ "$1" == "--config" ]]; then - CONFIG_PATH="$2" - fi - shift 2 - ;; - --bypassing-all-profiles) - BYPASS_ALL_PROFILES=1 - shift - ;; - --bypassing-repo-profile) - BYPASS_REPO_PROFILE=1 - shift - ;; - --deleting-repo-profile) - DELETE_REPO_PROFILE=1 - shift - ;; - --) - break - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ "$DELETE_REPO_PROFILE" -eq 1 ]]; then - rm -f "$REPO_PROFILE" -fi - -if [[ "$BYPASS_ALL_PROFILES" -eq 0 ]]; then - load_config_file "$GLOBAL_PROFILE" 0 - if [[ "$BYPASS_REPO_PROFILE" -eq 0 ]]; then - load_config_file "$REPO_PROFILE" 0 - fi -fi - -if [[ -n "$CONFIG_PATH" ]]; then - load_config_file "$CONFIG_PATH" 1 -fi - -EXTRA_ARGS=() -set -- "${ORIGINAL_ARGS[@]}" -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace-root) - WORKSPACE_ROOT="$2" - shift 2 - ;; - --package) - PACKAGE_NAME="$2" - shift 2 - ;; - --path) - TEST_PATH="$2" - shift 2 - ;; - --config) - shift 2 - ;; - --bypassing-all-profiles|--bypassing-repo-profile|--deleting-repo-profile) - shift - ;; - --) - shift - EXTRA_ARGS=("$@") - break - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -require_cmd uv - -if [[ ! -d "$WORKSPACE_ROOT" ]]; then - echo "error: workspace root does not exist: $WORKSPACE_ROOT" >&2 - exit 1 -fi - -if [[ ! -f "$WORKSPACE_ROOT/pyproject.toml" ]]; then - echo "error: missing pyproject.toml at $WORKSPACE_ROOT/pyproject.toml" >&2 - exit 1 -fi - -cd "$WORKSPACE_ROOT" - -CMD=(uv run) -if [[ -n "$PACKAGE_NAME" ]]; then - CMD+=(--package "$PACKAGE_NAME") -fi -CMD+=(pytest) - -if [[ -n "$TEST_PATH" ]]; then - CMD+=("$TEST_PATH") -fi - -if [[ "${#EXTRA_ARGS[@]}" -gt 0 ]]; then - CMD+=("${EXTRA_ARGS[@]}") -fi - -echo "info: running: ${CMD[*]}" -"${CMD[@]}" diff --git a/skills/safari-mcp-workflow/SKILL.md b/skills/safari-mcp-workflow/SKILL.md index e7a409676..1463bf987 100644 --- a/skills/safari-mcp-workflow/SKILL.md +++ b/skills/safari-mcp-workflow/SKILL.md @@ -64,17 +64,12 @@ Use Safari MCP for evidence from a live Safari Technology Preview tab. It owns b - Recommend `apple-ui-accessibility-workflow` for Apple native UI accessibility work. - Recommend `explore-apple-swift-docs` for current Apple or WebKit documentation. -## Customization - -Use `references/customization-flow.md`. The first version has no runtime-enforced settings: origin, interaction, and privacy boundaries must be chosen for each live session. - ## References ### Workflow References - `references/setup-and-privacy.md` - `references/evidence-and-validation.md` -- `references/customization-flow.md` ### Authoritative Sources @@ -82,5 +77,3 @@ Use `references/customization-flow.md`. The first version has no runtime-enforce - [Safari Technology Preview](https://developer.apple.com/safari/technology-preview/) ### Script Inventory - -- `scripts/customization_config.py` diff --git a/skills/safari-mcp-workflow/references/customization-flow.md b/skills/safari-mcp-workflow/references/customization-flow.md deleted file mode 100644 index 1abfb2633..000000000 --- a/skills/safari-mcp-workflow/references/customization-flow.md +++ /dev/null @@ -1,20 +0,0 @@ -# Safari MCP Workflow Customization Contract - -## Purpose - -Preserve the repo-wide customization-file contract without making origin, interaction, or privacy decisions persistent defaults. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` maintains the common configuration shape. -- The workflow ignores persisted settings because each browser session requires its own approved target and interaction boundary. - -## Update Flow - -1. Inspect current settings with `scripts/customization_config.py effective`. -2. Add a setting only after its stable behavior and safety boundary are documented. -3. Validate YAML before persisting it. diff --git a/skills/safari-mcp-workflow/references/customization.template.yaml b/skills/safari-mcp-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/safari-mcp-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/safari-mcp-workflow/scripts/customization_config.py b/skills/safari-mcp-workflow/scripts/customization_config.py deleted file mode 100755 index c03565916..000000000 --- a/skills/safari-mcp-workflow/scripts/customization_config.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Load and persist per-skill Safari MCP customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "safari-mcp-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value: object) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - return quote_string("" if value is None else str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as error: - fail(f"Invalid YAML in {path}: {error}") - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not allow_partial and set(config) != ALLOWED_TOP_LEVEL: - fail("Missing required customization keys: schemaVersion, isCustomized, settings") - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", str(key)): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - for key in ("schemaVersion", "isCustomized"): - if key in overlay: - merged[key] = overlay[key] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {config['schemaVersion']}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - lines.extend(f" {key}: {encode_scalar(value)}" for key, value in sorted(config["settings"].items())) - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def durable_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - config = parse_yaml(template_path()) - validate_config(config, allow_partial=False) - return config - - -def load_durable() -> dict: - return parse_yaml(durable_path()) if durable_path().exists() else {} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage Safari MCP workflow customization.") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("path") - commands.add_parser("effective") - apply = commands.add_parser("apply") - apply.add_argument("--input", required=True) - commands.add_parser("reset") - args = parser.parse_args() - if args.command == "path": - print(durable_path()) - return - if args.command == "effective": - print(dump_yaml(merge_configs(load_template(), load_durable())), end="") - return - if args.command == "reset": - durable_path().unlink(missing_ok=True) - print(durable_path()) - return - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - updated = merge_configs(merge_configs(load_template(), load_durable()), incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - durable_path().parent.mkdir(parents=True, exist_ok=True) - durable_path().write_text(dump_yaml(updated), encoding="utf-8") - print(durable_path()) - - -if __name__ == "__main__": - main() diff --git a/skills/swift-package-extension-workflow/SKILL.md b/skills/swift-package-extension-workflow/SKILL.md index dc54dabd1..4356b69c0 100644 --- a/skills/swift-package-extension-workflow/SKILL.md +++ b/skills/swift-package-extension-workflow/SKILL.md @@ -32,7 +32,7 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or - Do not assume the two toolchains expose identical SwiftPM commands, flags, manifest APIs, macro support, or plugin behavior. 3. Read the relevant official SwiftPM, Swift Evolution, or Apple/Xcode documentation and state the behavior relied on before editing. 4. Classify the primary extension concern as `build-tool-plugin`, `command-plugin`, `macro`, `traits`, or `generated-source`. -5. Run `scripts/run_workflow.py` for nearest-package resolution and a non-mutating command plan. +5. Run the managed FSX planner through the owning repository recipe for nearest-package resolution and a non-mutating command plan. 6. Load only the reference needed for the selected concern: - plugins: `references/package-plugins-build-command-and-xcode.md` - permissions: `references/plugin-permissions-sandbox-and-outputs.md` @@ -77,10 +77,8 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or - Hand Xcode-managed builds to `xcode-build-run-workflow` and Xcode-native tests to `xcode-testing-workflow` with the exact package, plugin, macro, trait, scheme, and destination context. - Use `format-swift-sources` for formatter-specific behavior without duplicating the general plugin permission model. -## Customization +## Fixed Policy -- Use `references/customization.template.yaml` and `references/customization-flow.md`. -- `scripts/customization_config.py` stores and reports customization state. - The workflow currently keeps fixed package-first and least-permission defaults. ## References @@ -96,9 +94,6 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or ### Contract References -- `references/customization.template.yaml` -- `references/customization-flow.md` - ### Support References - Recommend `references/snippets/apple-swift-package-core.md` when reusable package policy is needed in an end-user repo. @@ -106,5 +101,4 @@ Own SwiftPM extension work that does not belong in ordinary package build/run or ### Script Inventory -- `scripts/run_workflow.py` -- `scripts/customization_config.py` +- `scripts/run-workflow.fsx` diff --git a/skills/swift-package-extension-workflow/references/customization-flow.md b/skills/swift-package-extension-workflow/references/customization-flow.md deleted file mode 100644 index 9428917f4..000000000 --- a/skills/swift-package-extension-workflow/references/customization-flow.md +++ /dev/null @@ -1,22 +0,0 @@ -# Swift Package Extension Workflow Customization Contract - -## Purpose - -Keep package-first, dual-toolchain, least-permission defaults explicit. - -## Knobs - -This skill does not expose ordinary user-facing customization knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` reads, writes, resets, and reports customization state. -- `scripts/run_workflow.py` loads the state but keeps fixed routing and command-planning policy. -- Commands remain agent-executed; the runtime script does not mutate packages or invoke plugins. - -## Update Flow - -1. Inspect settings with `scripts/customization_config.py effective`. -2. Update the skill and affected references together. -3. Preserve the empty template until a real stable knob exists. -4. Re-run the runtime dry runs and targeted tests. diff --git a/skills/swift-package-extension-workflow/references/customization.template.yaml b/skills/swift-package-extension-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/swift-package-extension-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/swift-package-extension-workflow/scripts/customization_config.py b/skills/swift-package-extension-workflow/scripts/customization_config.py deleted file mode 100755 index a834969ad..000000000 --- a/skills/swift-package-extension-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "swift-package-extension-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/skills/swift-package-extension-workflow/scripts/run-workflow.fsx b/skills/swift-package-extension-workflow/scripts/run-workflow.fsx new file mode 100644 index 000000000..a87cc9edd --- /dev/null +++ b/skills/swift-package-extension-workflow/scripts/run-workflow.fsx @@ -0,0 +1,49 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let args = fsi.CommandLineArgs |> Array.skip 1 |> Array.toList +let value name = args |> List.tryFindIndex ((=) name) |> Option.bind (fun index -> args |> List.tryItem (index + 1)) +let normalize (text: string) = String.Join(" ", text.Trim().ToLowerInvariant().Split([| ' '; '\t'; '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries)) +let infer request = + let text = normalize request + if text.Contains("macro") || text.Contains("expansion") then Some "macro" + elif text.Contains("trait") || text.Contains("feature flag") then Some "traits" + elif text.Contains("command plugin") || text.Contains("plugin command") || text.Contains("format plugin") then Some "command-plugin" + elif text.Contains("generated") || text.Contains("codegen") || text.Contains("code generation") then Some "generated-source" + elif text.Contains("build tool plugin") || text.Contains("build plugin") || text.Contains("plugin") then Some "build-tool-plugin" + else None +let requested = value "--repo-root" |> Option.defaultValue "." |> Path.GetFullPath +let candidate = if Directory.Exists(requested) then DirectoryInfo(requested) else FileInfo(requested).Directory +let packageRoot = + Seq.unfold (fun (directory: DirectoryInfo) -> if isNull directory then None else Some(directory, directory.Parent)) candidate + |> Seq.tryFind (fun directory -> File.Exists(Path.Combine(directory.FullName, "Package.swift"))) + |> Option.map (fun directory -> directory.FullName) + |> Option.orElseWith (fun () -> + if Directory.Exists(requested) then Directory.GetFiles(requested, "Package.swift", SearchOption.AllDirectories) |> Array.sort |> Array.tryHead |> Option.map Path.GetDirectoryName + else None) +let extensionType = value "--extension-type" |> Option.orElseWith (fun () -> value "--request" |> Option.bind infer) +let scope = value "--toolchain-scope" |> Option.defaultValue "both" +let identity = [ if scope = "swiftly" || scope = "both" then yield! [ "swiftly use --print-location"; "swift --version" ]; if scope = "xcode" || scope = "both" then yield! [ "xcode-select -p"; "xcrun --find swift"; "xcrun swift --version" ] ] +let baseCommands kind = + match kind with + | "build-tool-plugin" -> [ "swift package plugin --list"; "swift package init --type build-tool-plugin" ] + | "command-plugin" -> [ "swift package plugin --list"; "swift package plugin --help"; "swift package init --type command-plugin" ] + | "macro" -> [ "swift package init --type macro"; "swift build"; "swift test" ] + | "traits" -> [ "swift package show-traits --format json"; "swift build"; "swift test"; "swift build --disable-default-traits"; "swift test --disable-default-traits"; "swift build --enable-all-traits"; "swift test --enable-all-traits" ] + | _ -> [ "swift package dump-package"; "swift build"; "swift build -v" ] +let planned = extensionType |> Option.map (fun kind -> let commands = baseCommands kind in identity @ [ if scope = "swiftly" || scope = "both" then yield! commands; if scope = "xcode" || scope = "both" then yield! commands |> List.map (fun command -> "xcrun " + command) ]) |> Option.defaultValue [] +let status, next = + if extensionType.IsNone then "blocked", "Pass --extension-type or a request identifying plugin, macro, trait, or generated-source work." + elif not (Directory.Exists(requested) || File.Exists(requested)) then "blocked", "Resolve the requested repository path before continuing." + elif packageRoot.IsNone then "blocked", "Use a Swift package repository containing Package.swift." + else "success", "Proceed with the package-first extension plan." +let source = if value "--extension-type" |> Option.isSome then "explicit" elif extensionType.IsSome then "inferred" else "missing" +let context = {| requested_root = requested; package_root = packageRoot; exists = Directory.Exists(requested) || File.Exists(requested); has_package = packageRoot.IsSome |} +let support = {| minimum = "6.2"; policy = "latest stable minor plus previous stable minor" |} +let output = {| extension_type = extensionType; extension_type_source = source; package_context = context; toolchain_scope = scope; planned_commands = planned; support_window = support; next_step = next |} +let payload = {| status = status; path_type = "primary"; output = output |} +printfn "%s" (JsonSerializer.Serialize(payload, JsonSerializerOptions(WriteIndented = true))) +if status = "blocked" then exit 1 diff --git a/skills/swift-package-extension-workflow/scripts/run_workflow.py b/skills/swift-package-extension-workflow/scripts/run_workflow.py deleted file mode 100755 index 30f23f153..000000000 --- a/skills/swift-package-extension-workflow/scripts/run_workflow.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Plan SwiftPM plugin, macro, trait, and generated-source work.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - -import customization_config - -EXTENSION_TYPES = { - "build-tool-plugin", - "command-plugin", - "macro", - "traits", - "generated-source", -} -TOOLCHAIN_SCOPES = {"swiftly", "xcode", "both"} - - -def normalize(text: str | None) -> str: - return " ".join((text or "").strip().lower().split()) - - -def infer_extension_type(request: str | None) -> str | None: - text = normalize(request) - if not text: - return None - if "macro" in text or "expansion" in text: - return "macro" - if "trait" in text or "feature flag" in text: - return "traits" - if "command plugin" in text or "plugin command" in text or "format plugin" in text: - return "command-plugin" - if "generated" in text or "codegen" in text or "code generation" in text: - return "generated-source" - if "build tool plugin" in text or "build plugin" in text or "plugin" in text: - return "build-tool-plugin" - return None - - -def resolve_package_root(repo_root: str | None) -> tuple[Path, Path | None]: - requested = Path(repo_root or ".").expanduser().resolve() - candidate = requested if requested.is_dir() else requested.parent - for current in (candidate, *candidate.parents): - if (current / "Package.swift").exists(): - return requested, current - if requested.exists() and requested.is_dir(): - manifests = sorted(requested.rglob("Package.swift"), key=lambda path: (len(path.parts), str(path))) - if manifests: - return requested, manifests[0].parent - return requested, None - - -def package_context(repo_root: str | None) -> dict: - requested, package_root = resolve_package_root(repo_root) - scan_root = package_root or requested - plugin_sources = [] - if scan_root.exists(): - plugin_dir = scan_root / "Plugins" - if plugin_dir.exists(): - plugin_sources = sorted(str(path) for path in plugin_dir.rglob("*.swift")) - return { - "requested_root": str(requested), - "package_root": str(scan_root) if package_root is not None else None, - "exists": requested.exists(), - "has_package": package_root is not None, - "plugin_sources": plugin_sources, - } - - -def identity_commands(scope: str) -> list[str]: - commands: list[str] = [] - if scope in {"swiftly", "both"}: - commands.extend(["swiftly use --print-location", "swift --version"]) - if scope in {"xcode", "both"}: - commands.extend(["xcode-select -p", "xcrun --find swift", "xcrun swift --version"]) - return commands - - -def prefixed_commands(scope: str, commands: list[str]) -> list[str]: - planned: list[str] = [] - if scope in {"swiftly", "both"}: - planned.extend(commands) - if scope in {"xcode", "both"}: - planned.extend(f"xcrun {command}" for command in commands) - return planned - - -def extension_commands(extension_type: str, scope: str) -> list[str]: - if extension_type == "build-tool-plugin": - commands = ["swift package plugin --list", "swift package init --type build-tool-plugin"] - elif extension_type == "command-plugin": - commands = ["swift package plugin --list", "swift package plugin --help", "swift package init --type command-plugin"] - elif extension_type == "macro": - commands = ["swift package init --type macro", "swift build", "swift test"] - elif extension_type == "traits": - commands = [ - "swift package show-traits --format json", - "swift build", - "swift test", - "swift build --disable-default-traits", - "swift test --disable-default-traits", - "swift build --enable-all-traits", - "swift test --enable-all-traits", - ] - else: - commands = ["swift package dump-package", "swift build", "swift build -v"] - return identity_commands(scope) + prefixed_commands(scope, commands) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--extension-type", choices=sorted(EXTENSION_TYPES)) - parser.add_argument("--request") - parser.add_argument("--repo-root") - parser.add_argument("--toolchain-scope", choices=sorted(TOOLCHAIN_SCOPES), default="both") - parser.add_argument("--dry-run", action="store_true") - args = parser.parse_args() - customization_config.merge_configs( - customization_config.load_template(), - customization_config.load_durable(), - ) - - extension_type = args.extension_type or infer_extension_type(args.request) - context = package_context(args.repo_root) - status = "success" - next_step = "Proceed with the package-first extension plan." - if extension_type is None: - status = "blocked" - next_step = "Pass --extension-type or provide a request that identifies plugin, macro, trait, or generated-source work." - elif not context["exists"]: - status = "blocked" - next_step = "Resolve the requested repository path before continuing." - elif not context["has_package"]: - status = "blocked" - next_step = "Use a Swift package repository containing Package.swift." - commands = extension_commands(extension_type, args.toolchain_scope) if extension_type else [] - payload = { - "status": status, - "path_type": "primary", - "output": { - "extension_type": extension_type, - "extension_type_source": "explicit" if args.extension_type else "inferred" if extension_type else "missing", - "package_context": context, - "toolchain_scope": args.toolchain_scope, - "planned_commands": commands, - "support_window": {"minimum": "6.2", "policy": "latest stable minor plus previous stable minor"}, - "next_step": next_step, - }, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 1 if status == "blocked" else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/sync-skills-repo-guidance/SKILL.md b/skills/sync-skills-repo-guidance/SKILL.md deleted file mode 100644 index ce6d23e8c..000000000 --- a/skills/sync-skills-repo-guidance/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: sync-skills-repo-guidance -description: Audit Agent Skills or Codex plugin guidance and discovery mirrors. Use for stale policy, missing mirrors, or unclear portable-skill and host-plugin boundaries; defer narrow docs work. -metadata: - hermes: - category: agent-portability - tags: [agent-skills, codex, plugin, guidance] ---- - -# Sync Skills Repo Guidance - -Audit an existing Agent Skills or Codex plugin repository against the current house guidance and upstream standards. - -This is the Codex and shared-skills guidance-sync workflow inside Agent Portability Skills. It should preserve the difference between portable Agent Skills and host-specific packaging such as Codex plugins, Xcode plug-ins, Zed extensions, OpenCode config, Claude Code settings, MCP declarations, hooks, apps, and custom agents. - -## Codex Model Note - -When syncing Codex guidance, state clearly that OpenAI's documented Codex plugin system exposes repo-visible plugins through marketplace catalogs and does not document a richer repo-private scoping model beyond that. - -Before making policy claims about Codex Plugins, Skills, MCP, Hooks, marketplaces, or subagents, refresh the relevant OpenAI Codex docs. Keep this skill's local guidance focused on durable repo policy and remove copied upstream detail when the official docs already cover it clearly. - -## Codex Plugin Root Structure - -When this skill touches Codex packaging guidance, keep the plugin-root structure aligned with the current OpenAI docs: - -- every plugin has a manifest at `.codex-plugin/plugin.json` -- only `plugin.json` belongs in `.codex-plugin/` -- `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` belong at the plugin root -- plugin manifests should point to bundled skill folders with `"skills": "./skills/"` -- plugin manifests may point to bundled lifecycle hooks with `"hooks": "./hooks/hooks.json"`; if hooks live at `./hooks/hooks.json`, Codex checks that default path automatically -- plugin-bundled hooks are non-managed hooks, so installing or enabling a plugin does not make those hooks trusted automatically -- marketplace entries point `source.path` at the plugin root directory, not at `.codex-plugin/` - -## Codex Install Guidance - -Default user-facing install and update guidance to the official Git-backed marketplace commands. Use explicit refs such as `<owner>/<repo>@vX.Y.Z` only for pinned reproducible installs. Use manual local marketplace or copied-payload instructions only for local development, testing unpublished changes, or fallback cases where the Git-backed path is not available. - -Keep marketplace sources, marketplace catalogs, plugin payload directories, installed cache paths, and config-state distinct instead of collapsing them into one vague "plugin install" concept. Do not reproduce the full install-surface map unless the target repo truly needs a maintainer reference; link to the OpenAI docs for the full current details. - -When a workflow depends on a companion skill or plugin, first route through the Codex harness surfaces that are already available in the current session. Name the current-session skill to use, such as `repository-skills:maintain-project-repo`, before giving install advice. If the companion skill is missing from the session, tell the user to add or update the marketplace and install the plugin through Codex's plugin directory for future sessions; do not imply that editing `config.toml`, copying payload folders, or searching an arbitrary checkout is the standard way to make a skill callable from Codex. - -For `socket`, prefer: - -```bash -codex plugin marketplace add gaelic-ghost/socket -codex plugin marketplace upgrade socket -``` - -For standalone plugin repositories that carry their own repo marketplace, prefer the same pattern with that repository, for example: - -```bash -codex plugin marketplace add gaelic-ghost/apple-dev-skills -codex plugin marketplace add gaelic-ghost/SpeakSwiftlyServer -``` - -Do not describe `config.toml` as the place plugins install into. Do not describe a marketplace file as the install destination. Keep the wording explicit: marketplace sources are tracked by Codex, marketplaces are catalogs, plugin roots are payload directories, the cache is Codex's installed copy, and `config.toml` stores enabled-state. - -If you mention project-scoped `.codex/config.toml`, label it as a general Codex config capability from the config reference rather than as part of the documented plugin install-surface map. - -## Dependency Provenance - -When syncing `AGENTS.md`, include strict dependency guidance: - -- shared project dependencies must resolve from GitHub repository URLs, package managers, package registries, or other real remote repositories -- committed dependency declarations, lockfiles, scripts, docs, examples, generated project files, and CI config must not point at machine-local paths -- machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly - -## Codex Subagent Guidance - -When the user explicitly requests subagents, `skills-repo-guidance-sync`, review-packet planning, or asks to keep working while broad skills-repo guidance discovery happens in parallel, use the `skills-repo-guidance-sync` custom-agent role for bounded read-heavy discovery before this skill applies guidance sync. When the target is the Socket superproject itself, include root docs, marketplace metadata, and validation scripts in the bounded audit. - -Good `skills-repo-guidance-sync` jobs for this skill: - -- inspect AGENTS, README, maintainer docs, discovery mirrors, and plugin metadata for drift -- compare plugin, skill, hook, marketplace, and subagent claims against current official Codex docs -- inventory stale install-surface wording, unsupported non-Codex surfaces, and machine-local dependency guidance -- return a review packet with proposed patch set, validation handoff, affected files, and blockers - -Keep apply-mode edits in the main thread. The guidance sync worker may return proposed patch-set entries, but the main agent should review them with the user before saving, editing, or applying any edits. - -When auditing target skills, treat subagent guidance as useful only when it is explicit, bounded, and tied to real parallel support work. Match OpenAI's current Codex wording: - -- use `subagent` and `subagent workflow` rather than vague older `multi-agent` language -- say current Codex releases enable subagent workflows by default, but Codex only spawns subagents when there is an explicit trigger: the user asks for subagents or parallel agent work, or a narrower skill/plugin workflow instructs the agent to ask first and the user grants explicit permission -- mention built-in `default`, `worker`, and `explorer` agents only when agent configuration matters; avoid turning custom `.codex/agents/` setup into default skill boilerplate -- use `gpt-5.6-terra` as the current soft default only for explicitly pinned, bounded read-heavy roles; prefer `gpt-5.6` for harder reasoning or leave the model unpinned when Codex should choose -- prefer subagents for read-heavy discovery, docs pulling, tests, triage, log analysis, and summarization -- ask workers for concise findings, evidence, links, or file references instead of raw intermediate output -- keep write-heavy apply work in the main thread unless the user explicitly requests parallel implementation with disjoint write scopes -- preserve plugin-specific guidance that is stricter about subagent use, such as Codex Security repository-wide scan workflows that ask for subagents because the file-pass review depends on parallel workers - -Flag skill guidance that implies automatic delegation, recommends parallel writes without ownership boundaries, adds subagent advice to narrow single-file or sequential workflows, or suppresses narrower plugin guidance that explicitly calls for subagents. - -## Codex Hooks Guidance - -When auditing target skills or plugin-repo docs that mention OpenAI Codex Hooks, keep hooks conceptually separate from marketplace and install-surface guidance. Hooks are Codex runtime lifecycle scripts; plugins may bundle lifecycle config, but hooks are not themselves a plugin install surface. - -Flag hooks guidance that uses deprecated `features.codex_hooks` wording instead of canonical `features.hooks`, refers to removed or legacy plugin-hook gates such as `features.plugin_hooks`, implies hooks are disabled by default, implies project-local hooks load without a trusted `.codex/` layer, treats `PreToolUse` or `PostToolUse` as complete enforcement for every tool path, omits non-managed hook trust review, or confuses Codex Hooks with git pre-commit hooks or repo-maintenance hook scripts. - -## GitHub Repository Settings - -When the target repository has a GitHub remote, include repository settings in -the sync audit and route the canonical baseline through -`repository-skills:maintain-github-repository`. Report drift in repository -features, merge modes, Dependabot and security settings, private vulnerability -reporting, web commit sign-off, and branch protection. - -Keep this audit read-only unless the user requested settings changes. Do not -infer visibility changes, do not require reviewers a single-maintainer repo -does not have, and do not block a documented maintainer direct-push workflow. diff --git a/skills/sync-skills-repo-guidance/agents/openai.yaml b/skills/sync-skills-repo-guidance/agents/openai.yaml deleted file mode 100644 index 89b846c63..000000000 --- a/skills/sync-skills-repo-guidance/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Sync Skills Repo Guidance" - short_description: "Audit repo-wide guidance and mirror drift for skills repositories." - default_prompt: "Use $sync-skills-repo-guidance to audit ongoing guidance drift in this existing skills repository. Start with the current local audit script for AGENTS, optional README, `.gitignore`, optional maintainer-doc snippets, and discovery mirrors, refresh official OpenAI sources before making policy claims, keep Codex guidance explicit about marketplace-based plugin distribution, and do not overstate the current script's automation scope. When the user asks for subagents, skills-repo-guidance-sync, review-packet planning, or parallel skills-repo guidance discovery, delegate the read-heavy scan to the skills-repo-guidance-sync custom-agent role before the main thread reviews and applies any edits." diff --git a/skills/sync-skills-repo-guidance/references/source-order.md b/skills/sync-skills-repo-guidance/references/source-order.md deleted file mode 100644 index 60f525401..000000000 --- a/skills/sync-skills-repo-guidance/references/source-order.md +++ /dev/null @@ -1,7 +0,0 @@ -# Source Order - -1. root `skills/` -2. skill-local runtime files -3. repo docs: `AGENTS.md`, optional `README.md`, `ROADMAP.md` -4. maintainer docs under `docs/maintainers/` -5. local discovery mirrors: `.agents/skills` diff --git a/skills/sync-skills-repo-guidance/references/sync-checklist.md b/skills/sync-skills-repo-guidance/references/sync-checklist.md deleted file mode 100644 index 16b3d5731..000000000 --- a/skills/sync-skills-repo-guidance/references/sync-checklist.md +++ /dev/null @@ -1,13 +0,0 @@ -# Sync Checklist - -- root `skills/` is canonical -- `.codex-plugin/plugin.json` points at root `skills/` with `"skills": "./skills/"` -- `.agents/skills -> ../skills` -- AGENTS describes the repo as a source-first skills-export repository -- AGENTS keeps the Codex plugin-boundary note explicit -- README is optional; if present, it should stay public-facing and avoid duplicating manifest or AGENTS content -- AGENTS says to refresh current OpenAI Codex docs before changing plugin, skill, MCP, hooks, marketplace, or subagent guidance -- no nested staged plugin-directory guidance remains -- user-facing install and update guidance defaults to Git-backed marketplace sources and official marketplace add/upgrade commands -- no installer or install-validation guidance remains -- maintainer tooling guidance stays explicit diff --git a/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py b/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py deleted file mode 100644 index 519468e82..000000000 --- a/skills/sync-skills-repo-guidance/scripts/sync_skills_repo_guidance.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# /// -from __future__ import annotations - -import argparse -import json -import os -import sys -from dataclasses import asdict, dataclass -from pathlib import Path - - -EXACT_NO_FINDINGS = "No findings." -README_SNIPPETS = [ - "Installable maintainer skills for skills-export and plugin-export repositories.", - "does not document a richer repo-private scoping model", - "codex plugin marketplace add gaelic-ghost/socket", - "codex plugin marketplace upgrade socket", - "`agent-portability-skills` entry points at `./plugins/agent-portability-skills`", - "Git-backed marketplace sources", - "dev dependencies in `pyproject.toml`", - "`pytest`, `ruff`, and `mypy`", - "`\"skills\": \"./skills/\"`", - "Only `plugin.json` belongs in `.codex-plugin/`", - "refresh the official OpenAI docs", -] -AGENTS_SNIPPETS = [ - "canonical authored and exported surface", - 'manifest points to bundled skills with `"skills": "./skills/"`', - "`hooks/`", - "Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories", - "Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly", - "Default user-facing install and update guidance to Git-backed marketplace sources", - "`skills/install-plugin-to-socket`", - "`skills/validate-plugin-install-surfaces`", - "check the current OpenAI Codex docs", -] -AUDIT_SNIPPETS = [ - "This repository ships root `.codex-plugin` packaging and does not track a nested staged plugin directory for itself.", - 'Its plugin manifest must declare `"skills": "./skills/"`', - "user installs normally come through the Git-backed `socket` marketplace", - "This repository does not ship `install-plugin-to-socket`.", - "This repository does not ship `validate-plugin-install-surfaces`.", -] -INSTALL_SURFACES_SNIPPETS = [ - "only `plugin.json` belongs in `.codex-plugin/`", - 'plugin manifests point to bundled skill folders with a root-relative `"skills": "./skills/"` field', - "Tracked marketplace source", - "Preferred User Install And Update Path", - "codex plugin marketplace add gaelic-ghost/socket", - "codex plugin marketplace upgrade socket", - "Documented plugin path: `~/.codex/config.toml`", - "project-scoped `.codex/config.toml`, label it as a general Codex config capability", - "first route through the Codex harness surfaces that are already available in the current session", - "install the plugin through Codex's plugin directory for future sessions", -] -GITIGNORE_SNIPPETS: list[str] = [] - -@dataclass -class Finding: - path: str - issue_id: str - message: str - - -def infer_plugin_name(repo_root: Path, explicit: str | None) -> str: - return explicit or repo_root.name - - -def _check_file_contains(repo_root: Path, path: Path, snippets: list[str], issue_prefix: str) -> list[Finding]: - findings: list[Finding] = [] - if not path.exists(): - findings.append(Finding(str(path.relative_to(repo_root)), "missing-path", "Expected repo guidance file is missing.")) - return findings - text = path.read_text(encoding="utf-8") - for snippet in snippets: - if snippet not in text: - findings.append(Finding(str(path.relative_to(repo_root)), f"{issue_prefix}-missing-snippet", f"Expected to mention: {snippet}")) - return findings - - -def _check_symlink(repo_root: Path, path: Path, target: str) -> list[Finding]: - rel = str(path.relative_to(repo_root)) - if not path.exists() and not path.is_symlink(): - return [Finding(rel, "missing-symlink", f"Expected symlink to {target}.")] - if not path.is_symlink(): - return [Finding(rel, "not-symlink", f"Expected POSIX symlink to {target}.")] - actual = os.readlink(path) - if actual != target: - return [Finding(rel, "wrong-symlink-target", f"Expected {target}, found {actual}.")] - return [] - - -def audit_repo(repo_root: Path, plugin_name: str) -> list[Finding]: - findings: list[Finding] = [] - readme = repo_root / "README.md" - if readme.exists(): - findings.extend(_check_file_contains(repo_root, readme, README_SNIPPETS, "readme")) - findings.extend(_check_file_contains(repo_root, repo_root / "AGENTS.md", AGENTS_SNIPPETS, "agents")) - findings.extend(_check_file_contains(repo_root, repo_root / ".gitignore", GITIGNORE_SNIPPETS, "gitignore")) - reality_audit = repo_root / "docs" / "maintainers" / "reality-audit.md" - if reality_audit.exists(): - findings.extend(_check_file_contains(repo_root, reality_audit, AUDIT_SNIPPETS, "reality-audit")) - install_surfaces = repo_root / "docs" / "maintainers" / "codex-plugin-install-surfaces.md" - if install_surfaces.exists(): - findings.extend(_check_file_contains(repo_root, install_surfaces, INSTALL_SURFACES_SNIPPETS, "install-surfaces")) - findings.extend(_check_symlink(repo_root, repo_root / ".agents" / "skills", "../skills")) - manifest_path = repo_root / ".codex-plugin" / "plugin.json" - if not manifest_path.exists(): - findings.append( - Finding( - ".codex-plugin/plugin.json", - "missing-plugin-manifest", - "Expected source-repo plugin packaging at `.codex-plugin/plugin.json`.", - ) - ) - else: - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - findings.append( - Finding( - ".codex-plugin/plugin.json", - "invalid-plugin-manifest", - f"Expected valid JSON plugin manifest: {exc.msg}.", - ) - ) - else: - if manifest.get("skills") != "./skills/": - findings.append( - Finding( - ".codex-plugin/plugin.json", - "missing-skills-component", - 'Expected plugin manifest to declare bundled skills with `"skills": "./skills/"`.', - ) - ) - if (repo_root / "plugins").exists(): - findings.append(Finding("plugins", "forbidden-path", "Nested staged plugin directories are forbidden for this repo model.")) - return findings - - -def build_report(repo_root: Path, plugin_name: str, run_mode: str, findings: list[Finding], errors: list[str]) -> dict[str, object]: - return {"run_context": {"repo_root": str(repo_root), "plugin_name": plugin_name, "run_mode": run_mode}, "findings": [asdict(item) for item in findings], "errors": errors} - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--repo-root", required=True) - parser.add_argument("--run-mode", choices=("check-only", "apply"), required=True) - parser.add_argument("--plugin-name") - parser.add_argument("--print-md", action="store_true") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - repo_root = Path(args.repo_root).resolve() - if not repo_root.exists() or not repo_root.is_dir(): - print("Repository root does not exist or is not a directory.", file=sys.stderr) - return 1 - plugin_name = infer_plugin_name(repo_root, args.plugin_name) - findings = audit_repo(repo_root, plugin_name) - report = build_report(repo_root, plugin_name, args.run_mode, findings, []) - if args.print_md and not findings: - print(EXACT_NO_FINDINGS) - else: - print(json.dumps(report, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/tvos-app-experience-workflow/SKILL.md b/skills/tvos-app-experience-workflow/SKILL.md index 17a9b8112..95909f90b 100644 --- a/skills/tvos-app-experience-workflow/SKILL.md +++ b/skills/tvos-app-experience-workflow/SKILL.md @@ -138,13 +138,6 @@ only for a demonstrated geometry or lifecycle constraint. - Recommend `explore-apple-swift-docs` when the real need is current Apple documentation rather than a design decision. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the shared customization-file -contract. This first version has no runtime-enforced knobs. - ## References ### Workflow References @@ -152,7 +145,6 @@ contract. This first version has no runtime-enforced knobs. - `references/focus-layout-and-input.md` - `references/platform-beta-and-migration.md` - `references/validation-expectations.md` -- `references/customization-flow.md` ### Support References @@ -162,5 +154,3 @@ contract. This first version has no runtime-enforced knobs. accessibility beyond this skill's tvOS focus and Large Text boundary. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/skills/tvos-app-experience-workflow/references/customization-flow.md b/skills/tvos-app-experience-workflow/references/customization-flow.md deleted file mode 100644 index 6975c1ddc..000000000 --- a/skills/tvos-app-experience-workflow/references/customization-flow.md +++ /dev/null @@ -1,28 +0,0 @@ -# tvOS App Experience Workflow Customization Contract - -## Purpose - -Preserve the repository customization-file contract without inventing persistent -behavior for a documentation-first tvOS decision workflow. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` can inspect, apply, and reset the standard - customization file shape. -- `tvos-app-experience-workflow` ignores persisted settings because focus, - device capability, beta-SDK, and migration decisions require current evidence. -- Add a knob only after its deterministic behavior and documentation are clear. - -## Update Flow - -1. Inspect with `scripts/customization_config.py effective`. -2. Document a real deterministic knob in `SKILL.md` and this file first. -3. Apply a reviewed YAML overlay and rerun `effective`. - -## Validation - -Do not claim a runtime-tunable behavior that the workflow does not implement. diff --git a/skills/tvos-app-experience-workflow/references/customization.template.yaml b/skills/tvos-app-experience-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/tvos-app-experience-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/tvos-app-experience-workflow/scripts/customization_config.py b/skills/tvos-app-experience-workflow/scripts/customization_config.py deleted file mode 100755 index 9033580ca..000000000 --- a/skills/tvos-app-experience-workflow/scripts/customization_config.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Maintain policy-only customization metadata for the tvOS app workflow.""" - -from __future__ import annotations - -import argparse -import os -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "tvos-app-experience-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -REQUIRED_KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def template_path() -> Path: - return Path(__file__).parents[1] / "references" / "customization.template.yaml" - - -def load_yaml(path: Path, *, partial: bool = False) -> dict: - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except FileNotFoundError: - fail(f"Missing YAML file: {path}") - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - unknown = set(value) - REQUIRED_KEYS - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial and set(value) != REQUIRED_KEYS: - fail("Customization file must contain schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - if any(isinstance(item, (dict, list)) for item in value["settings"].values()): - fail("settings values must be scalar") - return value - - -def load_template() -> dict: - return load_yaml(template_path()) - - -def config_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def effective() -> dict: - base = load_template() - path = config_path() - if not path.exists(): - return base - overlay = load_yaml(path, partial=True) - return { - "schemaVersion": overlay.get("schemaVersion", base["schemaVersion"]), - "isCustomized": overlay.get("isCustomized", base["isCustomized"]), - "settings": {**base["settings"], **overlay.get("settings", {})}, - } - - -def emit(value: dict) -> None: - print(yaml.safe_dump(value, sort_keys=False).strip()) - - -def main() -> None: - parser = argparse.ArgumentParser() - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - path = config_path() - if args.command == "effective": - emit(effective()) - elif args.command == "apply": - overlay = load_yaml(Path(args.input), partial=True) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(overlay, sort_keys=False), encoding="utf-8") - print(path) - else: - path.unlink(missing_ok=True) - print(path) - - -if __name__ == "__main__": - main() diff --git a/skills/tvos-media-playback-workflow/SKILL.md b/skills/tvos-media-playback-workflow/SKILL.md index 129c2391b..ce9a6c0d5 100644 --- a/skills/tvos-media-playback-workflow/SKILL.md +++ b/skills/tvos-media-playback-workflow/SKILL.md @@ -124,20 +124,12 @@ session policy, or Xcode execution workflows. - Recommend `explore-apple-swift-docs` when current AVKit or tvOS docs lookup is the real task. -## Customization - -Use `references/customization-flow.md`. - -`scripts/customization_config.py` preserves the shared customization-file -contract. This first version has no runtime-enforced knobs. - ## References ### Workflow References - `references/system-player-and-remote-commands.md` - `references/playback-validation-and-handoffs.md` -- `references/customization-flow.md` ### Support References @@ -147,5 +139,3 @@ contract. This first version has no runtime-enforced knobs. release-note evidence. ### Script Inventory - -- `scripts/customization_config.py` diff --git a/skills/tvos-media-playback-workflow/references/customization-flow.md b/skills/tvos-media-playback-workflow/references/customization-flow.md deleted file mode 100644 index 971f19e33..000000000 --- a/skills/tvos-media-playback-workflow/references/customization-flow.md +++ /dev/null @@ -1,28 +0,0 @@ -# tvOS Media Playback Workflow Customization Contract - -## Purpose - -Preserve the standard customization-file contract without hiding media-command -or runtime decisions behind unverified persisted settings. - -## Knobs - -The first version defines no runtime-enforced knobs. - -## Runtime Behavior - -- `scripts/customization_config.py` supports the standard configuration shape. -- `tvos-media-playback-workflow` ignores persisted settings because player - choice, commands, stream support, and device behavior require live evidence. -- Add a knob only after its deterministic runtime behavior is documented. - -## Update Flow - -1. Inspect with `scripts/customization_config.py effective`. -2. Document a real deterministic knob in `SKILL.md` and this file first. -3. Apply a reviewed YAML overlay and rerun `effective`. - -## Validation - -Do not let customization metadata imply that remote-command behavior has been -validated on hardware. diff --git a/skills/tvos-media-playback-workflow/references/customization.template.yaml b/skills/tvos-media-playback-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/tvos-media-playback-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/tvos-media-playback-workflow/scripts/customization_config.py b/skills/tvos-media-playback-workflow/scripts/customization_config.py deleted file mode 100755 index c41c623e1..000000000 --- a/skills/tvos-media-playback-workflow/scripts/customization_config.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = ["PyYAML>=6.0.2,<7"] -# /// -"""Maintain policy-only customization metadata for the tvOS playback workflow.""" - -from __future__ import annotations - -import argparse -import os -import sys -from pathlib import Path - -import yaml - -SKILL_NAME = "tvos-media-playback-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -REQUIRED_KEYS = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def template_path() -> Path: - return Path(__file__).parents[1] / "references" / "customization.template.yaml" - - -def load_yaml(path: Path, *, partial: bool = False) -> dict: - try: - value = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except FileNotFoundError: - fail(f"Missing YAML file: {path}") - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - if not isinstance(value, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - unknown = set(value) - REQUIRED_KEYS - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - if not partial and set(value) != REQUIRED_KEYS: - fail("Customization file must contain schemaVersion, isCustomized, and settings") - if "schemaVersion" in value and value["schemaVersion"] != 1: - fail("schemaVersion must be 1") - if "isCustomized" in value and not isinstance(value["isCustomized"], bool): - fail("isCustomized must be boolean") - if "settings" in value: - if not isinstance(value["settings"], dict): - fail("settings must be a mapping") - if any(isinstance(item, (dict, list)) for item in value["settings"].values()): - fail("settings values must be scalar") - return value - - -def load_template() -> dict: - return load_yaml(template_path()) - - -def config_path() -> Path: - root = Path(os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT)).expanduser() - return root / SKILL_NAME / "customization.yaml" - - -def effective() -> dict: - base = load_template() - path = config_path() - if not path.exists(): - return base - overlay = load_yaml(path, partial=True) - return { - "schemaVersion": overlay.get("schemaVersion", base["schemaVersion"]), - "isCustomized": overlay.get("isCustomized", base["isCustomized"]), - "settings": {**base["settings"], **overlay.get("settings", {})}, - } - - -def emit(value: dict) -> None: - print(yaml.safe_dump(value, sort_keys=False).strip()) - - -def main() -> None: - parser = argparse.ArgumentParser() - command = parser.add_subparsers(dest="command", required=True) - command.add_parser("effective") - apply = command.add_parser("apply") - apply.add_argument("--input", required=True) - command.add_parser("reset") - args = parser.parse_args() - path = config_path() - if args.command == "effective": - emit(effective()) - elif args.command == "apply": - overlay = load_yaml(Path(args.input), partial=True) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(overlay, sort_keys=False), encoding="utf-8") - print(path) - else: - path.unlink(missing_ok=True) - print(path) - - -if __name__ == "__main__": - main() diff --git a/skills/virtualization-framework-workflow/SKILL.md b/skills/virtualization-framework-workflow/SKILL.md index f8cbb2fa9..ab8ef7cb5 100644 --- a/skills/virtualization-framework-workflow/SKILL.md +++ b/skills/virtualization-framework-workflow/SKILL.md @@ -60,10 +60,6 @@ Implement one explicit macOS or Linux Virtualization framework path without flat - Use `xcode-build-run-workflow` and `xcode-testing-workflow` for execution and tests. - Use `prepare-isolated-analysis-lab` for hostile-workload control policy. -## Customization - -Use [customization-flow.md](references/customization-flow.md). The first release has no runtime-enforced knobs. - ## References - [macOS and Linux guest matrix](references/macos-and-linux-guest-matrix.md) diff --git a/skills/virtualization-framework-workflow/references/customization-flow.md b/skills/virtualization-framework-workflow/references/customization-flow.md deleted file mode 100644 index 54484e3e0..000000000 --- a/skills/virtualization-framework-workflow/references/customization-flow.md +++ /dev/null @@ -1,21 +0,0 @@ -# Customization Flow - -Preserve the repo-wide customization-file contract without pretending this -workflow already has runtime-tunable behavior. - -## Current Behavior - -- `references/customization.template.yaml` is the default persisted shape. -- `scripts/customization_config.py` can show, apply, and reset customization - state for consistency with the rest of Apple Dev Skills. -- The workflow currently ignores persisted settings at runtime because no - runtime-enforced knobs are documented yet. - -## Future Knobs - -Only add runtime behavior after documenting: - -- the exact setting key -- the allowed values -- which recommendation changes when the setting is present -- how tests prove the change is applied diff --git a/skills/virtualization-framework-workflow/references/customization.template.yaml b/skills/virtualization-framework-workflow/references/customization.template.yaml deleted file mode 100644 index cddd82d10..000000000 --- a/skills/virtualization-framework-workflow/references/customization.template.yaml +++ /dev/null @@ -1,3 +0,0 @@ -schemaVersion: 1 -isCustomized: false -settings: {} diff --git a/skills/virtualization-framework-workflow/scripts/customization_config.py b/skills/virtualization-framework-workflow/scripts/customization_config.py deleted file mode 100755 index 0c2ec7f6d..000000000 --- a/skills/virtualization-framework-workflow/scripts/customization_config.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "PyYAML>=6.0.2,<7", -# ] -# /// -"""Load and persist per-skill customization state.""" - -from __future__ import annotations - -import argparse -import copy -import os -import re -import sys -from pathlib import Path - -import yaml - -SCHEMA_VERSION = 1 -SKILL_NAME = "virtualization-framework-workflow" -CONFIG_HOME_ENV = "APPLE_DEV_SKILLS_CONFIG_HOME" -DEFAULT_CONFIG_ROOT = "~/.config/gaelic-ghost/apple-dev-skills" -ALLOWED_TOP_LEVEL = {"schemaVersion", "isCustomized", "settings"} - - -def fail(message: str) -> None: - print(f"ERROR: {message}", file=sys.stderr) - raise SystemExit(1) - - -def quote_string(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def encode_scalar(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if value is None: - return quote_string("") - return quote_string(str(value)) - - -def parse_yaml(path: Path) -> dict: - if not path.exists(): - fail(f"Missing YAML file: {path}") - - try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - fail(f"Invalid YAML in {path}: {exc}") - - if loaded is None: - return {} - if not isinstance(loaded, dict): - fail(f"Top-level YAML document must be a mapping in {path}") - - if isinstance(loaded.get("settings"), dict): - loaded["settings"] = { - key: ("" if value is None else value) for key, value in loaded["settings"].items() - } - - return loaded - - -def validate_config(config: dict, *, allow_partial: bool) -> None: - unknown = set(config.keys()) - ALLOWED_TOP_LEVEL - if unknown: - fail(f"Unknown top-level keys: {', '.join(sorted(unknown))}") - - if not allow_partial: - for required in ("schemaVersion", "isCustomized", "settings"): - if required not in config: - fail(f"Missing required key: {required}") - - if "schemaVersion" in config and config["schemaVersion"] != SCHEMA_VERSION: - fail(f"schemaVersion must be {SCHEMA_VERSION}") - - if "isCustomized" in config and not isinstance(config["isCustomized"], bool): - fail("isCustomized must be boolean") - - if "settings" in config: - if not isinstance(config["settings"], dict): - fail("settings must be a mapping") - for key, value in config["settings"].items(): - if not re.fullmatch(r"[A-Za-z0-9_]+", key): - fail(f"Invalid settings key: {key}") - if isinstance(value, (dict, list)): - fail(f"settings values must be scalar: {key}") - - -def merge_configs(base: dict, overlay: dict) -> dict: - merged = { - "schemaVersion": base.get("schemaVersion", SCHEMA_VERSION), - "isCustomized": base.get("isCustomized", False), - "settings": copy.deepcopy(base.get("settings", {})), - } - - if "schemaVersion" in overlay: - merged["schemaVersion"] = overlay["schemaVersion"] - if "isCustomized" in overlay: - merged["isCustomized"] = overlay["isCustomized"] - if "settings" in overlay: - merged["settings"].update(overlay["settings"]) - - return merged - - -def dump_yaml(config: dict) -> str: - lines = [ - f"schemaVersion: {int(config['schemaVersion'])}", - f"isCustomized: {'true' if config['isCustomized'] else 'false'}", - "settings:", - ] - for key in sorted(config["settings"].keys()): - lines.append(f" {key}: {encode_scalar(config['settings'][key])}") - return "\n".join(lines) + "\n" - - -def template_path() -> Path: - return Path(__file__).resolve().parents[1] / "references" / "customization.template.yaml" - - -def config_root() -> Path: - root = os.environ.get(CONFIG_HOME_ENV, DEFAULT_CONFIG_ROOT) - return Path(root).expanduser() - - -def durable_path() -> Path: - return config_root() / SKILL_NAME / "customization.yaml" - - -def load_template() -> dict: - cfg = parse_yaml(template_path()) - validate_config(cfg, allow_partial=False) - return cfg - - -def load_durable() -> dict: - path = durable_path() - if not path.exists(): - return {} - cfg = parse_yaml(path) - validate_config(cfg, allow_partial=False) - return cfg - - -def cmd_path(_: argparse.Namespace) -> None: - print(durable_path()) - - -def cmd_effective(_: argparse.Namespace) -> None: - effective = merge_configs(load_template(), load_durable()) - validate_config(effective, allow_partial=False) - print(dump_yaml(effective), end="") - - -def cmd_apply(args: argparse.Namespace) -> None: - template = load_template() - current = merge_configs(template, load_durable()) - incoming = parse_yaml(Path(args.input)) - validate_config(incoming, allow_partial=True) - - updated = merge_configs(current, incoming) - updated["schemaVersion"] = SCHEMA_VERSION - updated["isCustomized"] = True - validate_config(updated, allow_partial=False) - - target = durable_path() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(dump_yaml(updated), encoding="utf-8") - print(target) - - -def cmd_reset(_: argparse.Namespace) -> None: - target = durable_path() - if target.exists(): - target.unlink() - print(target) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Manage per-skill customization config") - subparsers = parser.add_subparsers(dest="command", required=True) - - parser_path = subparsers.add_parser("path", help="Print durable config path") - parser_path.set_defaults(func=cmd_path) - - parser_effective = subparsers.add_parser("effective", help="Print merged effective config") - parser_effective.set_defaults(func=cmd_effective) - - parser_apply = subparsers.add_parser("apply", help="Apply and persist config overrides") - parser_apply.add_argument("--input", required=True, help="Path to YAML overrides") - parser_apply.set_defaults(func=cmd_apply) - - parser_reset = subparsers.add_parser("reset", help="Delete durable config for this skill") - parser_reset.set_defaults(func=cmd_reset) - - return parser - - -def main() -> None: - parser = build_parser() - args = parser.parse_args() - args.func(args) - - -if __name__ == "__main__": - main() diff --git a/tests/repository-maintenance-e2e.fsx b/tests/repository-maintenance-e2e.fsx index f3a81c461..576e167a9 100644 --- a/tests/repository-maintenance-e2e.fsx +++ b/tests/repository-maintenance-e2e.fsx @@ -44,11 +44,21 @@ let nestedTests = not (parts |> Array.exists (fun part -> part = ".git" || part = ".venv" || part = ".codex")) && parts.Length > 1 && parts[0] <> "tests" - && (parts |> Array.exists (fun part -> part = "test" || part = "tests"))) + && (parts |> Array.exists (fun part -> part = "test" || part = "tests" || part = "evals"))) if not (Array.isEmpty nestedTests) then let rendered = String.concat ", " nestedTests failwith $"Tests must live only at the Socket root: {rendered}" +let legacyAutomation = + Directory.GetFiles(socketRoot, "*", SearchOption.AllDirectories) + |> Array.map (fun path -> Path.GetRelativePath(socketRoot, path)) + |> Array.filter (fun path -> + let parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + not (parts |> Array.exists (fun part -> part = ".git" || part = ".venv" || part = ".codex")) + && (path.EndsWith(".py", StringComparison.Ordinal) || path.EndsWith(".sh", StringComparison.Ordinal))) +if not (Array.isEmpty legacyAutomation) then + failwith $"Socket automation must use FSX only; found {legacyAutomation[0]}." + let snapshot () = Directory.GetFiles(testRoot, "*", SearchOption.AllDirectories) |> Array.filter (fun path -> not (path.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}"))) diff --git a/uv.lock b/uv.lock deleted file mode 100644 index b357d29b0..000000000 --- a/uv.lock +++ /dev/null @@ -1,328 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, - { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, - { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, - { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, - { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, -] - -[[package]] -name = "mypy" -version = "1.20.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/0d/555ab7453cc4a4a8643b7f21c842b1a84c36b15392061ae7b052ee119320/mypy-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c01eb9bac2c6a962d00f9d23421cd2913840e65bba365167d057bd0b4171a92e", size = 14336012, upload-time = "2026-04-13T02:45:39.935Z" }, - { url = "https://files.pythonhosted.org/packages/57/26/85a28893f7db8a16ebb41d1e9dfcb4475844d06a88480b6639e32a74d6ef/mypy-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55d12ddbd8a9cac5b276878bd534fa39fff5bf543dc6ae18f25d30c8d7d27fca", size = 13224636, upload-time = "2026-04-13T02:45:49.659Z" }, - { url = "https://files.pythonhosted.org/packages/93/41/bd4cd3c2caeb6c448b669222b8cfcbdee4a03b89431527b56fca9e56b6f3/mypy-1.20.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0aa322c1468b6cdfc927a44ce130f79bb44bcd34eb4a009eb9f96571fd80955", size = 13663471, upload-time = "2026-04-13T02:46:20.276Z" }, - { url = "https://files.pythonhosted.org/packages/3e/56/7ee8c471e10402d64b6517ae10434541baca053cffd81090e4097d5609d4/mypy-1.20.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f8bc95899cf676b6e2285779a08a998cc3a7b26f1026752df9d2741df3c79e8", size = 14532344, upload-time = "2026-04-13T02:46:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/b5/95/b37d1fa859a433f6156742e12f62b0bb75af658544fb6dada9363918743a/mypy-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47c2b90191a870a04041e910277494b0d92f0711be9e524d45c074fe60c00b65", size = 14776670, upload-time = "2026-04-13T02:45:52.481Z" }, - { url = "https://files.pythonhosted.org/packages/03/77/b302e4cb0b80d2bdf6bf4fce5864bb4cbfa461f7099cea544eaf2457df78/mypy-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:9857dc8d2ec1a392ffbda518075beb00ac58859979c79f9e6bdcb7277082c2f2", size = 10816524, upload-time = "2026-04-13T02:45:37.711Z" }, - { url = "https://files.pythonhosted.org/packages/7f/21/d969d7a68eb964993ebcc6170d5ecaf0cf65830c58ac3344562e16dc42a9/mypy-1.20.1-cp311-cp311-win_arm64.whl", hash = "sha256:09d8df92bb25b6065ab91b178da843dda67b33eb819321679a6e98a907ce0e10", size = 9750419, upload-time = "2026-04-13T02:45:08.542Z" }, - { url = "https://files.pythonhosted.org/packages/69/1b/75a7c825a02781ca10bc2f2f12fba2af5202f6d6005aad8d2d1f264d8d78/mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51", size = 14494077, upload-time = "2026-04-13T02:45:55.085Z" }, - { url = "https://files.pythonhosted.org/packages/b0/54/5e5a569ea5c2b4d48b729fb32aa936eeb4246e4fc3e6f5b3d36a2dfbefb9/mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28", size = 13319495, upload-time = "2026-04-13T02:45:29.674Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a4/a1945b19f33e91721b59deee3abb484f2fa5922adc33bb166daf5325d76d/mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f", size = 13696948, upload-time = "2026-04-13T02:46:15.006Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/75e969781c2359b2f9c15b061f28ec6d67c8b61865ceda176e85c8e7f2de/mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37", size = 14706744, upload-time = "2026-04-13T02:46:00.482Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6e/b221b1de981fc4262fe3e0bf9ec272d292dfe42394a689c2d49765c144c4/mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237", size = 14949035, upload-time = "2026-04-13T02:45:06.021Z" }, - { url = "https://files.pythonhosted.org/packages/ca/4b/298ba2de0aafc0da3ff2288da06884aae7ba6489bc247c933f87847c41b3/mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d", size = 10883216, upload-time = "2026-04-13T02:45:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f9/5e25b8f0b8cb92f080bfed9c21d3279b2a0b6a601cdca369a039ba84789d/mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019", size = 9814299, upload-time = "2026-04-13T02:45:21.934Z" }, - { url = "https://files.pythonhosted.org/packages/21/e8/ef0991aa24c8f225df10b034f3c2681213cb54cf247623c6dec9a5744e70/mypy-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f3886c03e40afefd327bd70b3f634b39ea82e87f314edaa4d0cce4b927ddcc1", size = 14500739, upload-time = "2026-04-13T02:46:05.442Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/416ebec3047636ed89fa871dc8c54bf05e9e20aa9499da59790d7adb312d/mypy-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e860eb3904f9764e83bafd70c8250bdffdc7dde6b82f486e8156348bf7ceb184", size = 13314735, upload-time = "2026-04-13T02:46:47.154Z" }, - { url = "https://files.pythonhosted.org/packages/10/1e/1505022d9c9ac2e014a384eb17638fb37bf8e9d0a833ea60605b66f8f7ba/mypy-1.20.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4b5aac6e785719da51a84f5d09e9e843d473170a9045b1ea7ea1af86225df4b", size = 13704356, upload-time = "2026-04-13T02:45:19.773Z" }, - { url = "https://files.pythonhosted.org/packages/98/91/275b01f5eba5c467a3318ec214dd865abb66e9c811231c8587287b92876a/mypy-1.20.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f37b6cd0fe2ad3a20f05ace48ca3523fc52ff86940e34937b439613b6854472e", size = 14696420, upload-time = "2026-04-13T02:45:24.205Z" }, - { url = "https://files.pythonhosted.org/packages/a1/57/b3779e134e1b7250d05f874252780d0a88c068bc054bcff99ca20a3a2986/mypy-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4bbb0f6b54ce7cc350ef4a770650d15fa70edd99ad5267e227133eda9c94218", size = 14936093, upload-time = "2026-04-13T02:45:32.087Z" }, - { url = "https://files.pythonhosted.org/packages/be/33/81b64991b0f3f278c3b55c335888794af190b2d59031a5ad1401bcb69f1e/mypy-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3dc20f8ec76eecd77148cdd2f1542ed496e51e185713bf488a414f862deb8f2", size = 10889659, upload-time = "2026-04-13T02:46:02.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fd/7adcb8053572edf5ef8f3db59599dfeeee3be9cc4c8c97e2d28f66f42ac5/mypy-1.20.1-cp313-cp313-win_arm64.whl", hash = "sha256:a9d62bbac5d6d46718e2b0330b25e6264463ed832722b8f7d4440ff1be3ca895", size = 9815515, upload-time = "2026-04-13T02:46:32.103Z" }, - { url = "https://files.pythonhosted.org/packages/40/cd/db831e84c81d57d4886d99feee14e372f64bbec6a9cb1a88a19e243f2ef5/mypy-1.20.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:12927b9c0ed794daedcf1dab055b6c613d9d5659ac511e8d936d96f19c087d12", size = 14483064, upload-time = "2026-04-13T02:45:26.901Z" }, - { url = "https://files.pythonhosted.org/packages/d5/82/74e62e7097fa67da328ac8ece8de09133448c04d20ddeaeba251a3000f01/mypy-1.20.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:752507dd481e958b2c08fc966d3806c962af5a9433b5bf8f3bdd7175c20e34fe", size = 13335694, upload-time = "2026-04-13T02:46:12.514Z" }, - { url = "https://files.pythonhosted.org/packages/74/c4/97e9a0abe4f3cdbbf4d079cb87a03b786efeccf5bf2b89fe4f96939ab2e6/mypy-1.20.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c614655b5a065e56274c6cbbe405f7cf7e96c0654db7ba39bc680238837f7b08", size = 13726365, upload-time = "2026-04-13T02:45:17.422Z" }, - { url = "https://files.pythonhosted.org/packages/d7/aa/a19d884a8d28fcd3c065776323029f204dbc774e70ec9c85eba228b680de/mypy-1.20.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c3f6221a76f34d5100c6d35b3ef6b947054123c3f8d6938a4ba00b1308aa572", size = 14693472, upload-time = "2026-04-13T02:46:41.253Z" }, - { url = "https://files.pythonhosted.org/packages/84/44/cc9324bd21cf786592b44bf3b5d224b3923c1230ec9898d508d00241d465/mypy-1.20.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4bdfc06303ac06500af71ea0cdbe995c502b3c9ba32f3f8313523c137a25d1b6", size = 14919266, upload-time = "2026-04-13T02:46:28.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/dc/779abb25a8c63e8f44bf5a336217fa92790fa17e0c40e0c725d10cb01bbd/mypy-1.20.1-cp314-cp314-win_amd64.whl", hash = "sha256:0131edd7eba289973d1ba1003d1a37c426b85cdef76650cd02da6420898a5eb3", size = 11049713, upload-time = "2026-04-13T02:45:57.673Z" }, - { url = "https://files.pythonhosted.org/packages/28/08/4172be2ad7de9119b5a92ca36abbf641afdc5cb1ef4ae0c3a8182f29674f/mypy-1.20.1-cp314-cp314-win_arm64.whl", hash = "sha256:33f02904feb2c07e1fdf7909026206396c9deeb9e6f34d466b4cfedb0aadbbe4", size = 9999819, upload-time = "2026-04-13T02:46:35.039Z" }, - { url = "https://files.pythonhosted.org/packages/2d/af/af9e46b0c8eabbce9fc04a477564170f47a1c22b308822282a59b7ff315f/mypy-1.20.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:168472149dd8cc505c98cefd21ad77e4257ed6022cd5ed2fe2999bed56977a5a", size = 15547508, upload-time = "2026-04-13T02:46:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/a7/cd/39c9e4ad6ba33e069e5837d772a9e6c304b4a5452a14a975d52b36444650/mypy-1.20.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb674600309a8f22790cca883a97c90299f948183ebb210fbef6bcee07cb1986", size = 14399557, upload-time = "2026-04-13T02:46:10.021Z" }, - { url = "https://files.pythonhosted.org/packages/83/c1/3fd71bdc118ffc502bf57559c909927bb7e011f327f7bb8e0488e98a5870/mypy-1.20.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef2b2e4cc464ba9795459f2586923abd58a0055487cbe558cb538ea6e6bc142a", size = 15045789, upload-time = "2026-04-13T02:45:10.81Z" }, - { url = "https://files.pythonhosted.org/packages/8e/73/6f07ff8b57a7d7b3e6e5bf34685d17632382395c8bb53364ec331661f83e/mypy-1.20.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee461d396dd46b3f0ed5a098dbc9b8860c81c46ad44fa071afcfbc149f167c9", size = 15850795, upload-time = "2026-04-13T02:45:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e2/f7dffec1c7767078f9e9adf0c786d1fe0ff30964a77eb213c09b8b58cb76/mypy-1.20.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e364926308b3e66f1361f81a566fc1b2f8cd47fc8525e8136d4058a65a4b4f02", size = 16088539, upload-time = "2026-04-13T02:46:17.841Z" }, - { url = "https://files.pythonhosted.org/packages/1a/76/e0dee71035316e75a69d73aec2f03c39c21c967b97e277fd0ef8fd6aec66/mypy-1.20.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0c17fbd746d38c70cbc42647cfd884f845a9708a4b160a8b4f7e70d41f4d7fa", size = 12575567, upload-time = "2026-04-13T02:45:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/7ed43c9d9c3d1468f86605e323a5d97e411a448790a00f07e779f3211a46/mypy-1.20.1-cp314-cp314t-win_arm64.whl", hash = "sha256:db2cb89654626a912efda69c0d5c1d22d948265e2069010d3dde3abf751c7d08", size = 10378823, upload-time = "2026-04-13T02:45:13.35Z" }, - { url = "https://files.pythonhosted.org/packages/d8/28/926bd972388e65a39ee98e188ccf67e81beb3aacfd5d6b310051772d974b/mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06", size = 2636553, upload-time = "2026-04-13T02:46:30.45Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "packaging" -version = "26.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, -] - -[[package]] -name = "pathspec" -version = "1.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, -] - -[[package]] -name = "socket-maintenance" -version = "10.0.2" -source = { virtual = "." } - -[package.dev-dependencies] -dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "pyyaml" }, - { name = "ruff" }, - { name = "types-pyyaml" }, -] - -[package.metadata] - -[package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=1.20.1" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "ruff", specifier = ">=0.14.0" }, - { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, -] - -[[package]] -name = "types-pyyaml" -version = "6.0.12.20260408" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] From f175dfeb2d5143f7cd9a1e21919ac99ef899e57d Mon Sep 17 00:00:00 2001 From: Gale W <mail@galewilliams.com> Date: Fri, 21 Aug 2026 00:26:34 -0400 Subject: [PATCH 4/5] plugin: add deterministic agent plugin maintenance --- .agents/plugins/marketplace.json | 12 + .claude-plugin/marketplace.json | 5 +- README.md | 3 + ROADMAP.md | 72 ++++- .../deferred-work-wakeup-policy.md | 1 - .../macos-platform-security-skills-plan.md | 20 +- ...irtualization-and-container-skills-plan.md | 5 +- .../xcode-27-agentic-tooling-plan.md | 9 +- .../xcode-plugin-install-support-plan.md | 4 +- docs/releases/v10.0.3.md | 46 ++++ justfile | 4 + plugins/agent-engineering-skills/AGENTS.md | 10 +- .../.codex-plugin/plugin.json | 37 +++ plugins/agent-plugin-skills/AGENTS.md | 35 +++ .../skills/maintain-agent-plugins/SKILL.md | 103 +++++++ .../maintain-agent-plugins/agents/openai.yaml | 4 + .../assets/agent-plugins/agent-plugins.fsx | 251 ++++++++++++++++++ .../assets/agent-plugins/agent-plugins.just | 5 + .../syncing/20-agent-plugins.fsx | 22 ++ .../validations/30-agent-plugins.fsx | 22 ++ .../scripts/maintain-agent-plugins.fsx | 115 ++++++++ .../.codex-plugin/plugin.json | 9 +- plugins/agent-portability-skills/AGENTS.md | 18 +- plugins/agentdeck/.codex-plugin/plugin.json | 10 +- plugins/agentdeck/AGENTS.md | 3 + .../.codex-plugin/plugin.json | 4 +- .../.codex-plugin/plugin.json | 5 +- .../.codex-plugin/plugin.json | 51 +--- plugins/apple-dev-skills/AGENTS.md | 8 +- plugins/apple-dev-skills/README.md | 7 +- plugins/apple-dev-skills/ROADMAP.md | 4 +- .../docs/maintainers/reality-audit.md | 2 +- .../.codex-plugin/plugin.json | 6 +- .../.codex-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 5 +- .../dotnet-skills/.codex-plugin/plugin.json | 10 +- .../game-dev-skills/.codex-plugin/plugin.json | 9 +- .../.codex-plugin/plugin.json | 8 +- .../.codex-plugin/plugin.json | 9 +- .../.codex-plugin/plugin.json | 4 +- plugins/professional-skills/AGENTS.md | 7 +- .../python-skills/.codex-plugin/plugin.json | 13 +- .../.codex-plugin/plugin.json | 4 +- .../server-side-jvm/.codex-plugin/plugin.json | 4 +- .../.codex-plugin/plugin.json | 38 +-- plugins/swift-lang/.codex-plugin/plugin.json | 9 +- .../web-dev-skills/.codex-plugin/plugin.json | 2 +- scripts/agent-plugins/agent-plugins.fsx | 251 ++++++++++++++++++ scripts/agent-plugins/agent-plugins.just | 5 + .../syncing/20-agent-plugins.fsx | 22 ++ .../syncing/40-repository-skills-exports.fsx | 19 +- .../validations/30-agent-plugins.fsx | 22 ++ skills.sh.json | 8 +- skills/maintain-agent-plugins/SKILL.md | 103 +++++++ .../maintain-agent-plugins/agents/openai.yaml | 4 + .../assets/agent-plugins/agent-plugins.fsx | 251 ++++++++++++++++++ .../assets/agent-plugins/agent-plugins.just | 5 + .../syncing/20-agent-plugins.fsx | 22 ++ .../validations/30-agent-plugins.fsx | 22 ++ .../scripts/maintain-agent-plugins.fsx | 115 ++++++++ tests/repository-maintenance-e2e.fsx | 52 ++++ 61 files changed, 1717 insertions(+), 220 deletions(-) create mode 100644 docs/releases/v10.0.3.md create mode 100644 plugins/agent-plugin-skills/.codex-plugin/plugin.json create mode 100644 plugins/agent-plugin-skills/AGENTS.md create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/SKILL.md create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/agents/openai.yaml create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx create mode 100644 plugins/agent-plugin-skills/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx create mode 100644 scripts/agent-plugins/agent-plugins.fsx create mode 100644 scripts/agent-plugins/agent-plugins.just create mode 100644 scripts/repo-maintenance/syncing/20-agent-plugins.fsx create mode 100644 scripts/repo-maintenance/validations/30-agent-plugins.fsx create mode 100644 skills/maintain-agent-plugins/SKILL.md create mode 100644 skills/maintain-agent-plugins/agents/openai.yaml create mode 100644 skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx create mode 100644 skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just create mode 100644 skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx create mode 100644 skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx create mode 100644 skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index a06b5fdeb..3bc90d0b7 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -310,6 +310,18 @@ "authentication": "ON_INSTALL" }, "category": "Developer Tools" + }, + { + "name": "agent-plugin-skills", + "source": { + "source": "local", + "path": "./plugins/agent-plugin-skills" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" } ] } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 48af92140..b8c514d38 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,8 @@ }, "description": "Socket workflows for Claude Code, with Cowork-compatible skills and explicitly classified connector support.", "plugins": [ - { "name": "agent-portability-skills", "source": "./plugins/agent-portability-skills", "description": "Cross-host agent-skill and plugin portability workflows.", "category": "developer-tools", "tags": ["skills", "portability"], "strict": false }, + { "name": "agent-plugin-skills", "source": "./plugins/agent-plugin-skills", "description": "Deterministic agent-plugin repository creation and maintenance workflows.", "category": "developer-tools", "tags": ["plugins", "maintenance", "fsx"], "strict": false }, + { "name": "agent-portability-skills", "source": "./plugins/agent-portability-skills", "description": "Cross-host protocols and agent adapter workflows.", "category": "developer-tools", "tags": ["agents", "portability"], "strict": false }, { "name": "agent-engineering-skills", "source": "./plugins/agent-engineering-skills", "description": "Agent orchestration, external-worker, scheduling, and automation-design workflows.", "category": "developer-tools", "tags": ["agents", "orchestration"], "strict": false }, { "name": "android-dev-skills", "source": "./plugins/android-dev-skills", "description": "Android, Kotlin, Java, Gradle, testing, and release workflows.", "category": "developer-tools", "tags": ["android", "skills"], "strict": false }, { "name": "apple-creator-studio-skills", "source": "./plugins/apple-creator-studio-skills", "description": "Apple Creator Studio workflows for production and delivery.", "category": "productivity", "tags": ["apple", "creative"], "strict": false }, @@ -21,7 +22,7 @@ { "name": "model-lab-skills", "source": "./plugins/model-lab-skills", "description": "Reproducible model training, evaluation, intervention, and Apple runtime research workflows.", "category": "developer-tools", "tags": ["llm", "evaluation", "model-research", "apple-ml"], "strict": false }, { "name": "network-protocol-skills", "source": "./plugins/network-protocol-skills", "description": "Networking, transport, QUIC, HTTP/3, and WebRTC workflows.", "category": "developer-tools", "tags": ["networking", "protocols"], "strict": false }, { "name": "professional-skills", "source": "./plugins/professional-skills", "description": "Career and job-search workflows with Dice remote MCP support.", "category": "productivity", "tags": ["career", "job-search", "mcp"], "mcpServers": "./.mcp.json", "strict": false }, - { "name": "python-skills", "source": "./plugins/python-skills", "description": "Python, uv, FastAPI, FastMCP, testing, and packaging workflows.", "category": "developer-tools", "tags": ["python", "uv"], "strict": false }, + { "name": "python-skills", "source": "./plugins/python-skills", "description": "Diagnostics, packaging, tooling, CI, upgrade, and testing workflows for existing Python code.", "category": "developer-tools", "tags": ["python", "uv"], "strict": false }, { "name": "repository-skills", "source": "./plugins/repository-skills", "description": "Repository operations, documentation maintenance, release, and Codex worktree workflows.", "category": "developer-tools", "tags": ["repository", "documentation", "github", "worktrees"], "strict": false }, { "name": "reverse-engineering-skills", "source": "./plugins/reverse-engineering-skills", "description": "Artifact triage, binary analysis, exact-build macOS control research, and reproducible evidence workflows.", "category": "developer-tools", "tags": ["reverse-engineering", "security", "macos", "forensics"], "strict": false }, { "name": "rust-skills", "source": "./plugins/rust-skills", "description": "Rust, Cargo, crate, test, CI, and package workflows.", "category": "developer-tools", "tags": ["rust", "cargo"], "strict": false }, diff --git a/README.md b/README.md index 754bb35c0..d8ff8dc67 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,9 @@ Apple Dev Skills is Socket-owned under `plugins/apple-dev-skills` and keeps its Current Socket catalog shape: +- `agent-plugin-skills`: fixed-policy Codex plugin creation and repository-wide + plugin maintenance through managed FSX assets and exactly + `just plugins-check` and `just plugins-apply` - `agent-portability-skills`: cross-host protocol selection, ACP operation/development, Zed native/external/terminal workflows, Hermes operator/developer/gateway/Nous Research guidance, and source-bundled maintainer roles for Socket-owned skill portability and host adapter audits - `agent-engineering-skills`: portable coordinator/worker, external-agent, scheduling, and worktree/thread orchestration guidance; it also owns agent automation, eval, and n8n workflow design - `android-dev-skills`: Android, Kotlin, Java, Gradle, Android Gradle Plugin, Compose/XML UI, testing, lint, emulator-aware validation handoff, and release-readiness workflow guidance diff --git a/ROADMAP.md b/ROADMAP.md index 458da2059..56f2e6f76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ - [Milestone 31: macOS platform security skills expansion](#milestone-31-macos-platform-security-skills-expansion) - [Milestone 32: tvOS app experience and media playback workflows](#milestone-32-tvos-app-experience-and-media-playback-workflows) - [Milestone 33: Unified Swift workspace and CI-owned cloud deployment](#milestone-33-unified-swift-workspace-and-ci-owned-cloud-deployment) +- [Milestone 34: Deterministic agent plugin repositories](#milestone-34-deterministic-agent-plugin-repositories) - [Small Tickets](#small-tickets) - [Backlog Candidates](#backlog-candidates) - [History](#history) @@ -80,6 +81,7 @@ - Milestone 31: macOS platform security skills expansion - Completed - Milestone 32: tvOS app experience and media playback workflows - Completed - Milestone 33: Unified Swift workspace and CI-owned cloud deployment - In Progress +- Milestone 34: Deterministic agent plugin repositories - In Progress ## Milestone 6: Dotnet skills plugin @@ -501,6 +503,8 @@ In Progress - [x] Treat Agent Skills as the first portability layer while keeping Codex plugins, hooks, MCP registration, custom agents, and host package formats as target-specific adapters. - [ ] Keep Socket's root Codex marketplace model intact until a concrete non-Codex package or export target proves it needs a broader distribution abstraction. - [x] Rename `agent-plugin-skills` to `agent-portability-skills` so the child plugin name matches the cross-host compatibility role. +- [x] Recreate `agent-plugin-skills` as a distinct plugin-repository owner after + portability grew into cross-host protocols and adapters. - [ ] Route complex local orchestration through AgentUtils once that app exposes supported discovery, dry-run, backup, and apply contracts instead of expanding Socket plugin payloads into broad machine-management code. - [x] Add a checked-in Hermes Agent skill tap at root `skills/`, generated from the canonical `agent-portability-skills` source and grouped with `skills.sh.json`. - [x] Add `agent-portability-skills:hermes-agent-compatibility` with explicit skill, Codex bundle, MCP, and native Python plugin boundaries. @@ -517,7 +521,8 @@ In Progress - [ ] Replace the historical curated Hermes skill export with a complete, declarative Socket skill inventory. Keep the migration reviewable by grouping portable skills, recording intentional no-export decisions, and validating the inventory without claiming that Codex plugin manifests are Hermes plugins. - [ ] Add `agent-portability-skills:audit-agent-surface-portability`. - [ ] Add `agent-portability-skills:design-agent-host-adapter`. -- [ ] Add `agent-portability-skills:maintain-codex-plugin-surface`. +- [x] Move Codex plugin surface maintenance to + `agent-plugin-skills:maintain-agent-plugins`. - [x] Translate every declared Socket `.mcp.json` into checked-in, validated Hermes `mcp_servers` fragments with an explicit setup/status inventory. - [x] Document the prioritized native Hermes Python adapter plan, including per-plugin adapter shape, configuration boundary, and validation strategy without adding speculative bridge code. - [ ] Add common skill constraint checks for Codex, OpenCode, and Zed, including Zed's flat roots, trusted-worktree rule, and catalog budget. @@ -1133,6 +1138,62 @@ placing extensions beside their host apps under `Apps/`, standardizing Soto and native Homebrew-backed local service development, and reserving Linux artifacts and test/production deployments for GitHub Actions. +## Milestone 34: Deterministic agent plugin repositories + +### Status + +In Progress + +### Scope + + + +### Tickets + + + +### Exit Criteria + +- [x] The only plugin-maintenance recipes are `plugins-check` and + `plugins-apply`, and both cover the complete plugin set. +- [x] No removed bootstrap/sync skill, Python plugin automation, nested plugin + test, or portability-owned Codex packaging path remains. +- [x] A second apply is byte-idempotent and the root E2E/validation path passes. +- [ ] Socket 10.0.3 is merged, tagged, published, and remotely verified. + +### Decisions + +- `agent-plugin-skills` owns Codex plugin source shape, manifests, bundled + assets, marketplace wiring, and plugin repository consistency. +- `agent-portability-skills` owns cross-host protocols and adapters; + `agent-engineering-skills` owns agent-system behavior; AgentDeck owns its + runtime hooks; `repository-skills` 10.0.2 owns docs, repo sync/validation, and + releases. +- Plugin automation is managed FSX only. The public maintenance surface is + exactly `just plugins-check` and `just plugins-apply`, with no per-plugin + commands, profiles, Python generators, nested tests, or compatibility paths. +- Gale's publisher identity, Apache-2.0 license, default repository URL, + marketplace policy, default hook discovery, and prompt-count limit are fixed + policy. Plugin-specific prose and capability contents remain authored source. + +### Implementation + +- [x] Remove stale bootstrap and guidance-sync exports from + `agent-portability-skills` and `skills.sh.json`. +- [x] Add `agent-plugin-skills:maintain-agent-plugins` with a managed installer, + FSX reconciliation runtime, aggregate Just recipes, and repository-skills + sync/validation hooks. +- [x] Reconcile all Socket manifests to current plugin shape and fixed publisher + policy, add the new plugin to Codex and Claude marketplaces, and keep default + prompts within the documented three-prompt limit. +- [x] Narrow Agent Portability metadata and guidance, correct Agent Engineering + root-only validation guidance, and make AgentDeck's packaging/runtime + boundary explicit. +- [x] Prove install, apply idempotence, drift detection, marketplace wiring, and + repository-maintenance integration through the single root E2E test. +- [ ] Run root validation, commit and push the branch, and complete the canonical + Socket 10.0.3 patch release. + ## Small Tickets - [ ] Record issue-sized fixes, TODO/FIXME imports, and cleanup work that is too small or too unplanned for a milestone. @@ -1159,7 +1220,8 @@ and test/production deployments for GitHub Actions. - [x] Consolidated root agent guidance through the audited [`AGENTS.md` consolidation plan](./docs/maintainers/agents-guidance-consolidation-plan.md): preserved directly visible safety gates, moved procedures to one live owner, corrected current semantic drift, added focused ownership tests, and reduced root prompt load by 68 percent. Future skill-level consolidation can now apply the proven ownership pattern to repeated setup, validation, and handoff text. - [ ] Investigate further standardization and automation for shared skill scaffolding, evidence capture, validation prompts, and generated references so common workflow knowledge is maintained once and reused with lower token load. - [x] Redesign the Socket release flow around one branch-backed `prepare`/`inspect`/`advance` lifecycle. The feature worktree owns version preparation and the PR; the clean `main` checkout owns post-merge validation, evidence, annotated tagging, publication, structured branch accounting, and the final marketplace refresh. -- [x] Centralized Socket validation under `scripts/validate_socket.py` with core, compatibility, full, and release profiles. The shared structural layer checks marketplace wiring, plugin manifests and assets, child `AGENTS.md`, `SKILL.md` frontmatter, and present `agents/openai.yaml`; child-specific policy and behavior checks remain owned by their child projects and run once in the full profile. +- [x] Replaced the former Python validation profiles with canonical FSX + repository validation and one root integration/E2E test. - [ ] Add a future Apple Developer Portal Driver for accessible, interactive portal-only provisioning tasks. Keep Apple authentication, two-factor authentication, account/team selection, and destructive operations behind explicit user-visible confirmation gates; retain official App Store Connect REST, Xcode-aware discovery, `cktool`, and CKTool JS as the primary surfaces, and do not automate unsupported portal forms until a reviewed driver design exists. ## Backlog Candidates @@ -1182,7 +1244,8 @@ and test/production deployments for GitHub Actions. - [ ] Design a worker-thread orchestration workflow for Codex GUI use. Capture which fields belong in the worker launch envelope, how model and reasoning budgets are selected, how workers report branch, worktree, validation, and cleanup state, which actions remain main-thread only, and when a finished worker thread or worktree should be archived, removed, or kept for follow-up. - [ ] Add `agent-portability-skills:audit-agent-surface-portability` for inventorying `SKILL.md`, `.codex-plugin`, `.mcp.json`, hooks, app config, custom agents, and host compatibility notes across Socket child plugins. - [ ] Add `agent-portability-skills:design-agent-host-adapter` for deciding whether a host needs docs-only guidance, `.agents/skills` export, native MCP config, a plugin or package adapter, or no Socket-specific support. -- [ ] Add `agent-portability-skills:maintain-codex-plugin-surface` for Codex-specific marketplace, plugin manifest, hooks, MCP, app config, and enablement wording. +- [x] Move Codex-specific marketplace, plugin manifest, hooks, MCP, app config, + and enablement packaging to `agent-plugin-skills:maintain-agent-plugins`. - [ ] Keep Git-backed Codex marketplace install and update guidance ahead of local authoring notes. - [ ] Keep repo-local discovery mirror guidance separate from install guidance. - [ ] Add or refine troubleshooting language for confusing Codex plugin expectations. @@ -1241,7 +1304,8 @@ and test/production deployments for GitHub Actions. - Queued future `mlx-skills` and `coreml-skills` guidance plugins for Apple Silicon ML and Core ML workflows. - Completed the five-workflow Apple system-integration expansion: App Intents, Liquid Glass, SwiftUI performance evidence, iOS runtime forensics, and macOS distribution artifact inspection now have separate owners, targeted tests, and clear Xcode/provisioning handoffs. - Released the tvOS app-experience and media-playback workflows in Socket v9.23.0 with metadata, portability exports, compatibility records, validation, release evidence, and branch accounting. -- Consolidated root validation under `scripts/validate_socket.py`, keeping compatibility checks in CI and assigning child behavior suites to their owning projects so full validation does not run the same suite twice. +- Consolidated root validation under managed FSX repository maintenance and one + root integration/E2E path, removing nested child suites and duplicate runs. - Audited AppKit coverage against SwiftUI and queued an Apple Dev Skills AppKit app-architecture workflow so menu bar apps, restoration, MVC, archiving, Observation, and mixed AppKit/SwiftUI work get first-class guidance. - Implemented `apple-dev-skills:appkit-app-architecture-workflow` with AppKit ownership, menu bar, responder-chain, restoration, MVC, archiving, Observation, and mixed AppKit/SwiftUI references plus targeted tests. - Added draft `swift-steward` and `server-swift-steward` custom-agent roles plus root validator coverage so the steward contracts remain read-only and review-oriented until a guarded draft-patch workflow exists. diff --git a/docs/maintainers/deferred-work-wakeup-policy.md b/docs/maintainers/deferred-work-wakeup-policy.md index ce83962d1..fd2533b4f 100644 --- a/docs/maintainers/deferred-work-wakeup-policy.md +++ b/docs/maintainers/deferred-work-wakeup-policy.md @@ -68,5 +68,4 @@ Run these serially after changing this policy or its exported guidance: just repo-sync just repo-validate just test -uv run pytest plugins/repository-skills/skills/maintain-project-repo/tests/test_maintain_project_repo_workflow.py ``` diff --git a/docs/maintainers/macos-platform-security-skills-plan.md b/docs/maintainers/macos-platform-security-skills-plan.md index 61f3797f6..454c27850 100644 --- a/docs/maintainers/macos-platform-security-skills-plan.md +++ b/docs/maintainers/macos-platform-security-skills-plan.md @@ -713,24 +713,14 @@ Run commands strictly serially and from the owning repository root. Planning slice: -```bash -just repo-validate -just repo-validate -``` - -Implementation slices add, as applicable: - -```bash -cd plugins/apple-dev-skills && uv run pytest -cd plugins/reverse-engineering-skills && uv run scripts/validate_repo_metadata.py -cd plugins/cybersecurity-skills && uv run scripts/validate_repo_metadata.py +```text just repo-validate +just test ``` -Also run the repository's current Hermes, Claude/Cowork, architecture, and -release-ready validation gates before a publish or release request. Preserve -full-suite execution for the final integrated slice while using targeted tests -between coherent commits. +Also run the repository's current compatibility and release-ready validation +gates before a publish or release request. Preserve the root integration/E2E +execution for the final integrated slice between coherent commits. ## Branch And Commit Plan diff --git a/docs/maintainers/macos-virtualization-and-container-skills-plan.md b/docs/maintainers/macos-virtualization-and-container-skills-plan.md index 06bdfbd3b..0a136b861 100644 --- a/docs/maintainers/macos-virtualization-and-container-skills-plan.md +++ b/docs/maintainers/macos-virtualization-and-container-skills-plan.md @@ -242,9 +242,8 @@ Do not duplicate the same matrix in multiple plugins. Put Apple framework facts ### Static validation - Generate or refresh `agents/openai.yaml` from final skill content. -- Run the Apple Dev docs validator and pytest suite for Apple Dev changes. -- Run the Cybersecurity child metadata validator for security changes. -- Run root Socket metadata validation after every skill inventory or plugin metadata change. +- Run `just repo-validate` and the single root `just test` integration/E2E path + after skill inventory or plugin metadata changes. - Export portable skills through Hermes and update Claude and Cowork classifications in the same pass. - Keep all repository documentation links portable and all runtime paths discovered rather than machine-coded. diff --git a/docs/maintainers/xcode-27-agentic-tooling-plan.md b/docs/maintainers/xcode-27-agentic-tooling-plan.md index a4688752a..ac43f45c3 100644 --- a/docs/maintainers/xcode-27-agentic-tooling-plan.md +++ b/docs/maintainers/xcode-27-agentic-tooling-plan.md @@ -321,8 +321,7 @@ Validation: ```bash just repo-validate -just repo-validate -uv run pytest +just test ``` ### Slice 3: Localization And Device Hub @@ -336,8 +335,7 @@ Validation: ```bash just repo-validate -just repo-validate -uv run pytest +just test ``` ### Slice 4: Beta Docs Triage And Framework Updates @@ -350,8 +348,7 @@ Validation: ```bash just repo-validate -just repo-validate -uv run pytest +just test ``` ### Slice 5: Xcode Plug-In Research diff --git a/docs/maintainers/xcode-plugin-install-support-plan.md b/docs/maintainers/xcode-plugin-install-support-plan.md index 2fdf3b758..e545fe54c 100644 --- a/docs/maintainers/xcode-plugin-install-support-plan.md +++ b/docs/maintainers/xcode-plugin-install-support-plan.md @@ -155,9 +155,9 @@ matching import contract. Validation: -```bash +```text just repo-validate -uv run pytest +just test ``` ### Slice 2: Official Import Smoke Fixtures diff --git a/docs/releases/v10.0.3.md b/docs/releases/v10.0.3.md new file mode 100644 index 000000000..e9c6b8a3c --- /dev/null +++ b/docs/releases/v10.0.3.md @@ -0,0 +1,46 @@ +# Socket v10.0.3 + +## Changes + +- Replaces Socket's Python and shell repository automation with managed F# + scripts and canonical `just` commands from `repository-skills` 10.0.2. +- Makes `just docs-check` and `just docs-apply` the only documentation commands; + both always process README, CONTRIBUTING, AGENTS, and ROADMAP together. +- Adds `agent-plugin-skills:maintain-agent-plugins` with managed FSX assets, + exactly `just plugins-check` and `just plugins-apply`, fixed Socket publisher + policy, complete-manifest reconciliation, and repository-skills integration. +- Narrows `agent-portability-skills` to cross-host protocols and adapters, + keeps agent-system behavior in `agent-engineering-skills`, and makes + AgentDeck's runtime-hook versus package-maintenance boundary explicit. +- Aligns Socket plugin manifests with current plugin metadata shape, including + fixed publisher/license fields, default hook discovery, and no more than + three starter prompts. +- Consolidates validation into one root integration/E2E test and makes root + skill export synchronization discover newly declared managed skills. + +## Breaking Changes + +- Removes the obsolete plugin bootstrap and guidance-sync skills and their + stale exports. Use `agent-plugin-skills:maintain-agent-plugins` instead. +- Removes SwiftASB Skills and the deleted Python bootstrap, project-creation, + FastAPI, FastMCP, and agent-service skill surfaces. +- Removes nested child tests/evals and legacy Python or shell repository + automation. Socket validation now runs only from the repository root. +- Removes per-document documentation commands and customization profiles. + +## Upgrade + +- Refresh repository maintenance from `repository-skills` 10.0.2 or later, then + run `just repo-sync`. +- Replace old plugin bootstrap or guidance-sync invocations with + `$maintain-agent-plugins`; use `just plugins-apply` for full reconciliation + and `just plugins-check` for read-only validation. +- Replace nested child validation commands with `just repo-validate` and + `just test` at the Socket root. + +## Verification + +- `just docs-check` +- `just plugins-check` +- `just repo-validate` +- `just test` diff --git a/justfile b/justfile index 4b5967277..473d4e2fe 100644 --- a/justfile +++ b/justfile @@ -15,3 +15,7 @@ test: # BEGIN managed repo-maintenance import 'scripts/repo-maintenance/repo-maintenance.just' # END managed repo-maintenance + +# BEGIN managed agent-plugins +import 'scripts/agent-plugins/agent-plugins.just' +# END managed agent-plugins diff --git a/plugins/agent-engineering-skills/AGENTS.md b/plugins/agent-engineering-skills/AGENTS.md index 6f3870cb8..d30611ec9 100644 --- a/plugins/agent-engineering-skills/AGENTS.md +++ b/plugins/agent-engineering-skills/AGENTS.md @@ -10,6 +10,8 @@ documentation rules. orchestration, and agent-system evaluation. - Keep host compatibility adapters in `agent-portability-skills` and language implementation in the owning stack plugin. +- Keep plugin manifests, marketplace wiring, and plugin repository maintenance + in `agent-plugin-skills`. - Root `skills/` is the authored source of truth; plugin metadata is packaging. ## Validation @@ -17,9 +19,7 @@ documentation rules. Run from the Socket repository root so the shared maintainer environment and cache policy apply: -```bash -uv run python -B -m pytest \ - plugins/agent-engineering-skills/skills/design-agent-automation-workflow/tests \ - plugins/agent-engineering-skills/skills/design-agent-eval-workflow/tests \ - -o cache_dir=.codex/.cache/pytest +```text +just repo-validate +just test ``` diff --git a/plugins/agent-plugin-skills/.codex-plugin/plugin.json b/plugins/agent-plugin-skills/.codex-plugin/plugin.json new file mode 100644 index 000000000..65ba30ae9 --- /dev/null +++ b/plugins/agent-plugin-skills/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "agent-plugin-skills", + "version": "10.0.2", + "description": "Create and maintain deterministic agent plugin repositories with managed FSX automation and aggregate Just commands.", + "author": { + "name": "Gale", + "email": "mail@galewilliams.com", + "url": "https://github.com/gaelic-ghost" + }, + "homepage": "https://github.com/gaelic-ghost/agent-plugin-skills", + "repository": "https://github.com/gaelic-ghost/agent-plugin-skills", + "license": "Apache-2.0", + "keywords": [ + "codex", + "plugins", + "skills", + "maintainer", + "fsx", + "just" + ], + "skills": "./skills/", + "interface": { + "displayName": "Agent Plugin Skills", + "shortDescription": "Create and maintain deterministic agent plugins.", + "longDescription": "Fixed-policy workflows for creating and maintaining agent plugin repositories with managed F# scripts, aggregate Just commands, canonical manifests, marketplace wiring, and repository-skills integration.", + "developerName": "Gale", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/gaelic-ghost/agent-plugin-skills", + "defaultPrompt": [ + "Create or align an agent plugin with managed FSX automation." + ] + } +} diff --git a/plugins/agent-plugin-skills/AGENTS.md b/plugins/agent-plugin-skills/AGENTS.md new file mode 100644 index 000000000..9d7d8391f --- /dev/null +++ b/plugins/agent-plugin-skills/AGENTS.md @@ -0,0 +1,35 @@ +# AGENTS.md + +This plugin owns agent-plugin repository shape and deterministic plugin +maintenance. Follow Socket root guidance for Git, release, documentation, and +repository-wide safety rules. + +## Scope + +- Own Codex plugin manifests, bundled skill layout, local marketplace wiring, + fixed Socket publisher policy, and aggregate plugin validation/apply tooling. +- Keep repository documentation and releases in `repository-skills`, agent + system behavior in `agent-engineering-skills`, cross-host transports and + adapters in `agent-portability-skills`, and runtime hook behavior in the + plugin that ships the hook. +- Treat authored plugin contents as source and installed plugin state as + read-only runtime state. + +## Automation Contract + +- Use only managed `.fsx` scripts and `just` recipes. +- Expose exactly `just plugins-check` and `just plugins-apply` for plugin + maintenance. Both commands always process the whole repository plugin set. +- Do not add per-plugin commands, Python or shell scripts, customization + profiles, nested tests, compatibility wrappers, or parallel metadata paths. +- Keep Gale's publisher identity, Apache-2.0 license, marketplace policy, and + default repository conventions fixed in the managed implementation. + +## Validation + +Run the essential integration path from the Socket repository root: + +```text +just plugins-check +just test +``` diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/SKILL.md b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/SKILL.md new file mode 100644 index 000000000..00470e449 --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/SKILL.md @@ -0,0 +1,103 @@ +--- +name: maintain-agent-plugins +description: Create or align agent plugin repositories, install deterministic managed FSX plugin maintenance, and validate or apply the whole plugin set through exactly two aggregate Just commands. +license: Apache-2.0 +metadata: + semver: 1.0.0 +--- + +# Maintain Agent Plugins + +## Purpose + +Create and maintain Codex plugin source without reintroducing hand-maintained +metadata, per-plugin commands, Python automation, or customizable scaffolding. +Use current official OpenAI plugin documentation for host schema facts, then +apply the fixed Socket repository policy through the managed runtime. + +## Required Interface + +Plugin maintenance has exactly two public commands. Each processes every plugin +manifest and marketplace entry owned by the repository: + +```text +just plugins-check +just plugins-apply +``` + +Do not add per-plugin, per-manifest, scaffold, sync, bootstrap, or alternate +mode recipes. `plugins-check` is read-only. `plugins-apply` performs the complete +deterministic reconciliation and then checks its result. + +## Installation + +1. Confirm the target is a Git repository root. +2. Run `scripts/maintain-agent-plugins.fsx --repo-root <path> --operation install` + from this skill once to copy the managed runtime and Just import. +3. The installer also adds ordered repository-maintenance hooks when the target + already uses the canonical `scripts/repo-maintenance/` runtime. +4. Run `just plugins-apply`, then `just repo-validate` when repository-skills is + present. + +Use `--operation refresh` to replace managed files from this skill and +`--operation report-only` for a read-only drift report. These installer modes +are skill internals, not additional Just tasks. + +## Fixed Policy + +- Publisher: Gale, `mail@galewilliams.com`, + `https://github.com/gaelic-ghost`. +- License: Apache-2.0. +- Plugin names: stable kebab case matching their directory. +- Manifest: `.codex-plugin/plugin.json`; bundled skills: `./skills/`. +- Default plugin repository URL: + `https://github.com/gaelic-ghost/<plugin-name>`. +- Local marketplace policy: `AVAILABLE` and `ON_INSTALL`. +- Default category: `Developer Tools` unless the plugin already has an + intentional product category. +- Default-discovered hook files stay at `hooks/hooks.json`; do not duplicate + that default path in the manifest. +- `interface.defaultPrompt` is an array of at most three concise prompts. + +The runtime owns these choices. Do not add user profiles, policy files, +template variables, interactive questions, or project-local overrides. + +## Creating a Plugin + +1. Establish one bounded capability and its owning plugin name. +2. Create the plugin directory, `.codex-plugin/plugin.json`, `AGENTS.md`, and at + least one complete skill under `skills/<skill-name>/SKILL.md`. +3. Add only real optional surfaces: `.mcp.json`, `.app.json`, `hooks/`, agents, + or assets must have an actual consumer and validation path. +4. Use the fixed publisher and license policy. Write plugin-specific purpose, + descriptions, prompts, keywords, and capability content directly; these are + authored semantics, not customization inputs. +5. Run `just plugins-apply`. In Socket, this adds missing root marketplace + wiring and checks the complete plugin set. +6. Run only the repository root integration/E2E path. Do not create tests under + the plugin or skill. + +## Ownership Handoffs + +- Use `repository-skills:maintain-project-repo` for the four canonical docs, + repo-wide synchronization and validation, protected-main releases, and + version changes. +- Use `agent-engineering-skills` for agent orchestration, automation, eval, and + scheduling behavior. +- Use `agent-portability-skills` for ACP, A2A, MCP boundary selection, Zed, + Hermes, and other host adapters. +- Keep runtime hooks and MCP implementation with the plugin that ships them; + this skill owns their packaging consistency, not their product behavior. + +## Guards + +- Never edit installed plugin caches or enabled-state configuration. +- Never restore `bootstrap-skills-plugin-repo`, + `sync-skills-repo-guidance`, Python generators, or nested plugin tests. +- Never infer a new packaging layer or publish destination. +- Stop when a manifest name and directory disagree, a managed target is not a + regular file, or a required optional asset is missing. + +## Script Inventory + +- `scripts/maintain-agent-plugins.fsx` diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/agents/openai.yaml b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/agents/openai.yaml new file mode 100644 index 000000000..3144a1644 --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Maintain Agent Plugins" + short_description: "Create and align deterministic plugin repos" + default_prompt: "Use $maintain-agent-plugins to create or align the complete agent plugin set with managed FSX automation, exactly plugins-check and plugins-apply, fixed Socket publisher policy, and repository-skills integration." diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx new file mode 100644 index 000000000..68161d86a --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx @@ -0,0 +1,251 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text +open System.Text.Encodings.Web +open System.Text.Json +open System.Text.Json.Nodes +open System.Text.RegularExpressions + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..")) +let fail message = raise (InvalidOperationException(message)) +let jsonOptions = JsonSerializerOptions(WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let serialize (node: JsonNode) = node.ToJsonString(jsonOptions) + "\n" + +let normalizePrompt (value: string) = + let trimmed = value.Trim() + if trimmed.Length <= 128 then trimmed else trimmed.Substring(0, 127).TrimEnd() + "…" + +let requiredString (owner: string) (name: string) (node: JsonObject) = + match node[name] with + | null -> fail $"{owner} is missing required field {name}." + | value -> + let text = value.GetValue<string>() + if String.IsNullOrWhiteSpace(text) then fail $"{owner} has an empty {name}." + text + +let objectAt (owner: string) (name: string) (node: JsonObject) = + match node[name] with + | :? JsonObject as value -> value + | _ -> fail $"{owner} is missing object {name}." + +let pluginRoots = + let plugins = Path.Combine(root, "plugins") + if Directory.Exists(plugins) then + Directory.GetDirectories(plugins) + |> Array.filter (fun directory -> File.Exists(Path.Combine(directory, ".codex-plugin", "plugin.json"))) + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + elif File.Exists(Path.Combine(root, ".codex-plugin", "plugin.json")) then [| root |] + else fail $"No plugin manifests were found under {root}." + +let manifestPath (pluginRoot: string) = Path.Combine(pluginRoot, ".codex-plugin", "plugin.json") +let loadObject (path: string) = JsonNode.Parse(File.ReadAllText(path)).AsObject() + +let normalizeManifest (pluginRoot: string) = + let path = manifestPath pluginRoot + let manifest = loadObject path + let directoryName = Path.GetFileName(pluginRoot) + let name = requiredString path "name" manifest + if name <> directoryName then fail $"Plugin manifest name {name} does not match directory {directoryName}." + + let author = + match manifest["author"] with + | :? JsonObject as value -> value + | _ -> let value = JsonObject() in manifest["author"] <- value; value + author["name"] <- "Gale" + author["email"] <- "mail@galewilliams.com" + author["url"] <- "https://github.com/gaelic-ghost" + manifest["license"] <- "Apache-2.0" + + if manifest["homepage"] = null then + manifest["homepage"] <- $"https://github.com/gaelic-ghost/{name}" + if manifest["repository"] = null then + manifest["repository"] <- $"https://github.com/gaelic-ghost/{name}" + + match manifest["hooks"] with + | null -> () + | value when value.GetValue<string>() = "./hooks/hooks.json" -> manifest.Remove("hooks") |> ignore + | _ -> () + + match manifest["interface"] with + | :? JsonObject as interfaceNode -> + match interfaceNode["defaultPrompt"] with + | :? JsonValue as prompt -> + let prompts = JsonArray() + prompts.Add(normalizePrompt (prompt.GetValue<string>())) + interfaceNode["defaultPrompt"] <- prompts + | :? JsonArray as prompts -> + while prompts.Count > 3 do prompts.RemoveAt(prompts.Count - 1) + for index in 0 .. prompts.Count - 1 do + prompts[index] <- normalizePrompt (prompts[index].GetValue<string>()) + | _ -> () + | _ -> () + + let updated = serialize manifest + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let ensureSocketMarketplace () = + let path = Path.Combine(root, ".agents", "plugins", "marketplace.json") + if File.Exists(path) then + let marketplace = loadObject path + let entries = marketplace["plugins"].AsArray() + let existing = + entries + |> Seq.choose (fun node -> if isNull node then None else Some(node["name"].GetValue<string>())) + |> Set.ofSeq + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue<string>() + if not (existing.Contains(name)) then + let category = + match manifest["interface"] with + | :? JsonObject as value when value["category"] <> null -> value["category"].GetValue<string>() + | _ -> "Developer Tools" + let entry = JsonObject() + entry["name"] <- name + let source = JsonObject() + source["source"] <- "local" + source["path"] <- $"./plugins/{name}" + entry["source"] <- source + let policy = JsonObject() + policy["installation"] <- "AVAILABLE" + policy["authentication"] <- "ON_INSTALL" + entry["policy"] <- policy + entry["category"] <- category + entries.Add(entry) + let updated = serialize marketplace + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let ensureSkillsExport () = + let path = Path.Combine(root, "skills.sh.json") + if File.Exists(path) then + let document = loadObject path + let groupings = document["groupings"].AsArray() + for groupingNode in groupings do + let skills = groupingNode["skills"].AsArray() + let staleIndexes = + skills + |> Seq.indexed + |> Seq.choose (fun (index, node) -> + let name = node.GetValue<string>() + if name = "bootstrap-skills-plugin-repo" || name = "sync-skills-repo-guidance" then Some index else None) + |> Seq.sortDescending + |> Seq.toList + for index in staleIndexes do skills.RemoveAt(index) + let pluginGrouping = + groupings + |> Seq.tryFind (fun node -> node["title"].GetValue<string>() = "Agent Plugin Skills") + match pluginGrouping with + | Some grouping -> + let skills = grouping["skills"].AsArray() + if not (skills |> Seq.exists (fun node -> node.GetValue<string>() = "maintain-agent-plugins")) then skills.Add("maintain-agent-plugins") + | None -> + let grouping = JsonObject() + grouping["title"] <- "Agent Plugin Skills" + let skills = JsonArray() + skills.Add("maintain-agent-plugins") + grouping["skills"] <- skills + groupings.Insert(0, grouping) + let updated = serialize document + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let validateManifest (pluginRoot: string) = + let path = manifestPath pluginRoot + let manifest = loadObject path + let name = requiredString path "name" manifest + if name <> Path.GetFileName(pluginRoot) then fail $"Plugin manifest name {name} does not match its directory." + let version = requiredString path "version" manifest + if not (Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")) then fail $"Plugin {name} has invalid SemVer {version}." + requiredString path "description" manifest |> ignore + if requiredString path "license" manifest <> "Apache-2.0" then fail $"Plugin {name} must use Apache-2.0." + let author = objectAt path "author" manifest + if requiredString path "name" author <> "Gale" + || requiredString path "email" author <> "mail@galewilliams.com" + || requiredString path "url" author <> "https://github.com/gaelic-ghost" then + fail $"Plugin {name} does not use the fixed Socket publisher identity." + requiredString path "homepage" manifest |> ignore + requiredString path "repository" manifest |> ignore + let interfaceNode = objectAt path "interface" manifest + for field in [ "displayName"; "shortDescription"; "longDescription"; "developerName"; "category" ] do + requiredString path field interfaceNode |> ignore + match interfaceNode["defaultPrompt"] with + | null -> () + | :? JsonArray as prompts when prompts.Count <= 3 -> + for prompt in prompts do + let value = prompt.GetValue<string>() + if String.IsNullOrWhiteSpace(value) || value.Length > 128 then fail $"Plugin {name} has an invalid default prompt." + | :? JsonArray -> fail $"Plugin {name} has more than three default prompts." + | _ -> fail $"Plugin {name} interface.defaultPrompt must be an array." + if manifest["hooks"] <> null && manifest["hooks"].GetValue<string>() = "./hooks/hooks.json" then + fail $"Plugin {name} redundantly declares the default hooks/hooks.json path." + for field in [ "composerIcon"; "logo"; "logoDark" ] do + match interfaceNode[field] with + | null -> () + | value -> + let relative = value.GetValue<string>().TrimStart('.', '/', '\\') + if not (File.Exists(Path.Combine(pluginRoot, relative))) then fail $"Plugin {name} references missing {field} asset {relative}." + if manifest["skills"] <> null then + let relative = manifest["skills"].GetValue<string>().TrimStart('.', '/', '\\') + let skillsRoot = Path.Combine(pluginRoot, relative) + if not (Directory.Exists(skillsRoot)) then fail $"Plugin {name} references missing skills directory {relative}." + if Array.isEmpty (Directory.GetFiles(skillsRoot, "SKILL.md", SearchOption.AllDirectories)) then fail $"Plugin {name} contains no SKILL.md files." + +let validateSocketMarketplace () = + let path = Path.Combine(root, ".agents", "plugins", "marketplace.json") + if File.Exists(path) then + let marketplace = loadObject path + let entries = marketplace["plugins"].AsArray() + let byName = + entries + |> Seq.map (fun node -> node["name"].GetValue<string>(), node.AsObject()) + |> Seq.groupBy fst + |> Seq.map (fun (name, values) -> name, values |> Seq.map snd |> Seq.toList) + |> Map.ofSeq + for KeyValue(name, values) in byName do + if values.Length <> 1 then fail $"Socket marketplace contains duplicate plugin {name}." + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue<string>() + match byName.TryFind(name) with + | None -> fail $"Socket marketplace is missing plugin {name}. Run just plugins-apply." + | Some [ entry ] -> + let source = objectAt name "source" entry + if requiredString name "source" source <> "local" || requiredString name "path" source <> $"./plugins/{name}" then + fail $"Socket marketplace source is incorrect for {name}." + let policy = objectAt name "policy" entry + if requiredString name "installation" policy <> "AVAILABLE" || requiredString name "authentication" policy <> "ON_INSTALL" then + fail $"Socket marketplace policy is incorrect for {name}." + | _ -> fail $"Socket marketplace contains duplicate plugin {name}." + +let validateStaleExports () = + let path = Path.Combine(root, "skills.sh.json") + if File.Exists(path) then + let text = File.ReadAllText(path) + for stale in [ "bootstrap-skills-plugin-repo"; "sync-skills-repo-guidance" ] do + if text.Contains(stale, StringComparison.Ordinal) then fail $"Stale removed skill remains exported: {stale}." + +let validateAll () = + pluginRoots |> Array.iter validateManifest + validateSocketMarketplace () + validateStaleExports () + printfn "Validated %d agent plugin manifest(s), assets, marketplace entries, and removed-surface guards." pluginRoots.Length + +let operation = fsi.CommandLineArgs |> Array.skip 1 |> Array.tryHead |> Option.defaultValue "check" +match operation with +| "check" -> validateAll () +| "apply" -> + pluginRoots |> Array.iter normalizeManifest + ensureSocketMarketplace () + ensureSkillsExport () + validateAll () + printfn "Applied deterministic agent-plugin policy to the complete plugin set." +| _ -> fail $"Usage: agent-plugins.fsx check|apply" diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just new file mode 100644 index 000000000..237ca9477 --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just @@ -0,0 +1,5 @@ +plugins-check: + dotnet fsi scripts/agent-plugins/agent-plugins.fsx check + +plugins-apply: + dotnet fsi scripts/agent-plugins/agent-plugins.fsx apply diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx new file mode 100644 index 000000000..743fdb6b8 --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx @@ -0,0 +1,22 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let script = Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx") +if not (File.Exists(script)) then failwith $"Managed agent-plugin runtime is missing: {script}" + +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; script; "apply" ] do info.ArgumentList.Add(argument) +use child = Process.Start(info) +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Agent-plugin apply failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "%s" (stdout.Trim()) diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx new file mode 100644 index 000000000..a7ca130f7 --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx @@ -0,0 +1,22 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let script = Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx") +if not (File.Exists(script)) then failwith $"Managed agent-plugin runtime is missing: {script}" + +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; script; "check" ] do info.ArgumentList.Add(argument) +use child = Process.Start(info) +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Agent-plugin validation failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "%s" (stdout.Trim()) diff --git a/plugins/agent-plugin-skills/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx new file mode 100644 index 000000000..5b29e1a88 --- /dev/null +++ b/plugins/agent-plugin-skills/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx @@ -0,0 +1,115 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json + +type ManagedFile = { Source: string; Target: string; RequiresRepoMaintenance: bool } +type Action = { Action: string; Target: string } + +let scriptRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let skillRoot = Path.GetFullPath(Path.Combine(scriptRoot, "..")) +let assetsRoot = Path.Combine(skillRoot, "assets") + +let managed = [ + { Source = "agent-plugins/agent-plugins.fsx"; Target = "scripts/agent-plugins/agent-plugins.fsx"; RequiresRepoMaintenance = false } + { Source = "agent-plugins/agent-plugins.just"; Target = "scripts/agent-plugins/agent-plugins.just"; RequiresRepoMaintenance = false } + { Source = "repo-maintenance/validations/30-agent-plugins.fsx"; Target = "scripts/repo-maintenance/validations/30-agent-plugins.fsx"; RequiresRepoMaintenance = true } + { Source = "repo-maintenance/syncing/20-agent-plugins.fsx"; Target = "scripts/repo-maintenance/syncing/20-agent-plugins.fsx"; RequiresRepoMaintenance = true } +] + +let parseArgs argv = + let mutable repoRoot = "." + let mutable operation = "install" + let rec loop args = + match args with + | [] -> () + | "--repo-root" :: value :: tail -> repoRoot <- value; loop tail + | "--operation" :: value :: tail -> operation <- value; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + Path.GetFullPath(repoRoot), operation + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let ensureInside (root: string) (relative: string) = + if Path.IsPathRooted(relative) || relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) |> Array.contains ".." then + failwith $"Managed target must be repository-relative: {relative}" + Path.Combine(root, relative) |> Path.GetFullPath + +let isGitRepository (root: string) = + let info = ProcessStartInfo("git") + info.WorkingDirectory <- root + info.UseShellExecute <- false + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + info.ArgumentList.Add("rev-parse") + info.ArgumentList.Add("--show-prefix") + use child = Process.Start(info) + let output = child.StandardOutput.ReadToEnd().Trim() + child.StandardError.ReadToEnd() |> ignore + child.WaitForExit() + child.ExitCode = 0 && String.IsNullOrWhiteSpace(output) + +let copyManaged (root: string) (apply: bool) (file: ManagedFile) = + let source = Path.Combine(assetsRoot, file.Source) + let target = ensureInside root file.Target + if not (File.Exists(source)) then failwith $"Managed source is missing: {source}" + if File.Exists(target) && not ((File.GetAttributes(target) &&& FileAttributes.Directory) = enum 0) then + failwith $"Managed target is not a regular file: {target}" + let content = File.ReadAllText(source).Replace("\r\n", "\n") + let action = + if File.Exists(target) && File.ReadAllText(target).Replace("\r\n", "\n") = content then "unchanged" + elif File.Exists(target) then "update" + else "install" + if apply && action <> "unchanged" then atomicWrite target content + { Action = action; Target = file.Target } + +let ensureJustImport (root: string) (apply: bool) = + let path = Path.Combine(root, "justfile") + let importLine = "import 'scripts/agent-plugins/agent-plugins.just'" + let existing = if File.Exists(path) then File.ReadAllText(path).Replace("\r\n", "\n") else "" + if existing.Contains(importLine, StringComparison.Ordinal) then { Action = "unchanged"; Target = "justfile" } + else + let updated = existing.TrimEnd() + (if String.IsNullOrWhiteSpace(existing) then "" else "\n\n") + "# BEGIN managed agent-plugins\n" + importLine + "\n# END managed agent-plugins\n" + if apply then atomicWrite path updated + { Action = (if File.Exists(path) then "update" else "install"); Target = "justfile" } + +let runRuntime (root: string) (operation: string) = + let info = ProcessStartInfo("dotnet") + info.WorkingDirectory <- root + info.UseShellExecute <- false + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + for argument in [ "fsi"; Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx"); operation ] do info.ArgumentList.Add(argument) + use child = Process.Start(info) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + if child.ExitCode <> 0 then failwith $"Managed agent-plugin {operation} failed: {stderr.Trim()}\n{stdout.Trim()}" + stdout.Trim() + +let root, operation = parseArgs (fsi.CommandLineArgs |> Array.skip 1) +if not (Directory.Exists(root)) then failwith $"Repository root does not exist: {root}" +if not (isGitRepository root) then failwith $"Path is not the root of a Git repository: {root}" +if not (List.contains operation [ "install"; "refresh"; "report-only" ]) then failwith $"Unsupported operation: {operation}" +let apply = operation <> "report-only" +let hasRepoMaintenance = Directory.Exists(Path.Combine(root, "scripts", "repo-maintenance")) +let files = managed |> List.filter (fun file -> not file.RequiresRepoMaintenance || hasRepoMaintenance) +let actions = (files |> List.map (copyManaged root apply)) @ [ ensureJustImport root apply ] +let hasDrift = actions |> List.exists (fun action -> action.Action <> "unchanged") +let runtimeResult = + if apply then runRuntime root "apply" + elif hasDrift then "Managed files differ; run the installer with --operation refresh." + else runRuntime root "check" +let report = {| status = (if operation = "report-only" && hasDrift then "drift" else "success"); operation = operation; repoRoot = root; actions = actions; result = runtimeResult |} +let options = JsonSerializerOptions(WriteIndented = true) +Console.WriteLine(JsonSerializer.Serialize(report, options)) +if operation = "report-only" && hasDrift then exit 1 diff --git a/plugins/agent-portability-skills/.codex-plugin/plugin.json b/plugins/agent-portability-skills/.codex-plugin/plugin.json index 114a966cc..581b4d285 100644 --- a/plugins/agent-portability-skills/.codex-plugin/plugin.json +++ b/plugins/agent-portability-skills/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agent-portability-skills", "version": "10.0.2", - "description": "Skills for agent portability, ACP, A2A, Zed, Hermes, Codex plugin surfaces, and host adapter guidance.", + "description": "Skills for agent portability, ACP, A2A, Zed, Hermes, and host adapter guidance.", "author": { "name": "Gale", "email": "mail@galewilliams.com", @@ -12,13 +12,10 @@ "license": "Apache-2.0", "keywords": [ "codex", - "plugin", "skills", - "maintainer", "agent-skills", "portability", "mcp", - "hooks", "hermes", "nous", "acp", @@ -31,8 +28,8 @@ "skills": "./skills/", "interface": { "displayName": "Agent Portability Skills", - "shortDescription": "Maintainer skills for agent skill and plugin portability.", - "longDescription": "Installable workflows for ACP editor-agent integration, A2A peer-agent operation, Zed native and external agents, Hermes Agent and Nous services, Socket-owned skill portability, Codex plugin surfaces, MCP and hook boundaries, and host adapter guidance across Xcode, OpenCode, and Claude Code.", + "shortDescription": "Cross-host protocols and agent adapter workflows.", + "longDescription": "Installable workflows for ACP editor-agent integration, A2A peer-agent operation, Zed native and external agents, Hermes Agent and Nous services, MCP boundary selection, and host adapter guidance.", "developerName": "Gale", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/agent-portability-skills/AGENTS.md b/plugins/agent-portability-skills/AGENTS.md index cb2495e90..0e4a5d777 100644 --- a/plugins/agent-portability-skills/AGENTS.md +++ b/plugins/agent-portability-skills/AGENTS.md @@ -4,8 +4,13 @@ This file is the Agent Portability Skills child-repo override for work done from ## Scope -- `agent-portability-skills` is the canonical home for maintainer skills that help Socket keep agent skills, Codex plugin surfaces, MCP declarations, hooks, custom agents, and host-specific adapter guidance portable without pretending every host uses the same package model. -- The shipped skills cover skills-export and plugin-export repositories, cross-host protocol selection, ACP agent operation and development, Zed native/external/terminal workflows, Socket-to-Hermes compatibility, Hermes operator and extension-development workflows, Hermes messaging gateways, and Nous Research services. Future skills should extend that foundation into explicit Socket child-plugin portability, Xcode plug-in, OpenCode, Claude Code, and MCP compatibility work. +- `agent-portability-skills` owns cross-host protocol selection, agent host + adapters, ACP and A2A operation, Zed agent integration, Hermes operation and + extension development, and Nous Research service boundaries. +- Keep Codex plugin repository shape, manifests, marketplace consistency, and + plugin-maintenance automation in `agent-plugin-skills`. Portability may + compare a Codex plugin with another host package, but it does not author or + maintain the Codex package. - Root [`skills/`](./skills/) is the canonical authored and exported surface. - Treat [`.codex-plugin/plugin.json`](./.codex-plugin/plugin.json) as plugin packaging metadata only. - Use the Socket root maintainer docs for shared marketplace, release, and contribution workflow. Keep child maintainer notes only when they describe `agent-portability-skills`-specific behavior. @@ -13,10 +18,11 @@ This file is the Agent Portability Skills child-repo override for work done from ## Local Rules -- Before changing Codex plugin, skill, MCP, hooks, marketplace, ACP, or host adapter guidance, check the current official docs for the affected host. Keep this repo's skills focused on Socket policy and agent portability decisions rather than copying full upstream docs. -- Keep Codex plugin structure aligned with current OpenAI docs: only `plugin.json` belongs in `.codex-plugin/`, while `skills/`, `.app.json`, `.mcp.json`, `hooks/`, and `assets/` stay at the plugin root. The manifest points to bundled skills with `"skills": "./skills/"`; it may point to hooks explicitly, but Codex also checks `./hooks/hooks.json` by default. Installing or enabling a plugin does not automatically trust plugin-bundled hooks. -- Keep Codex-specific marketplace, plugin manifest, hook, app, and MCP behavior distinct from host-native surfaces such as Zed skills, Xcode plug-ins, OpenCode skills, Claude Code skills, and future adapter packages. -- Default user-facing install and update guidance to Git-backed marketplace sources. Do not recreate nested staged plugin directories, manual-first local install stories, `skills/install-plugin-to-socket`, or `skills/validate-plugin-install-surfaces`. +- Before changing ACP, A2A, MCP, Zed, Hermes, or host adapter guidance, check + the current official docs for the affected host. +- Keep transport and host compatibility decisions distinct from package + authoring. Hand Codex manifest, marketplace, bundled asset, and plugin + repository work to `agent-plugin-skills:maintain-agent-plugins`. - Resolve shared project dependencies only from GitHub repository URLs, package managers, package registries, or other real remote repositories that another contributor can fetch. Machine-local dependency paths are expressly prohibited in any project that is public or intended to be shared publicly. - When a skill contract changes, update nearby skill and maintainer docs in the same pass. diff --git a/plugins/agentdeck/.codex-plugin/plugin.json b/plugins/agentdeck/.codex-plugin/plugin.json index a5405da54..b02c36f18 100644 --- a/plugins/agentdeck/.codex-plugin/plugin.json +++ b/plugins/agentdeck/.codex-plugin/plugin.json @@ -7,7 +7,6 @@ "email": "mail@galewilliams.com", "url": "https://github.com/gaelic-ghost" }, - "hooks": "./hooks/hooks.json", "interface": { "displayName": "AgentDeck", "shortDescription": "Local Codex runtime helpers for hooks and thread utilities.", @@ -18,9 +17,14 @@ "Read", "Write" ], - "defaultPrompt": "Help me inspect local Codex runtime utility behavior.", + "defaultPrompt": [ + "Help me inspect local Codex runtime utility behavior." + ], "brandColor": "#A78BFA", "composerIcon": "./assets/agentdeck-icon.svg", "logo": "./assets/agentdeck-icon.svg" - } + }, + "license": "Apache-2.0", + "homepage": "https://github.com/gaelic-ghost/agentdeck", + "repository": "https://github.com/gaelic-ghost/agentdeck" } diff --git a/plugins/agentdeck/AGENTS.md b/plugins/agentdeck/AGENTS.md index 47ef00338..1c85fdf4b 100644 --- a/plugins/agentdeck/AGENTS.md +++ b/plugins/agentdeck/AGENTS.md @@ -7,6 +7,9 @@ Use this file for durable guidance inside the AgentDeck Socket plugin. - This plugin is for local Codex runtime utilities that are not specific to one programming language, Apple platform workflow, external app, or skill-repository maintainer task. - Keep utilities small, explicit, and independently removable. - Do not add broad convenience tooling here when the behavior clearly belongs in `agent-engineering-skills`, `agent-portability-skills`, a language-specific `*-skills` plugin, or an app integration plugin. +- Keep AgentDeck manifest and marketplace consistency in + `agent-plugin-skills`; AgentDeck owns the runtime behavior of the hooks it + ships. ## Current Utility diff --git a/plugins/android-dev-skills/.codex-plugin/plugin.json b/plugins/android-dev-skills/.codex-plugin/plugin.json index 8a59ceeea..1f6b3a3de 100644 --- a/plugins/android-dev-skills/.codex-plugin/plugin.json +++ b/plugins/android-dev-skills/.codex-plugin/plugin.json @@ -40,9 +40,7 @@ "defaultPrompt": [ "Choose the right Android project shape before we start implementing.", "Inspect this Android repository and decide which Gradle, AGP, module, and variant surfaces own the change.", - "Build this Kotlin Android UI or platform change while respecting existing Compose or XML conventions.", - "Maintain this Java Android or Kotlin/Java interop boundary without forcing a migration.", - "Run and explain the Android tests, lint checks, or release-readiness workflow for this project." + "Build this Kotlin Android UI or platform change while respecting existing Compose or XML conventions." ], "brandColor": "#3DDC84", "composerIcon": "./assets/android-dev-icon.svg", diff --git a/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json b/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json index 3bbec7de5..e6e6d487a 100644 --- a/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json +++ b/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json @@ -41,10 +41,7 @@ "defaultPrompt": [ "Prepare a Compressor batch and verify its exported delivery artifacts without overwriting source media.", "Prepare this Final Cut Pro library or project safely, then verify a defined share or Compressor handoff.", - "Prepare this Motion project or Final Cut Pro template while preserving the editable source.", - "Set up or repair this Logic Pro session, then bounce a defined master or stem delivery safely.", - "Prepare this MainStage concert for rehearsal with explicit audio, MIDI, patch, and live-performance safety checks.", - "Prepare or export this GarageBand project while preserving recordings, tracks, and source media." + "Prepare this Motion project or Final Cut Pro template while preserving the editable source." ], "brandColor": "#C18CFF", "composerIcon": "./assets/creator-studio-icon.png", diff --git a/plugins/apple-dev-skills/.codex-plugin/plugin.json b/plugins/apple-dev-skills/.codex-plugin/plugin.json index 05f4603b8..a98846a0e 100644 --- a/plugins/apple-dev-skills/.codex-plugin/plugin.json +++ b/plugins/apple-dev-skills/.codex-plugin/plugin.json @@ -132,54 +132,9 @@ ], "websiteURL": "https://github.com/gaelic-ghost/apple-dev-skills", "defaultPrompt": [ - "Diagnose a macOS privacy permission failure from the protected operation and responsible executable, using the documented public status or request path without treating reset, entitlements, or administrator access as a grant.", - "Persist access to a user-selected macOS file or directory across relaunches with the narrowest sandbox entitlement and a complete balanced security-scoped bookmark lifecycle.", - "Trace an Apple entitlement from desired behavior through tracked project source, developer-account and profile authorization, final signed main and nested code, and runtime policy or consent.", - "Choose whether this macOS-hosted task belongs on the host, in an OCI container, an Apple container machine, a full Linux or macOS VM, or a physical Mac.", - "Design or diagnose a custom macOS or Linux VM host with Apple's Virtualization framework, explicit devices, lifecycle, validation, and save or restore boundaries.", - "Prepare and validate a persistent Linux development guest with explicit distro, services, resources, host integrations, nested-virtualization gates, and reset strategy.", - "Prepare a clean macOS development guest from a compatible restore image with explicit identity, artifact lifecycle, checkpoints, integrations, and physical-Mac fidelity gaps.", - "Repair or modernize Apple media and audio code across AVFAudio sessions, AVAudioEngine graphs, AVFoundation pipelines, Core Media timing, and legacy Core Audio surfaces.", - "Design a remote-first tvOS experience with SwiftUI focus behavior, Large Text, hardware capability gates, and a TVMLKit migration path.", - "Choose AVKit's tvOS system player before custom controls, then keep remote commands, Now Playing state, and playback validation under one explicit owner.", - "Build or repair Core Image processing, RAW, color, HDR, filter, custom-kernel, and rendering pipelines using current Apple documentation.", - "Decode, encode, inspect, thumbnail, preserve metadata, or bridge Apple image representations across Image I/O, Core Graphics, AppKit, UIKit, Core Image, and Core Video.", - "Implement or repair Apple Vision text, barcode, face, pose, segmentation, tracking, feature-print, coordinate, and live-frame analysis using current documentation.", - "Integrate, evaluate, profile, or repair custom Core ML image classification, object detection, and segmentation through Apple Vision.", - "Configure or repair AVFoundation camera discovery, controls, rotation, photo capture, synchronized outputs, depth, calibration, mattes, and computational capture using capability evidence.", - "Implement or repair ARKit world tracking, planes, ray casting, scene depth, LiDAR reconstruction, meshes, maps, relocalization, and visionOS provider lifecycles.", - "Implement or repair ARKit TrueDepth face geometry, blend shapes, eye transforms, body anchors, skeletons, scale, privacy, and Face ID boundaries.", - "Choose an Apple app extension point and design its target, isolated process, activation, entitlements, app-group data flow, privacy, signing, distribution, and validation boundaries.", - "Build or validate a macOS MailKit extension for content blocking, message actions, compose sessions, or message security without exposing private mail data.", - "Choose File Provider for remote storage synchronization or Finder Sync for bounded Finder badges, menus, and monitored-folder visibility without treating Finder Sync as a sync engine.", - "Implement or repair VideoToolbox compression and decompression, Core Video pixel buffers and Metal interop, compressed samples, hardware capability, color, HDR, and codec performance.", - "Implement or repair PhotosUI media selection and PhotoKit authorization, assets, resources, iCloud requests, library changes, saves, albums, and nondestructive editing.", - "Explore the relevant Apple docs first, then explain the right SwiftUI, AppKit, or Xcode path for this repository.", - "Audit or repair SwiftUI code into self-contained declarative components with local reactive state and no external ViewModel shape.", - "Choose, customize, color, animate, validate, or integrate SF Symbols using current Apple docs and the local SF Symbols app when needed.", - "Design, implement, repair, or validate SwiftUI animation, transitions, symbol effects, phase/keyframe motion, and reduce-motion behavior.", - "Decide when Core Animation is justified, then repair CALayer trees, CAAnimation timing, transactions, model/presentation behavior, and layer-backed framework bridges.", - "Choose Apple system typography, Dynamic Type, San Francisco or New York system designs, custom font integration, and font licensing boundaries.", - "Set up Xcode coding intelligence, Xcode-hosted agents, external-agent MCP access through xcrun mcpbridge, and command/tool permission boundaries.", - "Plan or implement Xcode String Catalog localization with source extraction, translator context, plural and device variants, XLIFF review, and locale-aware UI validation.", - "Use Xcode Device Hub to inspect simulated or physical devices, vary a simulator environment, capture evidence, and hand off diagnostics safely.", - "Debug a running Apple app through Xcode's active LLDB session and verify whether the selected Xcode toolchain can start standalone lldb-mcp.", - "Design and validate native macOS window scenes, chrome, drag regions, placement, restoration, and utility-window behavior.", - "Add focused Apple unified logging and OSSignposter evidence with privacy-aware categories and clear Instruments handoffs.", - "Audit an existing Xcode app project before migrating to XcodeGen, or modernize an old project.yml to the current synced-folder, xcconfig, entitlements, and asset-catalog baseline.", - "Help me design and preview an Apple app icon using Icon Composer, local Mac artwork tools, ictool exports, and careful Computer Use guidance for the GUI.", - "Choose the right Safari extension, Safari Web Inspector, SafariServices, messaging, content blocker, or automation fallback path for this Mac app.", - "Inspect, debug, and validate a scoped local website in Safari Technology Preview through Safari MCP, keeping browser evidence and authorized interactions explicit.", - "Design a DeviceCheck or App Attest flow for this Apple app, keeping DCDevice, DCAppAttestService, server challenges, entitlements, rollout, and backend validation handoffs explicit.", - "Plan safe Apple Developer provisioning or CloudKit automation using official App Store Connect REST APIs, Xcode-aware discovery, cktool, or CKTool JS; keep credentials local, show a dry run first, and route portal-only configuration to the Apple Developer Portal.", - "Add or diagnose a generated Swift OpenAPI client in this Apple app using OpenAPIURLSession and current Apple docs.", - "Help me build, test, or debug this Swift or Xcode project using the repo's Apple workflows.", - "Create or align one product workspace with Apps, Packages, and Services, or add a component without converting the repository.", - "Design or diagnose a SwiftPM build tool plugin, command plugin, macro target, package trait matrix, generated-source flow, or plugin permission across Swiftly and Xcode toolchains.", - "Design, migrate, test, or integrate SwiftData persistence using the dedicated SwiftData workflow and current Apple documentation.", - "Add, configure, present, test, or troubleshoot TipKit inline tips and tooltip popovers using current Apple documentation.", - "Write or review DocC symbol comments, articles, extension files, and landing-page structure for this Swift repository.", - "Sync the Apple project guidance in this repo without hand-editing Xcode project files, using Productivity Skills when repo-maintenance files must be installed or refreshed." + "Diagnose a macOS privacy permission failure from the protected operation and responsible executable, using the documented publi…", + "Persist access to a user-selected macOS file or directory across relaunches with the narrowest sandbox entitlement and a comple…", + "Trace an Apple entitlement from desired behavior through tracked project source, developer-account and profile authorization, f…" ], "brandColor": "#0A84FF", "composerIcon": "./assets/xcode-hammer-icon.png", diff --git a/plugins/apple-dev-skills/AGENTS.md b/plugins/apple-dev-skills/AGENTS.md index 67329c4e1..6d4cdcbd2 100644 --- a/plugins/apple-dev-skills/AGENTS.md +++ b/plugins/apple-dev-skills/AGENTS.md @@ -39,10 +39,10 @@ This file is the Apple Dev Skills child-repo override for work done from `socket Run from the Socket repository root so the shared maintainer environment and cache policy apply: -```bash +```text just repo-validate -uv run python -B -m pytest plugins/apple-dev-skills/tests \ - -o cache_dir=.codex/.cache/pytest +just test ``` -Use the docs validator when README, AGENTS, ROADMAP, active skill inventory, or maintainer docs change. Use pytest when skill behavior, scripts, validation helpers, or tested contracts change. +Use `just docs-check` when repository documentation changes. Keep all +integration/E2E coverage in the Socket root `tests/` directory. diff --git a/plugins/apple-dev-skills/README.md b/plugins/apple-dev-skills/README.md index ec5a4ba36..8c9a3c88d 100644 --- a/plugins/apple-dev-skills/README.md +++ b/plugins/apple-dev-skills/README.md @@ -117,16 +117,15 @@ When installed as a Codex plugin, Apple Dev Skills declares two Xcode-selected M ## Development -Treat root [`skills/`](./skills/) as the canonical authored surface. Keep shared reusable assets in [`shared/`](./shared/) and tests in [`tests/`](./tests/). +Treat root [`skills/`](./skills/) as the canonical authored surface. Keep shared reusable assets in [`shared/`](./shared/) and integration/E2E tests only in Socket root [`tests/`](../../tests/). Use [`CONTRIBUTING.md`](./CONTRIBUTING.md) for maintainer workflow details, and use [AGENTS.md](./AGENTS.md) for agent-facing repo rules. Run the repository test suite for skill and metadata changes: -```bash +```text just repo-validate -uv run python -B -m pytest plugins/apple-dev-skills/tests \ - -o cache_dir=.codex/.cache/pytest +just test ``` ## Repo Structure diff --git a/plugins/apple-dev-skills/ROADMAP.md b/plugins/apple-dev-skills/ROADMAP.md index 4099021da..c60a3a2ad 100644 --- a/plugins/apple-dev-skills/ROADMAP.md +++ b/plugins/apple-dev-skills/ROADMAP.md @@ -259,7 +259,7 @@ Completed ### Tickets - [x] Keep the personal-scope `agent-portability-skills` install current for work on this repository without reintroducing a nested packaged plugin tree here. -- [x] Use `maintain-plugin-repo` and `sync-skills-repo-guidance` only for the plugin-shape and export-surface checks that still belong in that repo's standards layer. +- [x] Use `agent-plugin-skills:maintain-agent-plugins` for plugin-shape and export-surface checks while keeping Apple workflow behavior in this plugin. - [x] Confirm that repo docs already align with the current `repository-skills` documentation standards before treating docs wording drift as a Milestone 28 blocker. - [x] Align plugin metadata, export surfaces, ignores, and maintainer guidance with the current shared plugin standards without flattening repo-specific policy. - [x] Remove stale nested packaging language while keeping the adjacent standards repo as the maintainer-only setup. @@ -712,7 +712,7 @@ Completed - [x] Slice 3: add the reference files for session policy, engine graph repair, media pipelines, Core Media timing, Core Audio modernization, anti-patterns, and validation handoffs. - [x] Slice 4: update README active skill inventory, plugin metadata, repo validator expectations, and any router or handoff notes from existing Xcode, SwiftUI, AppKit, accessibility, and docs-exploration skills. - [x] Slice 5: add targeted tests for skill metadata, docs-gate language, repair anti-pattern coverage, deprecated API modernization guidance, Xcode handoffs, and active inventory preservation. -- [x] Slice 6: run the docs validator, targeted pytest files, full `uv run pytest`, and root Socket metadata validation before any release or marketplace refresh. +- [x] Slice 6: run canonical repository validation and the single Socket root integration/E2E test before any release or marketplace refresh. ### Exit Criteria diff --git a/plugins/apple-dev-skills/docs/maintainers/reality-audit.md b/plugins/apple-dev-skills/docs/maintainers/reality-audit.md index e771d47d9..fcaa42cc8 100644 --- a/plugins/apple-dev-skills/docs/maintainers/reality-audit.md +++ b/plugins/apple-dev-skills/docs/maintainers/reality-audit.md @@ -57,7 +57,7 @@ Deprecated compatibility skills that remain on disk do not count as part of the Use this flow when validating the current top-level export surface and local discovery mirrors instead of checking a nested packaged plugin tree. 1. From the Socket root, run `just repo-validate`. -2. Run `uv run python -B -m pytest plugins/apple-dev-skills/tests -o cache_dir=.codex/.cache/pytest`. +2. Run `just test` from the Socket root. 3. Confirm `.agents/skills` still points at `../skills`. 4. Confirm root docs, skill docs, and the roadmap all describe top-level `skills/` as the active export surface and do not mention a nested packaged plugin tree or removed installer workflows. 5. If discovery or docs drift remains, update the docs to match the tested top-level export surface instead of preserving stale packaging language. diff --git a/plugins/cloud-deployment-skills/.codex-plugin/plugin.json b/plugins/cloud-deployment-skills/.codex-plugin/plugin.json index 9f3b7fcd4..30f63a0a5 100644 --- a/plugins/cloud-deployment-skills/.codex-plugin/plugin.json +++ b/plugins/cloud-deployment-skills/.codex-plugin/plugin.json @@ -43,9 +43,9 @@ ], "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/cloud-deployment-skills", "defaultPrompt": [ - "Choose the right cloud deployment path and route AWS or Azure work through the applicable official provider plugin when appropriate.", - "Decide whether this deployment should use an official provider plugin, provider CLI, MCP server, framework-owned deploy workflow, or a Socket-owned provider skill.", - "Set up or audit a Dockerized backend release path that builds in clean GitHub Actions, deploys only a published release manifest digest after production approval, verifies health, and rolls back by exact digest." + "Choose the right cloud deployment path and route AWS or Azure work through the applicable official provider plugin when appropr…", + "Decide whether this deployment should use an official provider plugin, provider CLI, MCP server, framework-owned deploy workflo…", + "Set up or audit a Dockerized backend release path that builds in clean GitHub Actions, deploys only a published release manifes…" ], "brandColor": "#22D3EE", "composerIcon": "./assets/cloud-deployment-icon.svg", diff --git a/plugins/cloud-inference-skills/.codex-plugin/plugin.json b/plugins/cloud-inference-skills/.codex-plugin/plugin.json index 968cf84d9..f787394f5 100644 --- a/plugins/cloud-inference-skills/.codex-plugin/plugin.json +++ b/plugins/cloud-inference-skills/.codex-plugin/plugin.json @@ -40,7 +40,7 @@ "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/cloud-inference-skills", "defaultPrompt": [ "Choose the right cloud GPU inference or training path for this model and budget.", - "Route this Runpod, Hugging Face, AWS, Vast.ai, or CoreWeave inference task through the safest available official tool, MCP server, CLI, or Socket guidance.", + "Route this Runpod, Hugging Face, AWS, Vast.ai, or CoreWeave inference task through the safest available official tool, MCP serv…", "Help me decide between quick managed inference, cheap flexible GPU infrastructure, and providers I already know." ], "brandColor": "#D946EF", diff --git a/plugins/cybersecurity-skills/.codex-plugin/plugin.json b/plugins/cybersecurity-skills/.codex-plugin/plugin.json index 239f33dd3..0f69398aa 100644 --- a/plugins/cybersecurity-skills/.codex-plugin/plugin.json +++ b/plugins/cybersecurity-skills/.codex-plugin/plugin.json @@ -33,9 +33,8 @@ "longDescription": "Guide agents from an ambiguous suspicious artifact, host behavior, vulnerability report, or incident to preserved evidence, an appropriate isolation decision, a verified disposable Linux or macOS analysis lab, validated findings, proportionate containment, and an understandable defensive explanation. Includes malware analysis, macOS defense, authorized testing, incident response, hunting, detection content, evidence export, teardown verification, and explicit handoffs to specialist Socket plugins.", "defaultPrompt": [ "Help me safely determine whether this suspicious artifact is dangerous.", - "Prepare and preflight a disposable Linux or macOS analysis lab with no ambient host authority, monitored networking, narrow evidence export, and verified teardown.", - "Investigate this Mac without destroying evidence or weakening protections.", - "Validate this vulnerability within an explicitly authorized test scope." + "Prepare and preflight a disposable Linux or macOS analysis lab with no ambient host authority, monitored networking, narrow evi…", + "Investigate this Mac without destroying evidence or weakening protections." ], "developerName": "Gale", "category": "Developer Tools", diff --git a/plugins/dotnet-skills/.codex-plugin/plugin.json b/plugins/dotnet-skills/.codex-plugin/plugin.json index c3a989ad3..d7ced344d 100644 --- a/plugins/dotnet-skills/.codex-plugin/plugin.json +++ b/plugins/dotnet-skills/.codex-plugin/plugin.json @@ -37,15 +37,7 @@ "defaultPrompt": [ "Choose the right .NET project shape before we start implementing.", "Bootstrap a .NET solution while treating F# and C# as equal options.", - "Build this F# project idiomatically instead of translating from C#.", - "Build this C# project idiomatically while respecting existing repo conventions.", - "Run and explain the .NET testing workflow for this solution.", - "Validate this .NET package surface without publishing it.", - "Diagnose why this .NET restore, build, test, or pack command failed.", - "Design the F# and C# boundary in this mixed .NET solution.", - "Choose the right F# web framework before building this ASP.NET Core application.", - "Build this Giraffe, Falco, or Oxpecker app idiomatically and test its endpoint contract.", - "Align this .NET repo's CI, upgrade, formatting, and analyzer workflow." + "Build this F# project idiomatically instead of translating from C#." ], "brandColor": "#512BD4", "composerIcon": "./assets/sharp-icon.jpg", diff --git a/plugins/game-dev-skills/.codex-plugin/plugin.json b/plugins/game-dev-skills/.codex-plugin/plugin.json index b020c53d2..2747e2ca1 100644 --- a/plugins/game-dev-skills/.codex-plugin/plugin.json +++ b/plugins/game-dev-skills/.codex-plugin/plugin.json @@ -50,14 +50,7 @@ "defaultPrompt": [ "Choose the right Apple game stack before we start implementing.", "Design, repair, or validate this native Apple Metal renderer with an explicit Metal 3 or Metal 4 capability path.", - "Choose GPTK 3 or GPTK 4 for this Windows game evaluation or Apple-platform port.", - "Integrate MetalFX, GPU asset streaming, or an experimental neural rendering pass with a measured fallback.", - "Build or repair this SpriteKit scene, game loop, physics, or resource flow.", - "Build or repair this SceneKit scene, node graph, camera, lighting, or asset flow.", - "Design or repair this GameplayKit entity, component, state-machine, pathfinding, or agent system.", - "Wire game controller, keyboard, mouse, or virtual-controller input for this Apple game.", - "Design or debug Core Haptics feedback for a game while keeping device validation honest.", - "Profile this Apple game for frame pacing, stutter, CPU/GPU overlap, memory pressure, or trace evidence without turning profiling into shader architecture." + "Choose GPTK 3 or GPTK 4 for this Windows game evaluation or Apple-platform port." ], "brandColor": "#28D7FF", "composerIcon": "./assets/game-dev-skills-icon.svg", diff --git a/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json b/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json index 4ce8a20a4..8c6ac63e9 100644 --- a/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json +++ b/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json @@ -21,11 +21,13 @@ "defaultPrompt": [ "Choose the correct chat, calling, or collaboration platform integration before implementation.", "Plan a secure, idempotent webhook and event lifecycle for this messaging app.", - "Build a Discord, Telegram, Slack, Teams, WhatsApp Business, or SMS/MMS/RCS integration with the platform-specific policy boundary explicit.", - "Choose the right Apple path for an iMessage collaboration feature, Communication Notification or Notification Service Extension, Push to Talk channel, VoIP/SIP calling app, documented iOS/iPadOS default role, or app-owned macOS client." + "Build a Discord, Telegram, Slack, Teams, WhatsApp Business, or SMS/MMS/RCS integration with the platform-specific policy bounda…" ], "brandColor": "#D946EF", "composerIcon": "./assets/messaging-collaboration-icon.svg", "logo": "./assets/messaging-collaboration-icon.svg" - } + }, + "license": "Apache-2.0", + "homepage": "https://github.com/gaelic-ghost/messaging-collaboration-skills", + "repository": "https://github.com/gaelic-ghost/messaging-collaboration-skills" } diff --git a/plugins/model-lab-skills/.codex-plugin/plugin.json b/plugins/model-lab-skills/.codex-plugin/plugin.json index 77d80bae3..c51b6b6f7 100644 --- a/plugins/model-lab-skills/.codex-plugin/plugin.json +++ b/plugins/model-lab-skills/.codex-plugin/plugin.json @@ -3,7 +3,9 @@ "version": "10.0.2", "description": "Reproducible model training, evaluation, intervention, and runtime research workflows.", "author": { - "name": "Gale" + "name": "Gale", + "email": "mail@galewilliams.com", + "url": "https://github.com/gaelic-ghost" }, "skills": "./skills/", "interface": { @@ -25,5 +27,8 @@ "brandColor": "#B56CFF", "composerIcon": "./assets/model-lab.svg", "logo": "./assets/model-lab.svg" - } + }, + "license": "Apache-2.0", + "homepage": "https://github.com/gaelic-ghost/model-lab-skills", + "repository": "https://github.com/gaelic-ghost/model-lab-skills" } diff --git a/plugins/network-protocol-skills/.codex-plugin/plugin.json b/plugins/network-protocol-skills/.codex-plugin/plugin.json index 88e645bab..030fa0055 100644 --- a/plugins/network-protocol-skills/.codex-plugin/plugin.json +++ b/plugins/network-protocol-skills/.codex-plugin/plugin.json @@ -45,9 +45,7 @@ "defaultPrompt": [ "Choose the right network transport for this feature before implementation.", "Plan an HTTP/3 or QUIC change and identify the stack-specific implementation handoff.", - "Assess whether Media over QUIC fits this real-time media workflow and what draft state must be checked.", - "Plan or diagnose a WebRTC feature, including signaling, ICE, DTLS, SRTP, data channels, TURN, and browser/runtime constraints.", - "Diagnose this protocol, proxy, CDN, browser, UDP, TLS, ALPN, Alt-Svc, or NAT traversal failure with concrete evidence." + "Assess whether Media over QUIC fits this real-time media workflow and what draft state must be checked." ], "brandColor": "#22D3EE", "composerIcon": "./assets/network-protocol-icon.svg", diff --git a/plugins/professional-skills/AGENTS.md b/plugins/professional-skills/AGENTS.md index a8db6219c..2ee5f7f98 100644 --- a/plugins/professional-skills/AGENTS.md +++ b/plugins/professional-skills/AGENTS.md @@ -17,8 +17,7 @@ documentation rules. Run from the Socket repository root so the shared maintainer environment and cache policy apply: -```bash -uv run python -B -m pytest \ - plugins/professional-skills/skills/dice-job-search-workflow/tests \ - -o cache_dir=.codex/.cache/pytest +```text +just repo-validate +just test ``` diff --git a/plugins/python-skills/.codex-plugin/plugin.json b/plugins/python-skills/.codex-plugin/plugin.json index 91f25d5fe..60b8f4593 100644 --- a/plugins/python-skills/.codex-plugin/plugin.json +++ b/plugins/python-skills/.codex-plugin/plugin.json @@ -38,18 +38,9 @@ ], "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/python-skills", "defaultPrompt": [ - "Bootstrap a new uv FastAPI service with typed settings and a committed .env baseline.", "Choose the right Python project shape before we start implementing.", - "Build this Python project while respecting its uv, package layout, tests, Ruff, and mypy conventions.", - "Diagnose why this Python uv, import, pytest, Ruff, mypy, FastAPI, FastMCP, or package command failed.", - "Validate this Python package surface without publishing it.", - "Align this Python repo's uv, Ruff, mypy, pytest, dependency-group, and validation workflow.", - "Align this Python repo's CI with local uv validation commands.", - "Plan this Python dependency, framework, lockfile, or tooling upgrade with staged validation.", - "Set up, run, or diagnose pytest for this uv workspace with explicit package targeting.", - "Maintain this FastAPI service's routes, typed settings, lifespan, OpenAPI contract, and tests.", - "Maintain this FastMCP server's curated tools, resources, prompts, transport, authorization, and client tests.", - "Add a FastMCP surface to an existing FastAPI project and choose the right combined-app pattern." + "Build this existing Python project while respecting its uv, package layout, tests, Ruff, and mypy conventions.", + "Diagnose this existing Python project's uv, import, pytest, Ruff, mypy, or package failure." ], "brandColor": "#0F766E", "composerIcon": "./assets/python-snake-icon.png", diff --git a/plugins/reverse-engineering-skills/.codex-plugin/plugin.json b/plugins/reverse-engineering-skills/.codex-plugin/plugin.json index cff43425c..5571eff40 100644 --- a/plugins/reverse-engineering-skills/.codex-plugin/plugin.json +++ b/plugins/reverse-engineering-skills/.codex-plugin/plugin.json @@ -38,7 +38,9 @@ "displayName": "Reverse Engineering Skills", "shortDescription": "Binary inspection and decompilation workflow skills.", "longDescription": "Guidance for reverse engineering workflows across artifact triage, preservation, exact-build comparison, reproducible evidence, macOS TCC, sandbox, entitlement, Gatekeeper, XProtect and system-policy research, Apple Mach-O, Swift and Objective-C runtime metadata, symbols, signing, Apple Silicon, dyld caches, dynamic analysis, kernel and firmware research, Cutter and Rizin, Malimite, Ghidra, Hopper, Unity, .NET assemblies, IL2CPP artifacts, and generated decompiler output.", - "defaultPrompt": "Use Reverse Engineering Skills when the task centers on compiled artifacts, decompiled output, disassembly, symbols, crash logs, binary metadata, or exact-build macOS security-control internals. Preserve original inputs, identify exact artifacts and builds, distinguish public contracts from private evidence and runtime observations, and record which tool produced each claim.", + "defaultPrompt": [ + "Use Reverse Engineering Skills when the task centers on compiled artifacts, decompiled output, disassembly, symbols, crash logs…" + ], "developerName": "Gale", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/server-side-jvm/.codex-plugin/plugin.json b/plugins/server-side-jvm/.codex-plugin/plugin.json index 89b5daf0c..9ef16323c 100644 --- a/plugins/server-side-jvm/.codex-plugin/plugin.json +++ b/plugins/server-side-jvm/.codex-plugin/plugin.json @@ -40,9 +40,7 @@ "defaultPrompt": [ "Choose the right server-side JVM project shape before we start implementing.", "Inspect this JVM repository and decide whether Gradle, Maven, or SBT owns the build workflow.", - "Build this Java service change idiomatically while respecting existing repo conventions.", - "Build this Scala service change with functional design treated as first-class.", - "Run and explain the JVM testing workflow for this service." + "Build this Java service change idiomatically while respecting existing repo conventions." ], "brandColor": "#7C3AED", "composerIcon": "./assets/java-coffee-cup-icon.png", diff --git a/plugins/server-side-swift/.codex-plugin/plugin.json b/plugins/server-side-swift/.codex-plugin/plugin.json index 857b0c0d4..73dcfe4b1 100644 --- a/plugins/server-side-swift/.codex-plugin/plugin.json +++ b/plugins/server-side-swift/.codex-plugin/plugin.json @@ -68,41 +68,9 @@ ], "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/server-side-swift", "defaultPrompt": [ - "Add a Hummingbird component to the existing product workspace with hb, native Homebrew PostgreSQL, and GitHub-only Linux builds and deployments.", - "Add a Vapor component to the existing product workspace with Vapor Toolbox, native Homebrew PostgreSQL, and GitHub-only Linux builds and deployments.", - "Align an existing Services/ component through the root workspace guidance entrypoint.", - "Add an AWS integration to this Swift service using Soto, one shared AWSClient, and explicit single-owner shutdown.", - "Plan a Leaf-rendered Vapor website with typed page contexts, layouts, partial components, escaping, accessibility, public assets, and rendering-focused tests.", - "Diagnose a Leaf rendering, layout, custom-tag, escaping, template-cache, public-asset, or rendered-HTML test failure in this Vapor service.", - "Create a Vapor service plan using current Vapor CLI, Vapor 5 alpha posture, and SwiftPM workflows.", - "Create a Hummingbird service plan using the official hb CLI, current Server or Lambda prompts, current Hummingbird docs, and SwiftPM workflows.", - "Choose a Vapor-aligned package from the Vapor or Vapor Community ecosystem for this service need.", - "Choose a Hummingbird-aligned package from the Hummingbird ecosystem for this service need.", - "Add Swift OpenAPI Generator to a server-side Swift package and wire the generated API to Vapor, Hummingbird, or a Hummingbird Lambda adapter.", - "Decide whether this Swift service boundary should use OpenAPI, JSON-RPC, gRPC, MCP-style tools, or ordinary HTTP routes.", - "Plan direct SwiftNIO work for event loops, channels, handlers, ByteBuffer framing, and back-pressure.", - "Diagnose a SwiftNIO event-loop, channel pipeline, protocol framing, or nonblocking-I/O failure.", - "Add observability to this Swift service with logging, metrics, tracing, correlation, and privacy-safe diagnostics.", - "Diagnose missing logs, noisy metrics, broken trace propagation, or unclear production signals in this Swift service.", - "Plan server-side authentication and authorization for a Vapor or Hummingbird service.", - "Diagnose token, session, JWT, password, middleware, or permission failures in this Swift service.", - "Plan an app-sync contract with cursors, idempotent writes, conflict handling, tombstones, and background job handoffs.", - "Diagnose duplicated writes, stale cursors, missed changes, or sync conflicts in this Swift service.", - "Prepare a Dockerfile as GitHub Actions cloud-build input without adding a local container path.", - "Diagnose why this Swift service Docker image fails to build, start, or bind its port.", - "Prepare a GitHub Actions-only Fly.io adapter that deploys the exact prebuilt image with a scoped token, protected environment, health verification, and rollback.", - "Diagnose why this Swift service fails to deploy or pass health checks on Fly.io.", - "Inspect this Vapor app and explain its routes, configuration, and run commands.", - "Assess whether this Vapor 4 app is ready for a Vapor 5 alpha spike without treating alpha APIs as stable.", - "Inspect this Hummingbird app and explain its Server or Lambda shape, router, middleware, request contexts, configuration, and run commands.", - "Inspect this OpenAPI-backed Swift service and explain its contract, generated symbols, transport, and validation commands.", - "Add a Vapor route with tests while keeping domain logic outside handlers.", - "Add a Hummingbird route with tests while keeping domain logic outside handlers.", - "Plan a server-side Swift persistence change with models, migrations, queries, and tests.", - "Set up Fluent models and migrations and explain the migration commands.", - "Add database-backed behavior to a Hummingbird service without copying Vapor app structure.", - "Diagnose why this Vapor build, run, migrate, or local server command failed.", - "Diagnose why this Hummingbird build, hb command, run, route, database, or local server command failed." + "Add a Hummingbird component to the existing product workspace with hb, native Homebrew PostgreSQL, and GitHub-only Linux builds…", + "Add a Vapor component to the existing product workspace with Vapor Toolbox, native Homebrew PostgreSQL, and GitHub-only Linux b…", + "Align an existing Services/ component through the root workspace guidance entrypoint." ], "brandColor": "#0EA5E9", "composerIcon": "./assets/swift-bird-icon.png", diff --git a/plugins/swift-lang/.codex-plugin/plugin.json b/plugins/swift-lang/.codex-plugin/plugin.json index e7be04c41..f3a66b2a0 100644 --- a/plugins/swift-lang/.codex-plugin/plugin.json +++ b/plugins/swift-lang/.codex-plugin/plugin.json @@ -50,13 +50,8 @@ "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/swift-lang", "defaultPrompt": [ "Review this Swift API for Swifty call-site ergonomics, naming, result shapes, errors, and consistency.", - "Design or repair this Swift error handling surface using throws, typed throws, Result, Optional, domain errors, and useful diagnostics.", - "Refactor this Swift data flow into a clear functional pipeline with map, flatMap, compactMap, filter, reduce, Optional, Result, throws, async throws, or AsyncSequence where appropriate.", - "Set up or repair SwiftFormat and SwiftLint policy for this Swift repository.", - "Split and reorganize these Swift source files by concern while preserving access control and validation.", - "Modernize and clean up this existing Swift implementation with a complete formatting, API, pipeline, source organization, concurrency, testability, and validation pass.", - "Choose the correct SwiftSyntax, compiler, SourceKit, index, or SourceKit-LSP surface for this tooling task and distinguish Swiftly from Xcode toolchain ownership.", - "Build or diagnose this Swift language tool using source-accurate syntax, compiler artifacts, semantic queries, project indexes, or LSP as appropriate." + "Design or repair this Swift error handling surface using throws, typed throws, Result, Optional, domain errors, and useful diag…", + "Refactor this Swift data flow into a clear functional pipeline with map, flatMap, compactMap, filter, reduce, Optional, Result,…" ], "brandColor": "#F05138", "composerIcon": "./assets/swift-lang-icon.svg", diff --git a/plugins/web-dev-skills/.codex-plugin/plugin.json b/plugins/web-dev-skills/.codex-plugin/plugin.json index 5e0e1a5f3..d51a23564 100644 --- a/plugins/web-dev-skills/.codex-plugin/plugin.json +++ b/plugins/web-dev-skills/.codex-plugin/plugin.json @@ -33,7 +33,7 @@ ], "websiteURL": "https://github.com/gaelic-ghost/socket/tree/main/plugins/web-dev-skills", "defaultPrompt": [ - "Work through an Expo SDK 56+ inline native module change with current Expo docs, live project inspection, type generation, and native-boundary validation." + "Work through an Expo SDK 56+ inline native module change with current Expo docs, live project inspection, type generation, and…" ], "brandColor": "#38BDF8", "composerIcon": "./assets/globe-icon.png", diff --git a/scripts/agent-plugins/agent-plugins.fsx b/scripts/agent-plugins/agent-plugins.fsx new file mode 100644 index 000000000..68161d86a --- /dev/null +++ b/scripts/agent-plugins/agent-plugins.fsx @@ -0,0 +1,251 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text +open System.Text.Encodings.Web +open System.Text.Json +open System.Text.Json.Nodes +open System.Text.RegularExpressions + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..")) +let fail message = raise (InvalidOperationException(message)) +let jsonOptions = JsonSerializerOptions(WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let serialize (node: JsonNode) = node.ToJsonString(jsonOptions) + "\n" + +let normalizePrompt (value: string) = + let trimmed = value.Trim() + if trimmed.Length <= 128 then trimmed else trimmed.Substring(0, 127).TrimEnd() + "…" + +let requiredString (owner: string) (name: string) (node: JsonObject) = + match node[name] with + | null -> fail $"{owner} is missing required field {name}." + | value -> + let text = value.GetValue<string>() + if String.IsNullOrWhiteSpace(text) then fail $"{owner} has an empty {name}." + text + +let objectAt (owner: string) (name: string) (node: JsonObject) = + match node[name] with + | :? JsonObject as value -> value + | _ -> fail $"{owner} is missing object {name}." + +let pluginRoots = + let plugins = Path.Combine(root, "plugins") + if Directory.Exists(plugins) then + Directory.GetDirectories(plugins) + |> Array.filter (fun directory -> File.Exists(Path.Combine(directory, ".codex-plugin", "plugin.json"))) + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + elif File.Exists(Path.Combine(root, ".codex-plugin", "plugin.json")) then [| root |] + else fail $"No plugin manifests were found under {root}." + +let manifestPath (pluginRoot: string) = Path.Combine(pluginRoot, ".codex-plugin", "plugin.json") +let loadObject (path: string) = JsonNode.Parse(File.ReadAllText(path)).AsObject() + +let normalizeManifest (pluginRoot: string) = + let path = manifestPath pluginRoot + let manifest = loadObject path + let directoryName = Path.GetFileName(pluginRoot) + let name = requiredString path "name" manifest + if name <> directoryName then fail $"Plugin manifest name {name} does not match directory {directoryName}." + + let author = + match manifest["author"] with + | :? JsonObject as value -> value + | _ -> let value = JsonObject() in manifest["author"] <- value; value + author["name"] <- "Gale" + author["email"] <- "mail@galewilliams.com" + author["url"] <- "https://github.com/gaelic-ghost" + manifest["license"] <- "Apache-2.0" + + if manifest["homepage"] = null then + manifest["homepage"] <- $"https://github.com/gaelic-ghost/{name}" + if manifest["repository"] = null then + manifest["repository"] <- $"https://github.com/gaelic-ghost/{name}" + + match manifest["hooks"] with + | null -> () + | value when value.GetValue<string>() = "./hooks/hooks.json" -> manifest.Remove("hooks") |> ignore + | _ -> () + + match manifest["interface"] with + | :? JsonObject as interfaceNode -> + match interfaceNode["defaultPrompt"] with + | :? JsonValue as prompt -> + let prompts = JsonArray() + prompts.Add(normalizePrompt (prompt.GetValue<string>())) + interfaceNode["defaultPrompt"] <- prompts + | :? JsonArray as prompts -> + while prompts.Count > 3 do prompts.RemoveAt(prompts.Count - 1) + for index in 0 .. prompts.Count - 1 do + prompts[index] <- normalizePrompt (prompts[index].GetValue<string>()) + | _ -> () + | _ -> () + + let updated = serialize manifest + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let ensureSocketMarketplace () = + let path = Path.Combine(root, ".agents", "plugins", "marketplace.json") + if File.Exists(path) then + let marketplace = loadObject path + let entries = marketplace["plugins"].AsArray() + let existing = + entries + |> Seq.choose (fun node -> if isNull node then None else Some(node["name"].GetValue<string>())) + |> Set.ofSeq + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue<string>() + if not (existing.Contains(name)) then + let category = + match manifest["interface"] with + | :? JsonObject as value when value["category"] <> null -> value["category"].GetValue<string>() + | _ -> "Developer Tools" + let entry = JsonObject() + entry["name"] <- name + let source = JsonObject() + source["source"] <- "local" + source["path"] <- $"./plugins/{name}" + entry["source"] <- source + let policy = JsonObject() + policy["installation"] <- "AVAILABLE" + policy["authentication"] <- "ON_INSTALL" + entry["policy"] <- policy + entry["category"] <- category + entries.Add(entry) + let updated = serialize marketplace + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let ensureSkillsExport () = + let path = Path.Combine(root, "skills.sh.json") + if File.Exists(path) then + let document = loadObject path + let groupings = document["groupings"].AsArray() + for groupingNode in groupings do + let skills = groupingNode["skills"].AsArray() + let staleIndexes = + skills + |> Seq.indexed + |> Seq.choose (fun (index, node) -> + let name = node.GetValue<string>() + if name = "bootstrap-skills-plugin-repo" || name = "sync-skills-repo-guidance" then Some index else None) + |> Seq.sortDescending + |> Seq.toList + for index in staleIndexes do skills.RemoveAt(index) + let pluginGrouping = + groupings + |> Seq.tryFind (fun node -> node["title"].GetValue<string>() = "Agent Plugin Skills") + match pluginGrouping with + | Some grouping -> + let skills = grouping["skills"].AsArray() + if not (skills |> Seq.exists (fun node -> node.GetValue<string>() = "maintain-agent-plugins")) then skills.Add("maintain-agent-plugins") + | None -> + let grouping = JsonObject() + grouping["title"] <- "Agent Plugin Skills" + let skills = JsonArray() + skills.Add("maintain-agent-plugins") + grouping["skills"] <- skills + groupings.Insert(0, grouping) + let updated = serialize document + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let validateManifest (pluginRoot: string) = + let path = manifestPath pluginRoot + let manifest = loadObject path + let name = requiredString path "name" manifest + if name <> Path.GetFileName(pluginRoot) then fail $"Plugin manifest name {name} does not match its directory." + let version = requiredString path "version" manifest + if not (Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")) then fail $"Plugin {name} has invalid SemVer {version}." + requiredString path "description" manifest |> ignore + if requiredString path "license" manifest <> "Apache-2.0" then fail $"Plugin {name} must use Apache-2.0." + let author = objectAt path "author" manifest + if requiredString path "name" author <> "Gale" + || requiredString path "email" author <> "mail@galewilliams.com" + || requiredString path "url" author <> "https://github.com/gaelic-ghost" then + fail $"Plugin {name} does not use the fixed Socket publisher identity." + requiredString path "homepage" manifest |> ignore + requiredString path "repository" manifest |> ignore + let interfaceNode = objectAt path "interface" manifest + for field in [ "displayName"; "shortDescription"; "longDescription"; "developerName"; "category" ] do + requiredString path field interfaceNode |> ignore + match interfaceNode["defaultPrompt"] with + | null -> () + | :? JsonArray as prompts when prompts.Count <= 3 -> + for prompt in prompts do + let value = prompt.GetValue<string>() + if String.IsNullOrWhiteSpace(value) || value.Length > 128 then fail $"Plugin {name} has an invalid default prompt." + | :? JsonArray -> fail $"Plugin {name} has more than three default prompts." + | _ -> fail $"Plugin {name} interface.defaultPrompt must be an array." + if manifest["hooks"] <> null && manifest["hooks"].GetValue<string>() = "./hooks/hooks.json" then + fail $"Plugin {name} redundantly declares the default hooks/hooks.json path." + for field in [ "composerIcon"; "logo"; "logoDark" ] do + match interfaceNode[field] with + | null -> () + | value -> + let relative = value.GetValue<string>().TrimStart('.', '/', '\\') + if not (File.Exists(Path.Combine(pluginRoot, relative))) then fail $"Plugin {name} references missing {field} asset {relative}." + if manifest["skills"] <> null then + let relative = manifest["skills"].GetValue<string>().TrimStart('.', '/', '\\') + let skillsRoot = Path.Combine(pluginRoot, relative) + if not (Directory.Exists(skillsRoot)) then fail $"Plugin {name} references missing skills directory {relative}." + if Array.isEmpty (Directory.GetFiles(skillsRoot, "SKILL.md", SearchOption.AllDirectories)) then fail $"Plugin {name} contains no SKILL.md files." + +let validateSocketMarketplace () = + let path = Path.Combine(root, ".agents", "plugins", "marketplace.json") + if File.Exists(path) then + let marketplace = loadObject path + let entries = marketplace["plugins"].AsArray() + let byName = + entries + |> Seq.map (fun node -> node["name"].GetValue<string>(), node.AsObject()) + |> Seq.groupBy fst + |> Seq.map (fun (name, values) -> name, values |> Seq.map snd |> Seq.toList) + |> Map.ofSeq + for KeyValue(name, values) in byName do + if values.Length <> 1 then fail $"Socket marketplace contains duplicate plugin {name}." + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue<string>() + match byName.TryFind(name) with + | None -> fail $"Socket marketplace is missing plugin {name}. Run just plugins-apply." + | Some [ entry ] -> + let source = objectAt name "source" entry + if requiredString name "source" source <> "local" || requiredString name "path" source <> $"./plugins/{name}" then + fail $"Socket marketplace source is incorrect for {name}." + let policy = objectAt name "policy" entry + if requiredString name "installation" policy <> "AVAILABLE" || requiredString name "authentication" policy <> "ON_INSTALL" then + fail $"Socket marketplace policy is incorrect for {name}." + | _ -> fail $"Socket marketplace contains duplicate plugin {name}." + +let validateStaleExports () = + let path = Path.Combine(root, "skills.sh.json") + if File.Exists(path) then + let text = File.ReadAllText(path) + for stale in [ "bootstrap-skills-plugin-repo"; "sync-skills-repo-guidance" ] do + if text.Contains(stale, StringComparison.Ordinal) then fail $"Stale removed skill remains exported: {stale}." + +let validateAll () = + pluginRoots |> Array.iter validateManifest + validateSocketMarketplace () + validateStaleExports () + printfn "Validated %d agent plugin manifest(s), assets, marketplace entries, and removed-surface guards." pluginRoots.Length + +let operation = fsi.CommandLineArgs |> Array.skip 1 |> Array.tryHead |> Option.defaultValue "check" +match operation with +| "check" -> validateAll () +| "apply" -> + pluginRoots |> Array.iter normalizeManifest + ensureSocketMarketplace () + ensureSkillsExport () + validateAll () + printfn "Applied deterministic agent-plugin policy to the complete plugin set." +| _ -> fail $"Usage: agent-plugins.fsx check|apply" diff --git a/scripts/agent-plugins/agent-plugins.just b/scripts/agent-plugins/agent-plugins.just new file mode 100644 index 000000000..237ca9477 --- /dev/null +++ b/scripts/agent-plugins/agent-plugins.just @@ -0,0 +1,5 @@ +plugins-check: + dotnet fsi scripts/agent-plugins/agent-plugins.fsx check + +plugins-apply: + dotnet fsi scripts/agent-plugins/agent-plugins.fsx apply diff --git a/scripts/repo-maintenance/syncing/20-agent-plugins.fsx b/scripts/repo-maintenance/syncing/20-agent-plugins.fsx new file mode 100644 index 000000000..743fdb6b8 --- /dev/null +++ b/scripts/repo-maintenance/syncing/20-agent-plugins.fsx @@ -0,0 +1,22 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let script = Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx") +if not (File.Exists(script)) then failwith $"Managed agent-plugin runtime is missing: {script}" + +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; script; "apply" ] do info.ArgumentList.Add(argument) +use child = Process.Start(info) +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Agent-plugin apply failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "%s" (stdout.Trim()) diff --git a/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx index 92c6619d8..eefd40c32 100644 --- a/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx +++ b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx @@ -2,6 +2,7 @@ open System open System.IO +open System.Text.Json let repositoryRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) let pluginsRoot = Path.Combine(repositoryRoot, "plugins") @@ -32,12 +33,20 @@ let replaceTree (source: string) (target: string) = if not (Directory.Exists(target)) && Directory.Exists(backup) then Directory.Move(backup, target) raise error -let exportedSkills = +let existingNames = Directory.GetDirectories(Path.Combine(repositoryRoot, "skills")) |> Array.filter (fun directory -> File.Exists(Path.Combine(directory, "SKILL.md"))) - |> Array.sort -for target in exportedSkills do - let skillName = Path.GetFileName(target) + |> Array.map Path.GetFileName + +let declaredNames = + use document = JsonDocument.Parse(File.ReadAllText(Path.Combine(repositoryRoot, "skills.sh.json"))) + document.RootElement.GetProperty("groupings").EnumerateArray() + |> Seq.collect (fun grouping -> grouping.GetProperty("skills").EnumerateArray() |> Seq.map (fun skill -> skill.GetString())) + |> Seq.toArray + +let exportedNames = Array.append existingNames declaredNames |> Array.distinct |> Array.sort +for skillName in exportedNames do + let target = Path.Combine(repositoryRoot, "skills", skillName) let source = let candidates = Directory.GetDirectories(pluginsRoot) |> Array.map (fun plugin -> Path.Combine(plugin, "skills", skillName)) |> Array.filter Directory.Exists match candidates with @@ -52,4 +61,4 @@ replaceTree (Path.Combine(pluginsRoot, "repository-skills", "shared", "project-docs")) (Path.Combine(repositoryRoot, "shared", "project-docs")) -printfn "Synchronized %d managed root skill exports and the shared documentation runtime." exportedSkills.Length +printfn "Synchronized %d managed root skill exports and the shared documentation runtime." exportedNames.Length diff --git a/scripts/repo-maintenance/validations/30-agent-plugins.fsx b/scripts/repo-maintenance/validations/30-agent-plugins.fsx new file mode 100644 index 000000000..a7ca130f7 --- /dev/null +++ b/scripts/repo-maintenance/validations/30-agent-plugins.fsx @@ -0,0 +1,22 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let script = Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx") +if not (File.Exists(script)) then failwith $"Managed agent-plugin runtime is missing: {script}" + +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; script; "check" ] do info.ArgumentList.Add(argument) +use child = Process.Start(info) +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Agent-plugin validation failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "%s" (stdout.Trim()) diff --git a/skills.sh.json b/skills.sh.json index 9c3574236..187a21356 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -1,10 +1,15 @@ { "$schema": "https://skills.sh/schemas/skills.sh.schema.json", "groupings": [ + { + "title": "Agent Plugin Skills", + "skills": [ + "maintain-agent-plugins" + ] + }, { "title": "Agent Portability Skills", "skills": [ - "bootstrap-skills-plugin-repo", "build-acp-agent", "build-hermes-agent-extensions", "choose-agent-integration-protocol", @@ -15,7 +20,6 @@ "operate-hermes-agent", "operate-hermes-agent-gateway", "operate-zed-agent", - "sync-skills-repo-guidance", "use-nous-research-services" ] }, diff --git a/skills/maintain-agent-plugins/SKILL.md b/skills/maintain-agent-plugins/SKILL.md new file mode 100644 index 000000000..00470e449 --- /dev/null +++ b/skills/maintain-agent-plugins/SKILL.md @@ -0,0 +1,103 @@ +--- +name: maintain-agent-plugins +description: Create or align agent plugin repositories, install deterministic managed FSX plugin maintenance, and validate or apply the whole plugin set through exactly two aggregate Just commands. +license: Apache-2.0 +metadata: + semver: 1.0.0 +--- + +# Maintain Agent Plugins + +## Purpose + +Create and maintain Codex plugin source without reintroducing hand-maintained +metadata, per-plugin commands, Python automation, or customizable scaffolding. +Use current official OpenAI plugin documentation for host schema facts, then +apply the fixed Socket repository policy through the managed runtime. + +## Required Interface + +Plugin maintenance has exactly two public commands. Each processes every plugin +manifest and marketplace entry owned by the repository: + +```text +just plugins-check +just plugins-apply +``` + +Do not add per-plugin, per-manifest, scaffold, sync, bootstrap, or alternate +mode recipes. `plugins-check` is read-only. `plugins-apply` performs the complete +deterministic reconciliation and then checks its result. + +## Installation + +1. Confirm the target is a Git repository root. +2. Run `scripts/maintain-agent-plugins.fsx --repo-root <path> --operation install` + from this skill once to copy the managed runtime and Just import. +3. The installer also adds ordered repository-maintenance hooks when the target + already uses the canonical `scripts/repo-maintenance/` runtime. +4. Run `just plugins-apply`, then `just repo-validate` when repository-skills is + present. + +Use `--operation refresh` to replace managed files from this skill and +`--operation report-only` for a read-only drift report. These installer modes +are skill internals, not additional Just tasks. + +## Fixed Policy + +- Publisher: Gale, `mail@galewilliams.com`, + `https://github.com/gaelic-ghost`. +- License: Apache-2.0. +- Plugin names: stable kebab case matching their directory. +- Manifest: `.codex-plugin/plugin.json`; bundled skills: `./skills/`. +- Default plugin repository URL: + `https://github.com/gaelic-ghost/<plugin-name>`. +- Local marketplace policy: `AVAILABLE` and `ON_INSTALL`. +- Default category: `Developer Tools` unless the plugin already has an + intentional product category. +- Default-discovered hook files stay at `hooks/hooks.json`; do not duplicate + that default path in the manifest. +- `interface.defaultPrompt` is an array of at most three concise prompts. + +The runtime owns these choices. Do not add user profiles, policy files, +template variables, interactive questions, or project-local overrides. + +## Creating a Plugin + +1. Establish one bounded capability and its owning plugin name. +2. Create the plugin directory, `.codex-plugin/plugin.json`, `AGENTS.md`, and at + least one complete skill under `skills/<skill-name>/SKILL.md`. +3. Add only real optional surfaces: `.mcp.json`, `.app.json`, `hooks/`, agents, + or assets must have an actual consumer and validation path. +4. Use the fixed publisher and license policy. Write plugin-specific purpose, + descriptions, prompts, keywords, and capability content directly; these are + authored semantics, not customization inputs. +5. Run `just plugins-apply`. In Socket, this adds missing root marketplace + wiring and checks the complete plugin set. +6. Run only the repository root integration/E2E path. Do not create tests under + the plugin or skill. + +## Ownership Handoffs + +- Use `repository-skills:maintain-project-repo` for the four canonical docs, + repo-wide synchronization and validation, protected-main releases, and + version changes. +- Use `agent-engineering-skills` for agent orchestration, automation, eval, and + scheduling behavior. +- Use `agent-portability-skills` for ACP, A2A, MCP boundary selection, Zed, + Hermes, and other host adapters. +- Keep runtime hooks and MCP implementation with the plugin that ships them; + this skill owns their packaging consistency, not their product behavior. + +## Guards + +- Never edit installed plugin caches or enabled-state configuration. +- Never restore `bootstrap-skills-plugin-repo`, + `sync-skills-repo-guidance`, Python generators, or nested plugin tests. +- Never infer a new packaging layer or publish destination. +- Stop when a manifest name and directory disagree, a managed target is not a + regular file, or a required optional asset is missing. + +## Script Inventory + +- `scripts/maintain-agent-plugins.fsx` diff --git a/skills/maintain-agent-plugins/agents/openai.yaml b/skills/maintain-agent-plugins/agents/openai.yaml new file mode 100644 index 000000000..3144a1644 --- /dev/null +++ b/skills/maintain-agent-plugins/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Maintain Agent Plugins" + short_description: "Create and align deterministic plugin repos" + default_prompt: "Use $maintain-agent-plugins to create or align the complete agent plugin set with managed FSX automation, exactly plugins-check and plugins-apply, fixed Socket publisher policy, and repository-skills integration." diff --git a/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx b/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx new file mode 100644 index 000000000..68161d86a --- /dev/null +++ b/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.fsx @@ -0,0 +1,251 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text +open System.Text.Encodings.Web +open System.Text.Json +open System.Text.Json.Nodes +open System.Text.RegularExpressions + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..")) +let fail message = raise (InvalidOperationException(message)) +let jsonOptions = JsonSerializerOptions(WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let serialize (node: JsonNode) = node.ToJsonString(jsonOptions) + "\n" + +let normalizePrompt (value: string) = + let trimmed = value.Trim() + if trimmed.Length <= 128 then trimmed else trimmed.Substring(0, 127).TrimEnd() + "…" + +let requiredString (owner: string) (name: string) (node: JsonObject) = + match node[name] with + | null -> fail $"{owner} is missing required field {name}." + | value -> + let text = value.GetValue<string>() + if String.IsNullOrWhiteSpace(text) then fail $"{owner} has an empty {name}." + text + +let objectAt (owner: string) (name: string) (node: JsonObject) = + match node[name] with + | :? JsonObject as value -> value + | _ -> fail $"{owner} is missing object {name}." + +let pluginRoots = + let plugins = Path.Combine(root, "plugins") + if Directory.Exists(plugins) then + Directory.GetDirectories(plugins) + |> Array.filter (fun directory -> File.Exists(Path.Combine(directory, ".codex-plugin", "plugin.json"))) + |> Array.sortWith (fun left right -> StringComparer.Ordinal.Compare(left, right)) + elif File.Exists(Path.Combine(root, ".codex-plugin", "plugin.json")) then [| root |] + else fail $"No plugin manifests were found under {root}." + +let manifestPath (pluginRoot: string) = Path.Combine(pluginRoot, ".codex-plugin", "plugin.json") +let loadObject (path: string) = JsonNode.Parse(File.ReadAllText(path)).AsObject() + +let normalizeManifest (pluginRoot: string) = + let path = manifestPath pluginRoot + let manifest = loadObject path + let directoryName = Path.GetFileName(pluginRoot) + let name = requiredString path "name" manifest + if name <> directoryName then fail $"Plugin manifest name {name} does not match directory {directoryName}." + + let author = + match manifest["author"] with + | :? JsonObject as value -> value + | _ -> let value = JsonObject() in manifest["author"] <- value; value + author["name"] <- "Gale" + author["email"] <- "mail@galewilliams.com" + author["url"] <- "https://github.com/gaelic-ghost" + manifest["license"] <- "Apache-2.0" + + if manifest["homepage"] = null then + manifest["homepage"] <- $"https://github.com/gaelic-ghost/{name}" + if manifest["repository"] = null then + manifest["repository"] <- $"https://github.com/gaelic-ghost/{name}" + + match manifest["hooks"] with + | null -> () + | value when value.GetValue<string>() = "./hooks/hooks.json" -> manifest.Remove("hooks") |> ignore + | _ -> () + + match manifest["interface"] with + | :? JsonObject as interfaceNode -> + match interfaceNode["defaultPrompt"] with + | :? JsonValue as prompt -> + let prompts = JsonArray() + prompts.Add(normalizePrompt (prompt.GetValue<string>())) + interfaceNode["defaultPrompt"] <- prompts + | :? JsonArray as prompts -> + while prompts.Count > 3 do prompts.RemoveAt(prompts.Count - 1) + for index in 0 .. prompts.Count - 1 do + prompts[index] <- normalizePrompt (prompts[index].GetValue<string>()) + | _ -> () + | _ -> () + + let updated = serialize manifest + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let ensureSocketMarketplace () = + let path = Path.Combine(root, ".agents", "plugins", "marketplace.json") + if File.Exists(path) then + let marketplace = loadObject path + let entries = marketplace["plugins"].AsArray() + let existing = + entries + |> Seq.choose (fun node -> if isNull node then None else Some(node["name"].GetValue<string>())) + |> Set.ofSeq + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue<string>() + if not (existing.Contains(name)) then + let category = + match manifest["interface"] with + | :? JsonObject as value when value["category"] <> null -> value["category"].GetValue<string>() + | _ -> "Developer Tools" + let entry = JsonObject() + entry["name"] <- name + let source = JsonObject() + source["source"] <- "local" + source["path"] <- $"./plugins/{name}" + entry["source"] <- source + let policy = JsonObject() + policy["installation"] <- "AVAILABLE" + policy["authentication"] <- "ON_INSTALL" + entry["policy"] <- policy + entry["category"] <- category + entries.Add(entry) + let updated = serialize marketplace + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let ensureSkillsExport () = + let path = Path.Combine(root, "skills.sh.json") + if File.Exists(path) then + let document = loadObject path + let groupings = document["groupings"].AsArray() + for groupingNode in groupings do + let skills = groupingNode["skills"].AsArray() + let staleIndexes = + skills + |> Seq.indexed + |> Seq.choose (fun (index, node) -> + let name = node.GetValue<string>() + if name = "bootstrap-skills-plugin-repo" || name = "sync-skills-repo-guidance" then Some index else None) + |> Seq.sortDescending + |> Seq.toList + for index in staleIndexes do skills.RemoveAt(index) + let pluginGrouping = + groupings + |> Seq.tryFind (fun node -> node["title"].GetValue<string>() = "Agent Plugin Skills") + match pluginGrouping with + | Some grouping -> + let skills = grouping["skills"].AsArray() + if not (skills |> Seq.exists (fun node -> node.GetValue<string>() = "maintain-agent-plugins")) then skills.Add("maintain-agent-plugins") + | None -> + let grouping = JsonObject() + grouping["title"] <- "Agent Plugin Skills" + let skills = JsonArray() + skills.Add("maintain-agent-plugins") + grouping["skills"] <- skills + groupings.Insert(0, grouping) + let updated = serialize document + if File.ReadAllText(path).Replace("\r\n", "\n") <> updated then atomicWrite path updated + +let validateManifest (pluginRoot: string) = + let path = manifestPath pluginRoot + let manifest = loadObject path + let name = requiredString path "name" manifest + if name <> Path.GetFileName(pluginRoot) then fail $"Plugin manifest name {name} does not match its directory." + let version = requiredString path "version" manifest + if not (Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")) then fail $"Plugin {name} has invalid SemVer {version}." + requiredString path "description" manifest |> ignore + if requiredString path "license" manifest <> "Apache-2.0" then fail $"Plugin {name} must use Apache-2.0." + let author = objectAt path "author" manifest + if requiredString path "name" author <> "Gale" + || requiredString path "email" author <> "mail@galewilliams.com" + || requiredString path "url" author <> "https://github.com/gaelic-ghost" then + fail $"Plugin {name} does not use the fixed Socket publisher identity." + requiredString path "homepage" manifest |> ignore + requiredString path "repository" manifest |> ignore + let interfaceNode = objectAt path "interface" manifest + for field in [ "displayName"; "shortDescription"; "longDescription"; "developerName"; "category" ] do + requiredString path field interfaceNode |> ignore + match interfaceNode["defaultPrompt"] with + | null -> () + | :? JsonArray as prompts when prompts.Count <= 3 -> + for prompt in prompts do + let value = prompt.GetValue<string>() + if String.IsNullOrWhiteSpace(value) || value.Length > 128 then fail $"Plugin {name} has an invalid default prompt." + | :? JsonArray -> fail $"Plugin {name} has more than three default prompts." + | _ -> fail $"Plugin {name} interface.defaultPrompt must be an array." + if manifest["hooks"] <> null && manifest["hooks"].GetValue<string>() = "./hooks/hooks.json" then + fail $"Plugin {name} redundantly declares the default hooks/hooks.json path." + for field in [ "composerIcon"; "logo"; "logoDark" ] do + match interfaceNode[field] with + | null -> () + | value -> + let relative = value.GetValue<string>().TrimStart('.', '/', '\\') + if not (File.Exists(Path.Combine(pluginRoot, relative))) then fail $"Plugin {name} references missing {field} asset {relative}." + if manifest["skills"] <> null then + let relative = manifest["skills"].GetValue<string>().TrimStart('.', '/', '\\') + let skillsRoot = Path.Combine(pluginRoot, relative) + if not (Directory.Exists(skillsRoot)) then fail $"Plugin {name} references missing skills directory {relative}." + if Array.isEmpty (Directory.GetFiles(skillsRoot, "SKILL.md", SearchOption.AllDirectories)) then fail $"Plugin {name} contains no SKILL.md files." + +let validateSocketMarketplace () = + let path = Path.Combine(root, ".agents", "plugins", "marketplace.json") + if File.Exists(path) then + let marketplace = loadObject path + let entries = marketplace["plugins"].AsArray() + let byName = + entries + |> Seq.map (fun node -> node["name"].GetValue<string>(), node.AsObject()) + |> Seq.groupBy fst + |> Seq.map (fun (name, values) -> name, values |> Seq.map snd |> Seq.toList) + |> Map.ofSeq + for KeyValue(name, values) in byName do + if values.Length <> 1 then fail $"Socket marketplace contains duplicate plugin {name}." + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue<string>() + match byName.TryFind(name) with + | None -> fail $"Socket marketplace is missing plugin {name}. Run just plugins-apply." + | Some [ entry ] -> + let source = objectAt name "source" entry + if requiredString name "source" source <> "local" || requiredString name "path" source <> $"./plugins/{name}" then + fail $"Socket marketplace source is incorrect for {name}." + let policy = objectAt name "policy" entry + if requiredString name "installation" policy <> "AVAILABLE" || requiredString name "authentication" policy <> "ON_INSTALL" then + fail $"Socket marketplace policy is incorrect for {name}." + | _ -> fail $"Socket marketplace contains duplicate plugin {name}." + +let validateStaleExports () = + let path = Path.Combine(root, "skills.sh.json") + if File.Exists(path) then + let text = File.ReadAllText(path) + for stale in [ "bootstrap-skills-plugin-repo"; "sync-skills-repo-guidance" ] do + if text.Contains(stale, StringComparison.Ordinal) then fail $"Stale removed skill remains exported: {stale}." + +let validateAll () = + pluginRoots |> Array.iter validateManifest + validateSocketMarketplace () + validateStaleExports () + printfn "Validated %d agent plugin manifest(s), assets, marketplace entries, and removed-surface guards." pluginRoots.Length + +let operation = fsi.CommandLineArgs |> Array.skip 1 |> Array.tryHead |> Option.defaultValue "check" +match operation with +| "check" -> validateAll () +| "apply" -> + pluginRoots |> Array.iter normalizeManifest + ensureSocketMarketplace () + ensureSkillsExport () + validateAll () + printfn "Applied deterministic agent-plugin policy to the complete plugin set." +| _ -> fail $"Usage: agent-plugins.fsx check|apply" diff --git a/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just b/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just new file mode 100644 index 000000000..237ca9477 --- /dev/null +++ b/skills/maintain-agent-plugins/assets/agent-plugins/agent-plugins.just @@ -0,0 +1,5 @@ +plugins-check: + dotnet fsi scripts/agent-plugins/agent-plugins.fsx check + +plugins-apply: + dotnet fsi scripts/agent-plugins/agent-plugins.fsx apply diff --git a/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx b/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx new file mode 100644 index 000000000..743fdb6b8 --- /dev/null +++ b/skills/maintain-agent-plugins/assets/repo-maintenance/syncing/20-agent-plugins.fsx @@ -0,0 +1,22 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let script = Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx") +if not (File.Exists(script)) then failwith $"Managed agent-plugin runtime is missing: {script}" + +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; script; "apply" ] do info.ArgumentList.Add(argument) +use child = Process.Start(info) +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Agent-plugin apply failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "%s" (stdout.Trim()) diff --git a/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx b/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx new file mode 100644 index 000000000..a7ca130f7 --- /dev/null +++ b/skills/maintain-agent-plugins/assets/repo-maintenance/validations/30-agent-plugins.fsx @@ -0,0 +1,22 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO + +let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +let script = Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx") +if not (File.Exists(script)) then failwith $"Managed agent-plugin runtime is missing: {script}" + +let info = ProcessStartInfo("dotnet") +info.WorkingDirectory <- root +info.UseShellExecute <- false +info.RedirectStandardOutput <- true +info.RedirectStandardError <- true +for argument in [ "fsi"; script; "check" ] do info.ArgumentList.Add(argument) +use child = Process.Start(info) +let stdout = child.StandardOutput.ReadToEnd() +let stderr = child.StandardError.ReadToEnd() +child.WaitForExit() +if child.ExitCode <> 0 then failwith $"Agent-plugin validation failed: {stderr.Trim()}\n{stdout.Trim()}" +printfn "%s" (stdout.Trim()) diff --git a/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx b/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx new file mode 100644 index 000000000..5b29e1a88 --- /dev/null +++ b/skills/maintain-agent-plugins/scripts/maintain-agent-plugins.fsx @@ -0,0 +1,115 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.Diagnostics +open System.IO +open System.Text +open System.Text.Json + +type ManagedFile = { Source: string; Target: string; RequiresRepoMaintenance: bool } +type Action = { Action: string; Target: string } + +let scriptRoot = Path.GetFullPath(__SOURCE_DIRECTORY__) +let skillRoot = Path.GetFullPath(Path.Combine(scriptRoot, "..")) +let assetsRoot = Path.Combine(skillRoot, "assets") + +let managed = [ + { Source = "agent-plugins/agent-plugins.fsx"; Target = "scripts/agent-plugins/agent-plugins.fsx"; RequiresRepoMaintenance = false } + { Source = "agent-plugins/agent-plugins.just"; Target = "scripts/agent-plugins/agent-plugins.just"; RequiresRepoMaintenance = false } + { Source = "repo-maintenance/validations/30-agent-plugins.fsx"; Target = "scripts/repo-maintenance/validations/30-agent-plugins.fsx"; RequiresRepoMaintenance = true } + { Source = "repo-maintenance/syncing/20-agent-plugins.fsx"; Target = "scripts/repo-maintenance/syncing/20-agent-plugins.fsx"; RequiresRepoMaintenance = true } +] + +let parseArgs argv = + let mutable repoRoot = "." + let mutable operation = "install" + let rec loop args = + match args with + | [] -> () + | "--repo-root" :: value :: tail -> repoRoot <- value; loop tail + | "--operation" :: value :: tail -> operation <- value; loop tail + | unknown :: _ -> failwith $"Unknown argument: {unknown}" + loop (List.ofArray argv) + Path.GetFullPath(repoRoot), operation + +let atomicWrite (path: string) (content: string) = + let directory = Path.GetDirectoryName(path) + Directory.CreateDirectory(directory) |> ignore + let temporary = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp") + File.WriteAllText(temporary, content, UTF8Encoding(false)) + File.Move(temporary, path, true) + +let ensureInside (root: string) (relative: string) = + if Path.IsPathRooted(relative) || relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) |> Array.contains ".." then + failwith $"Managed target must be repository-relative: {relative}" + Path.Combine(root, relative) |> Path.GetFullPath + +let isGitRepository (root: string) = + let info = ProcessStartInfo("git") + info.WorkingDirectory <- root + info.UseShellExecute <- false + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + info.ArgumentList.Add("rev-parse") + info.ArgumentList.Add("--show-prefix") + use child = Process.Start(info) + let output = child.StandardOutput.ReadToEnd().Trim() + child.StandardError.ReadToEnd() |> ignore + child.WaitForExit() + child.ExitCode = 0 && String.IsNullOrWhiteSpace(output) + +let copyManaged (root: string) (apply: bool) (file: ManagedFile) = + let source = Path.Combine(assetsRoot, file.Source) + let target = ensureInside root file.Target + if not (File.Exists(source)) then failwith $"Managed source is missing: {source}" + if File.Exists(target) && not ((File.GetAttributes(target) &&& FileAttributes.Directory) = enum 0) then + failwith $"Managed target is not a regular file: {target}" + let content = File.ReadAllText(source).Replace("\r\n", "\n") + let action = + if File.Exists(target) && File.ReadAllText(target).Replace("\r\n", "\n") = content then "unchanged" + elif File.Exists(target) then "update" + else "install" + if apply && action <> "unchanged" then atomicWrite target content + { Action = action; Target = file.Target } + +let ensureJustImport (root: string) (apply: bool) = + let path = Path.Combine(root, "justfile") + let importLine = "import 'scripts/agent-plugins/agent-plugins.just'" + let existing = if File.Exists(path) then File.ReadAllText(path).Replace("\r\n", "\n") else "" + if existing.Contains(importLine, StringComparison.Ordinal) then { Action = "unchanged"; Target = "justfile" } + else + let updated = existing.TrimEnd() + (if String.IsNullOrWhiteSpace(existing) then "" else "\n\n") + "# BEGIN managed agent-plugins\n" + importLine + "\n# END managed agent-plugins\n" + if apply then atomicWrite path updated + { Action = (if File.Exists(path) then "update" else "install"); Target = "justfile" } + +let runRuntime (root: string) (operation: string) = + let info = ProcessStartInfo("dotnet") + info.WorkingDirectory <- root + info.UseShellExecute <- false + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + for argument in [ "fsi"; Path.Combine(root, "scripts", "agent-plugins", "agent-plugins.fsx"); operation ] do info.ArgumentList.Add(argument) + use child = Process.Start(info) + let stdout = child.StandardOutput.ReadToEnd() + let stderr = child.StandardError.ReadToEnd() + child.WaitForExit() + if child.ExitCode <> 0 then failwith $"Managed agent-plugin {operation} failed: {stderr.Trim()}\n{stdout.Trim()}" + stdout.Trim() + +let root, operation = parseArgs (fsi.CommandLineArgs |> Array.skip 1) +if not (Directory.Exists(root)) then failwith $"Repository root does not exist: {root}" +if not (isGitRepository root) then failwith $"Path is not the root of a Git repository: {root}" +if not (List.contains operation [ "install"; "refresh"; "report-only" ]) then failwith $"Unsupported operation: {operation}" +let apply = operation <> "report-only" +let hasRepoMaintenance = Directory.Exists(Path.Combine(root, "scripts", "repo-maintenance")) +let files = managed |> List.filter (fun file -> not file.RequiresRepoMaintenance || hasRepoMaintenance) +let actions = (files |> List.map (copyManaged root apply)) @ [ ensureJustImport root apply ] +let hasDrift = actions |> List.exists (fun action -> action.Action <> "unchanged") +let runtimeResult = + if apply then runRuntime root "apply" + elif hasDrift then "Managed files differ; run the installer with --operation refresh." + else runRuntime root "check" +let report = {| status = (if operation = "report-only" && hasDrift then "drift" else "success"); operation = operation; repoRoot = root; actions = actions; result = runtimeResult |} +let options = JsonSerializerOptions(WriteIndented = true) +Console.WriteLine(JsonSerializer.Serialize(report, options)) +if operation = "report-only" && hasDrift then exit 1 diff --git a/tests/repository-maintenance-e2e.fsx b/tests/repository-maintenance-e2e.fsx index 576e167a9..402caadd4 100644 --- a/tests/repository-maintenance-e2e.fsx +++ b/tests/repository-maintenance-e2e.fsx @@ -7,6 +7,7 @@ type Result = { ExitCode: int; Stdout: string; Stderr: string } let socketRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..")) let installer = Path.Combine(socketRoot, "plugins", "repository-skills", "skills", "maintain-project-repo", "scripts", "maintain-project-repo.fsx") +let pluginInstaller = Path.Combine(socketRoot, "plugins", "agent-plugin-skills", "skills", "maintain-agent-plugins", "scripts", "maintain-agent-plugins.fsx") let testRoot = Path.Combine(Path.GetTempPath(), $"socket-repository-maintenance-e2e-{Guid.NewGuid():N}") Directory.CreateDirectory(testRoot) |> ignore @@ -35,6 +36,13 @@ let documentationRecipes = if documentationRecipes <> [| "docs-apply"; "docs-check" |] then let rendered = String.concat ", " documentationRecipes failwith $"Expected exactly docs-apply and docs-check, found: {rendered}" +let pluginRecipes = + rootRecipes.Stdout.Split([| ' '; '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries) + |> Array.filter (fun name -> name.StartsWith("plugins-", StringComparison.Ordinal)) + |> Array.sort +if pluginRecipes <> [| "plugins-apply"; "plugins-check" |] then + let rendered = String.concat ", " pluginRecipes + failwith $"Expected exactly plugins-apply and plugins-check, found: {rendered}" let nestedTests = Directory.GetFiles(socketRoot, "*", SearchOption.AllDirectories) @@ -68,17 +76,54 @@ let snapshot () = run testRoot "git" [ "init"; "-q" ] |> requireSuccess "temporary Git initialization" run socketRoot "dotnet" [ "fsi"; installer; "--repo-root"; testRoot; "--operation"; "install"; "--profile"; "generic" ] |> requireSuccess "repository-skills installation" +let fixturePluginRoot = Path.Combine(testRoot, "plugins", "sample-agent-plugin") +Directory.CreateDirectory(Path.Combine(fixturePluginRoot, ".codex-plugin")) |> ignore +Directory.CreateDirectory(Path.Combine(fixturePluginRoot, "skills", "sample-workflow")) |> ignore +File.WriteAllText( + Path.Combine(fixturePluginRoot, ".codex-plugin", "plugin.json"), + """{ + "name": "sample-agent-plugin", + "version": "1.0.0", + "description": "A deterministic plugin-maintenance fixture.", + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "Sample Agent Plugin", + "shortDescription": "Exercise managed plugin maintenance.", + "longDescription": "Exercise managed plugin maintenance through the repository root E2E path.", + "developerName": "Gale", + "category": "Developer Tools", + "defaultPrompt": "Maintain this sample plugin." + } +} +""") +File.WriteAllText( + Path.Combine(fixturePluginRoot, "skills", "sample-workflow", "SKILL.md"), + """--- +name: sample-workflow +description: Exercise the managed plugin fixture. +--- + +# Sample Workflow + +Exercise the fixture. +""") +run socketRoot "dotnet" [ "fsi"; pluginInstaller; "--repo-root"; testRoot; "--operation"; "install" ] |> requireSuccess "agent-plugin-skills installation" + let contributing = Path.Combine(testRoot, "CONTRIBUTING.md") File.AppendAllText(contributing, "\n```text\nSigned-off-by: Your Name <you@example.com>\n```\n") run testRoot "just" [ "docs-apply" ] |> requireSuccess "first full documentation apply" +run testRoot "just" [ "plugins-apply" ] |> requireSuccess "first full plugin apply" let first = snapshot () run testRoot "just" [ "docs-apply" ] |> requireSuccess "second full documentation apply" +run testRoot "just" [ "plugins-apply" ] |> requireSuccess "second full plugin apply" let second = snapshot () if first.Length <> second.Length || Array.exists2 (fun (leftPath, leftBytes) (rightPath, rightBytes) -> leftPath <> rightPath || leftBytes <> rightBytes) first second then failwith "Second full documentation apply was not byte-idempotent." run testRoot "just" [ "docs-check" ] |> requireSuccess "full documentation check" +run testRoot "just" [ "plugins-check" ] |> requireSuccess "full plugin check" let fixtureRecipes = run testRoot "just" [ "--summary" ] requireSuccess "fixture Just recipe discovery" fixtureRecipes let fixtureDocs = @@ -88,6 +133,13 @@ let fixtureDocs = if fixtureDocs <> [| "docs-apply"; "docs-check" |] then let rendered = String.concat ", " fixtureDocs failwith $"Installed repository exposed unexpected docs recipes: {rendered}" +let fixturePlugins = + fixtureRecipes.Stdout.Split([| ' '; '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries) + |> Array.filter (fun name -> name.StartsWith("plugins-", StringComparison.Ordinal)) + |> Array.sort +if fixturePlugins <> [| "plugins-apply"; "plugins-check" |] then + let rendered = String.concat ", " fixturePlugins + failwith $"Installed repository exposed unexpected plugin recipes: {rendered}" run testRoot "git" [ "add"; "-A" ] |> requireSuccess "stage generated repository" run testRoot "git" [ "-c"; "user.name=Socket Tests"; "-c"; "user.email=tests@example.invalid"; "commit"; "-qm"; "test fixture" ] |> requireSuccess "commit generated repository" run testRoot "just" [ "repo-validate" ] |> requireSuccess "managed repository validation" From b72ea542be9e9b56437c598177fde8edef278bbb Mon Sep 17 00:00:00 2001 From: Gale W <mail@galewilliams.com> Date: Fri, 21 Aug 2026 00:26:47 -0400 Subject: [PATCH 5/5] release: bump versions for v10.0.3 --- plugins/agent-engineering-skills/.codex-plugin/plugin.json | 2 +- plugins/agent-plugin-skills/.codex-plugin/plugin.json | 2 +- plugins/agent-portability-skills/.codex-plugin/plugin.json | 2 +- plugins/agentdeck/.codex-plugin/plugin.json | 2 +- plugins/android-dev-skills/.codex-plugin/plugin.json | 2 +- plugins/apple-creator-studio-skills/.codex-plugin/plugin.json | 2 +- plugins/apple-dev-skills/.codex-plugin/plugin.json | 2 +- plugins/cloud-deployment-skills/.codex-plugin/plugin.json | 2 +- plugins/cloud-inference-skills/.codex-plugin/plugin.json | 2 +- plugins/codebase-understanding-skills/.codex-plugin/plugin.json | 2 +- plugins/cybersecurity-skills/.codex-plugin/plugin.json | 2 +- plugins/dotnet-skills/.codex-plugin/plugin.json | 2 +- plugins/game-dev-skills/.codex-plugin/plugin.json | 2 +- .../messaging-collaboration-skills/.codex-plugin/plugin.json | 2 +- plugins/model-lab-skills/.codex-plugin/plugin.json | 2 +- plugins/network-protocol-skills/.codex-plugin/plugin.json | 2 +- plugins/professional-skills/.codex-plugin/plugin.json | 2 +- plugins/python-skills/.codex-plugin/plugin.json | 2 +- plugins/repository-skills/.codex-plugin/plugin.json | 2 +- plugins/reverse-engineering-skills/.codex-plugin/plugin.json | 2 +- plugins/rust-skills/.codex-plugin/plugin.json | 2 +- plugins/server-side-jvm/.codex-plugin/plugin.json | 2 +- plugins/server-side-swift/.codex-plugin/plugin.json | 2 +- plugins/swift-lang/.codex-plugin/plugin.json | 2 +- plugins/web-dev-skills/.codex-plugin/plugin.json | 2 +- 25 files changed, 25 insertions(+), 25 deletions(-) diff --git a/plugins/agent-engineering-skills/.codex-plugin/plugin.json b/plugins/agent-engineering-skills/.codex-plugin/plugin.json index 2b30d983f..c5b985a6f 100644 --- a/plugins/agent-engineering-skills/.codex-plugin/plugin.json +++ b/plugins/agent-engineering-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-engineering-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Agent-system design, orchestration, scheduling, external-agent, and evaluation workflow skills.", "author": { "name": "Gale", diff --git a/plugins/agent-plugin-skills/.codex-plugin/plugin.json b/plugins/agent-plugin-skills/.codex-plugin/plugin.json index 65ba30ae9..7be4ec708 100644 --- a/plugins/agent-plugin-skills/.codex-plugin/plugin.json +++ b/plugins/agent-plugin-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-plugin-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Create and maintain deterministic agent plugin repositories with managed FSX automation and aggregate Just commands.", "author": { "name": "Gale", diff --git a/plugins/agent-portability-skills/.codex-plugin/plugin.json b/plugins/agent-portability-skills/.codex-plugin/plugin.json index 581b4d285..432225567 100644 --- a/plugins/agent-portability-skills/.codex-plugin/plugin.json +++ b/plugins/agent-portability-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-portability-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Skills for agent portability, ACP, A2A, Zed, Hermes, and host adapter guidance.", "author": { "name": "Gale", diff --git a/plugins/agentdeck/.codex-plugin/plugin.json b/plugins/agentdeck/.codex-plugin/plugin.json index b02c36f18..c86c2f74e 100644 --- a/plugins/agentdeck/.codex-plugin/plugin.json +++ b/plugins/agentdeck/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentdeck", - "version": "10.0.2", + "version": "10.0.3", "description": "Local Codex runtime utilities for thread, hook, and app-server workflows.", "author": { "name": "Gale", diff --git a/plugins/android-dev-skills/.codex-plugin/plugin.json b/plugins/android-dev-skills/.codex-plugin/plugin.json index 1f6b3a3de..9e0be479d 100644 --- a/plugins/android-dev-skills/.codex-plugin/plugin.json +++ b/plugins/android-dev-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "android-dev-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Android, Kotlin, Java, Gradle, Android Gradle Plugin, testing, lint, UI implementation, and release-readiness workflow skills.", "author": { "name": "Gale", diff --git a/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json b/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json index e6e6d487a..52342cc8b 100644 --- a/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json +++ b/plugins/apple-creator-studio-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "apple-creator-studio-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Human-facing and Computer Use-aware Apple Creator Studio workflows for Final Cut Pro editing, Motion templates, Compressor delivery, Logic Pro production, MainStage concert preparation, and GarageBand projects.", "author": { "name": "Gale", diff --git a/plugins/apple-dev-skills/.codex-plugin/plugin.json b/plugins/apple-dev-skills/.codex-plugin/plugin.json index a98846a0e..90ff01482 100644 --- a/plugins/apple-dev-skills/.codex-plugin/plugin.json +++ b/plugins/apple-dev-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "apple-dev-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Apple development workflows for Codex, including macOS privacy permissions, sandbox file access, entitlement diagnosis, virtualization, SwiftPM, Xcode, app extensions, media, provisioning, SwiftUI, AppKit, Safari, security, OpenAPI, and DocC.", "author": { "name": "Gale", diff --git a/plugins/cloud-deployment-skills/.codex-plugin/plugin.json b/plugins/cloud-deployment-skills/.codex-plugin/plugin.json index 30f63a0a5..c06709d9a 100644 --- a/plugins/cloud-deployment-skills/.codex-plugin/plugin.json +++ b/plugins/cloud-deployment-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cloud-deployment-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex skills for routing cloud deployment work through official provider plugins, MCP servers, CLIs, and a reusable Dockerized-service release and deployment contract.", "author": { "name": "Gale", diff --git a/plugins/cloud-inference-skills/.codex-plugin/plugin.json b/plugins/cloud-inference-skills/.codex-plugin/plugin.json index f787394f5..926ca431d 100644 --- a/plugins/cloud-inference-skills/.codex-plugin/plugin.json +++ b/plugins/cloud-inference-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cloud-inference-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Cloud AI inference workflow skills for routing model serving, training, conversion, and GPU infrastructure work across Runpod, Hugging Face, AWS, Vast.ai, CoreWeave, and similar providers.", "author": { "name": "Gale", diff --git a/plugins/codebase-understanding-skills/.codex-plugin/plugin.json b/plugins/codebase-understanding-skills/.codex-plugin/plugin.json index 009e0816d..f4ce1e376 100644 --- a/plugins/codebase-understanding-skills/.codex-plugin/plugin.json +++ b/plugins/codebase-understanding-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codebase-understanding-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Code-path exploration, call-site tracing, and evidence-based codebase explanation skills.", "author": { "name": "Gale", diff --git a/plugins/cybersecurity-skills/.codex-plugin/plugin.json b/plugins/cybersecurity-skills/.codex-plugin/plugin.json index 0f69398aa..b6360580c 100644 --- a/plugins/cybersecurity-skills/.codex-plugin/plugin.json +++ b/plugins/cybersecurity-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cybersecurity-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Defensive cybersecurity, isolated Linux and macOS analysis labs, suspicious-content and malware analysis, macOS defense, vulnerability testing, pentesting, and incident response workflows.", "skills": "./skills/", "author": { diff --git a/plugins/dotnet-skills/.codex-plugin/plugin.json b/plugins/dotnet-skills/.codex-plugin/plugin.json index d7ced344d..e0a4afbf4 100644 --- a/plugins/dotnet-skills/.codex-plugin/plugin.json +++ b/plugins/dotnet-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dotnet-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex skills for choosing, bootstrapping, building, testing, packaging, diagnosing, and maintaining .NET projects, including F# web applications, with F# and C# as equal first-party languages.", "author": { "name": "Gale", diff --git a/plugins/game-dev-skills/.codex-plugin/plugin.json b/plugins/game-dev-skills/.codex-plugin/plugin.json index 2747e2ca1..7417beb31 100644 --- a/plugins/game-dev-skills/.codex-plugin/plugin.json +++ b/plugins/game-dev-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "game-dev-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Apple platform game development workflow skills for native Metal rendering, Game Porting Toolkit routing, MetalFX, GPU asset streaming, neural rendering, frameworks, input, haptics, and profiling.", "author": { "name": "Gale", diff --git a/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json b/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json index 8c6ac63e9..55082df43 100644 --- a/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json +++ b/plugins/messaging-collaboration-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "messaging-collaboration-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex workflows for chat apps, bots, collaboration, iMessage, Apple notifications and Push to Talk, VoIP, and default communication-app planning.", "author": { "name": "Gale", diff --git a/plugins/model-lab-skills/.codex-plugin/plugin.json b/plugins/model-lab-skills/.codex-plugin/plugin.json index c51b6b6f7..c35f74206 100644 --- a/plugins/model-lab-skills/.codex-plugin/plugin.json +++ b/plugins/model-lab-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "model-lab-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Reproducible model training, evaluation, intervention, and runtime research workflows.", "author": { "name": "Gale", diff --git a/plugins/network-protocol-skills/.codex-plugin/plugin.json b/plugins/network-protocol-skills/.codex-plugin/plugin.json index 030fa0055..03f925525 100644 --- a/plugins/network-protocol-skills/.codex-plugin/plugin.json +++ b/plugins/network-protocol-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "network-protocol-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex skills for choosing, planning, implementing, and diagnosing modern application transports and real-time networking protocols, including QUIC, HTTP/3, WebRTC, Media over QUIC, WebTransport-adjacent handoffs, protocol maturity checks, and stack-specific implementation routing.", "author": { "name": "Gale", diff --git a/plugins/professional-skills/.codex-plugin/plugin.json b/plugins/professional-skills/.codex-plugin/plugin.json index f1eb19ef2..1eff16379 100644 --- a/plugins/professional-skills/.codex-plugin/plugin.json +++ b/plugins/professional-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "professional-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Professional workflow skills for job search, career materials, and external professional services.", "author": { "name": "Gale", diff --git a/plugins/python-skills/.codex-plugin/plugin.json b/plugins/python-skills/.codex-plugin/plugin.json index 60b8f4593..0d309c8d9 100644 --- a/plugins/python-skills/.codex-plugin/plugin.json +++ b/plugins/python-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "python-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Bundled Python-focused Codex skills for uv bootstrapping, project implementation, diagnostics, packaging, tooling, CI, upgrades, FastAPI service maintenance, FastMCP service maintenance, and testing workflows.", "author": { "name": "Gale", diff --git a/plugins/repository-skills/.codex-plugin/plugin.json b/plugins/repository-skills/.codex-plugin/plugin.json index b95dbe531..56a7f7eab 100644 --- a/plugins/repository-skills/.codex-plugin/plugin.json +++ b/plugins/repository-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "repository-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Repository operations, documentation maintenance, GitHub settings, worktree, and release workflow skills.", "author": { "name": "Gale", diff --git a/plugins/reverse-engineering-skills/.codex-plugin/plugin.json b/plugins/reverse-engineering-skills/.codex-plugin/plugin.json index 5571eff40..c2cc333dc 100644 --- a/plugins/reverse-engineering-skills/.codex-plugin/plugin.json +++ b/plugins/reverse-engineering-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "reverse-engineering-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Workflow skills for reverse engineering, decompilation, disassembly, symbols, artifact analysis, and exact-build macOS security-control research.", "skills": "./skills/", "author": { diff --git a/plugins/rust-skills/.codex-plugin/plugin.json b/plugins/rust-skills/.codex-plugin/plugin.json index 026198582..e2d17a1dd 100644 --- a/plugins/rust-skills/.codex-plugin/plugin.json +++ b/plugins/rust-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rust-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Rust, Cargo, rustup, crate, workspace, CLI, library, package, CI, testing, linting, and formatting workflow skills.", "skills": "./skills/", "author": { diff --git a/plugins/server-side-jvm/.codex-plugin/plugin.json b/plugins/server-side-jvm/.codex-plugin/plugin.json index 9ef16323c..52b9d91e9 100644 --- a/plugins/server-side-jvm/.codex-plugin/plugin.json +++ b/plugins/server-side-jvm/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "server-side-jvm", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex skills for choosing, building, testing, and maintaining server-side JVM backend projects with Java and Scala as equal first-party languages and future Clojure support planned.", "author": { "name": "Gale", diff --git a/plugins/server-side-swift/.codex-plugin/plugin.json b/plugins/server-side-swift/.codex-plugin/plugin.json index 73dcfe4b1..bd27e4523 100644 --- a/plugins/server-side-swift/.codex-plugin/plugin.json +++ b/plugins/server-side-swift/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "server-side-swift", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex skills for adding, building, testing, and operating server-side Swift components in canonical product workspaces, with native Homebrew local services and GitHub-only Linux builds and deployments.", "author": { "name": "Gale", diff --git a/plugins/swift-lang/.codex-plugin/plugin.json b/plugins/swift-lang/.codex-plugin/plugin.json index f3a66b2a0..84db7ea19 100644 --- a/plugins/swift-lang/.codex-plugin/plugin.json +++ b/plugins/swift-lang/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "swift-lang", - "version": "10.0.2", + "version": "10.0.3", "description": "Shared Swift language and tooling skills for API style, errors, functional pipelines, formatting, source organization, SwiftSyntax, compiler inspection, SourceKit, indexing, SourceKit-LSP, and modernization.", "skills": "./skills/", "author": { diff --git a/plugins/web-dev-skills/.codex-plugin/plugin.json b/plugins/web-dev-skills/.codex-plugin/plugin.json index d51a23564..e2c7bb79a 100644 --- a/plugins/web-dev-skills/.codex-plugin/plugin.json +++ b/plugins/web-dev-skills/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "web-dev-skills", - "version": "10.0.2", + "version": "10.0.3", "description": "Codex skills for focused web and Expo native-boundary workflows.", "author": { "name": "Gale",