diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index f45e68275..3bc90d0b7 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": { @@ -322,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 2ed507981..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,14 +22,13 @@ { "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 }, { "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/.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/.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 3b114ee20..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 @@ -49,17 +54,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..b230a50a7 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,143 @@ 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 `/` 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. +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 -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 ``` - -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 67281eb91..d8ff8dc67 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 @@ -143,15 +143,50 @@ Currently available from the catalog: - `swift-lang` - `rust-skills` - `speak-swiftly` -- `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). 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 @@ -168,7 +203,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 @@ -179,38 +214,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 - -## 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..56f2e6f76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,8 @@ - [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 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) @@ -26,7 +25,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) @@ -34,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) @@ -53,68 +53,35 @@ ## Milestone Progress -- Milestone 5: SwiftASB skills plugin - Completed - 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 - 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 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 33: Unified Swift workspace and CI-owned cloud deployment - In Progress +- Milestone 34: Deterministic agent plugin repositories - In Progress ## Milestone 6: Dotnet skills plugin @@ -139,7 +106,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 @@ -150,7 +117,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 @@ -158,25 +125,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 @@ -220,7 +181,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 @@ -251,7 +212,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 @@ -280,7 +241,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 @@ -295,7 +256,7 @@ Decision note: the root marketplace entry is installable now that `web-dev-skill ### Status -Implementation Complete; Release Pending +In Progress ### Scope @@ -318,7 +279,7 @@ Implementation Complete; Release Pending - [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`. @@ -416,7 +377,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 @@ -479,7 +440,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 @@ -518,7 +479,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 @@ -532,7 +493,7 @@ In Progress ### Status -In progress +In Progress ### Scope @@ -542,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. @@ -549,7 +512,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 @@ -558,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. @@ -608,7 +572,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 @@ -623,7 +587,7 @@ Implemented Milestone 18 by adding the `swift-lang` child plugin with shared Swi ### Status -Release candidate +In Progress ### Scope @@ -640,7 +604,7 @@ Release candidate - [ ] 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 @@ -677,7 +641,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 @@ -710,7 +674,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 @@ -779,7 +743,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 @@ -934,6 +898,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 +925,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 +939,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 +985,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 +1000,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 +1015,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 +1038,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 +1053,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 +1076,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. @@ -1154,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. @@ -1180,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 @@ -1203,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. @@ -1234,7 +1276,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. @@ -1262,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. @@ -1281,7 +1324,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. @@ -1302,5 +1344,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 @@ - - 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.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/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..fd2533b4f 100644 --- a/docs/maintainers/deferred-work-wakeup-policy.md +++ b/docs/maintainers/deferred-work-wakeup-policy.md @@ -65,8 +65,7 @@ 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 -uv run pytest plugins/repository-skills/skills/maintain-project-repo/tests/test_maintain_project_repo_workflow.py +just repo-sync +just repo-validate +just test ``` diff --git a/docs/maintainers/dotnet-skills-plugin-plan.md b/docs/maintainers/dotnet-skills-plugin-plan.md index c535c2446..16baddeea 100644 --- a/docs/maintainers/dotnet-skills-plugin-plan.md +++ b/docs/maintainers/dotnet-skills-plugin-plan.md @@ -273,10 +273,10 @@ 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`. +- [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..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 -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh -uv run scripts/validate_socket_metadata.py -``` - -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 -uv run scripts/validate_socket_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/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/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/docs/maintainers/xcode-27-agentic-tooling-plan.md b/docs/maintainers/xcode-27-agentic-tooling-plan.md index c04d5357c..ac43f45c3 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,9 +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 -uv run pytest +just repo-validate +just test ``` ### Slice 3: Localization And Device Hub @@ -335,9 +334,8 @@ uv run pytest Validation: ```bash -uv run scripts/validate_socket_metadata.py -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh -uv run pytest +just repo-validate +just test ``` ### Slice 4: Beta Docs Triage And Framework Updates @@ -349,9 +347,8 @@ uv run pytest Validation: ```bash -uv run scripts/validate_socket_metadata.py -bash plugins/apple-dev-skills/.github/scripts/validate_repo_docs.sh -uv run pytest +just repo-validate +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 89b182e96..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 -uv run scripts/validate_socket_metadata.py -uv run pytest +```text +just repo-validate +just test ``` ### Slice 2: Official Import Smoke Fixtures @@ -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/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/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..473d4e2fe --- /dev/null +++ b/justfile @@ -0,0 +1,21 @@ +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 + +# BEGIN managed agent-plugins +import 'scripts/agent-plugins/agent-plugins.just' +# END managed agent-plugins 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-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-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-plugin-skills/.codex-plugin/plugin.json b/plugins/agent-plugin-skills/.codex-plugin/plugin.json new file mode 100644 index 000000000..7be4ec708 --- /dev/null +++ b/plugins/agent-plugin-skills/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "agent-plugin-skills", + "version": "10.0.3", + "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 --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/`. +- 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.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() + 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() = "./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())) + 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()) + | _ -> () + | _ -> () + + 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())) + |> Set.ofSeq + for pluginRoot in pluginRoots do + let manifest = loadObject (manifestPath pluginRoot) + let name = manifest["name"].GetValue() + if not (existing.Contains(name)) then + let category = + match manifest["interface"] with + | :? JsonObject as value when value["category"] <> null -> value["category"].GetValue() + | _ -> "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() + 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() = "Agent Plugin Skills") + match pluginGrouping with + | Some grouping -> + let skills = grouping["skills"].AsArray() + if not (skills |> Seq.exists (fun node -> node.GetValue() = "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() + 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() = "./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().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().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(), 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() + 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..432225567 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.", + "version": "10.0.3", + "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/.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..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,27 +18,19 @@ 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 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 / -codex plugin marketplace upgrade -``` - -Use explicit refs such as `/@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 /` and `codex plugin marketplace upgrade `. Explicit refs such as `/@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/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/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 `. + 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 +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 + 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 in if root.TryGetProperty("version", &value) then value.ToString() else "" +if format = "json" then + let serializedMatches = matches |> Array.map (fun item -> JsonSerializer.Deserialize(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 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 `/@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/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/.codex-plugin/plugin.json b/plugins/agentdeck/.codex-plugin/plugin.json index a5405da54..c86c2f74e 100644 --- a/plugins/agentdeck/.codex-plugin/plugin.json +++ b/plugins/agentdeck/.codex-plugin/plugin.json @@ -1,13 +1,12 @@ { "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", "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/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/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/android-dev-skills/.codex-plugin/plugin.json b/plugins/android-dev-skills/.codex-plugin/plugin.json index 8a59ceeea..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", @@ -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..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", @@ -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-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/.codex-plugin/plugin.json b/plugins/apple-dev-skills/.codex-plugin/plugin.json index 05f4603b8..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", @@ -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/.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..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 -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 +```text +just repo-validate +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/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..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 -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 +```text +just repo-validate +just test ``` ## Repo Structure diff --git a/plugins/apple-dev-skills/ROADMAP.md b/plugins/apple-dev-skills/ROADMAP.md index 3f06d480c..c60a3a2ad 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 @@ -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/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..fcaa42cc8 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,20 +44,20 @@ 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`. -2. Run `uv run python -B -m pytest plugins/apple-dev-skills/tests -o cache_dir=.codex/.cache/pytest`. +1. From the Socket root, run `just repo-validate`. +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/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 `, 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 886325a7c..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 @@ -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,16 +221,7 @@ 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`. - -`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, @@ -253,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 @@ -263,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 ` 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 `, 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 `. -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 `. -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 `, 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 `, 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 `. -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 `. -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 `. -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 `. -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 --file-prefix `. +2. Run `scripts/run-workflow.fsx --name --file-prefix `. The default creates iOS and macOS targets, their Swift Testing and XCUITest bundles, plus `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 ` instead of + `scripts/run-workflow.fsx --operation align --repo-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" $"\n\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 = "|\"\)?\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/plugins/reverse-engineering-skills/.codex-plugin/plugin.json b/plugins/reverse-engineering-skills/.codex-plugin/plugin.json index cff43425c..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": { @@ -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/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/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/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 89b5daf0c..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", @@ -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..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", @@ -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/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/.codex-plugin/plugin.json b/plugins/swift-lang/.codex-plugin/plugin.json index e7be04c41..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": { @@ -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/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/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/plugins/web-dev-skills/.codex-plugin/plugin.json b/plugins/web-dev-skills/.codex-plugin/plugin.json index 5e0e1a5f3..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", @@ -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/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/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/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/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..b32b63051 --- /dev/null +++ b/scripts/repo-maintenance/docs/contributing/CONTRIBUTING.template.md @@ -0,0 +1,67 @@ +# 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 + +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 + +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..57e9207aa --- /dev/null +++ b/scripts/repo-maintenance/lib/DocsCoordinator.fsx @@ -0,0 +1,120 @@ +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()}`") + 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("") + 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/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/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/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 new file mode 100644 index 000000000..eefd40c32 --- /dev/null +++ b/scripts/repo-maintenance/syncing/40-repository-skills-exports.fsx @@ -0,0 +1,64 @@ +#!/usr/bin/env -S dotnet fsi + +open System +open System.IO +open System.Text.Json + +let repositoryRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "..")) +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}" + 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 existingNames = + Directory.GetDirectories(Path.Combine(repositoryRoot, "skills")) + |> Array.filter (fun directory -> File.Exists(Path.Combine(directory, "SKILL.md"))) + |> 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 + | [| 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(pluginsRoot, "repository-skills", "shared", "project-docs")) + (Path.Combine(repositoryRoot, "shared", "project-docs")) + +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/scripts/repo-maintenance/validations/50-socket.fsx b/scripts/repo-maintenance/validations/50-socket.fsx new file mode 100644 index 000000000..f535468ea --- /dev/null +++ b/scripts/repo-maintenance/validations/50-socket.fsx @@ -0,0 +1,71 @@ +#!/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" || part = "evals"))) +if not (Array.isEmpty nestedTests) then fail $"Tests must live only at Socket root; found {relative nestedTests[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 E2E tests, and FSX-only 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/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/shared/project-docs/DocsCoordinator.fsx b/shared/project-docs/DocsCoordinator.fsx new file mode 100644 index 000000000..57e9207aa --- /dev/null +++ b/shared/project-docs/DocsCoordinator.fsx @@ -0,0 +1,120 @@ +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()}`") + 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("") + 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.sh.json b/skills.sh.json index 5ff51e9e8..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" ] }, @@ -125,9 +129,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-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/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/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/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/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 new file mode 100644 index 000000000..402caadd4 --- /dev/null +++ b/tests/repository-maintenance-e2e.fsx @@ -0,0 +1,148 @@ +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 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 + +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 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 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) + |> 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" || 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}"))) + |> 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 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 = + 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}" +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" + +printfn "Repository-maintenance end-to-end test passed." +printfn "Temporary fixture: %s" testRoot 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) 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" }, -]