diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9de142b..34fbab7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,8 +12,8 @@ permissions: contents: read jobs: - validate-skills-consistency: - name: validate-skills-consistency + validate-repository: + name: validate-repository runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -29,7 +29,6 @@ jobs: - name: Validate repository env: REPOSITORY_VALIDATION_EVIDENCE_FILE: ${{ runner.temp }}/repository-validation.log - REPOSITORY_VALIDATION_RENDER_DIR: ${{ runner.temp }}/ceratops-skills run: python scripts/validate-repository.py - name: Upload validation evidence diff --git a/AGENTS.history.json b/AGENTS.history.json index f88b214..750475d 100644 --- a/AGENTS.history.json +++ b/AGENTS.history.json @@ -210,6 +210,46 @@ "decision": "Narrow the skills repository's sole release-batch identity from arbitrary release/* branches to release/local and require owning helpers to reject alternatives.", "reason": "One canonical release line prevents task-named release branches from becoming reusable or retained release state.", "regression": "Continue allowing main for normal installed-source checkout, keep task work in thread worktrees, and do not ship or fast-change from any branch other than release/local." + }, + { + "rules": [ + "SKILLS-FAST-01" + ], + "decision": "Remove the retired preview rule from SKILLS-FAST-01's override relation while preserving its worktree and staging overrides.", + "reason": "An override must not reference a deleted rule, and fast-change still needs only its two surviving exceptions.", + "regression": "Preserve the complete fast-change action wording and its overrides of SKILLS-WORKTREE-01 and SKILLS-STAGE-01." + }, + { + "rules": [ + "SKILLS-PREVIEW-01" + ], + "decision": "Retire SKILLS-PREVIEW-01 without replacement deployment prose.", + "reason": "The lifecycle action and deterministic helpers own preview execution; duplicating that flow in repository policy creates source-of-truth drift.", + "regression": "Preserve the checkout, staging, creation, and shipping rules that independently constrain preview-related state changes." + }, + { + "rules": [ + "SKILLS-STAGE-01" + ], + "decision": "Remove SKILLS-STAGE-01's relation to the retired preview rule while preserving its authorization gate.", + "reason": "The limits edge becomes invalid when SKILLS-PREVIEW-01 is removed and does not add an independent constraint.", + "regression": "Continue requiring an explicit staging, shipping, or local-preview-sync request before staging skill changes." + }, + { + "rules": [ + "SKILLS-SHIP-02" + ], + "decision": "Retire SKILLS-SHIP-02 without replacement deployment prose.", + "reason": "Repository-lifecycle routing and its deterministic promote and ship actions already own this behavior.", + "regression": "Preserve release/local shipping, batch authorization, and GitHub workflow constraints in the surviving rules." + }, + { + "rules": [ + "SKILLS-DEPLOY-01" + ], + "decision": "Retire SKILLS-DEPLOY-01 without replacement deployment prose.", + "reason": "Deployment schema, lifecycle actions, and deterministic helpers are the executable owners; a repository rule would duplicate their contract.", + "regression": "Do not weaken surviving checkout, staging, creation, shipping, or section-manifest ownership rules." } ] } diff --git a/AGENTS.md b/AGENTS.md index ed36ffa..7eb3bb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,19 +28,13 @@ Project-specific rules for this skills repository. whenever its action contract and one-request orchestrator accept the complete intended scope. The action may update the verified primary `release/local` checkout and install its selected skills; otherwise use `update`. - - overrides: SKILLS-WORKTREE-01, SKILLS-PREVIEW-01, SKILLS-STAGE-01 + - overrides: SKILLS-WORKTREE-01, SKILLS-STAGE-01 - [SKILLS-FAST-02] An accepted rules-only `fast-change` uses its action contract instead of the ordinary instruction-edit verification requirement. - overrides: SKILLS-VERIFY-01 -- [SKILLS-PREVIEW-01] Keep installed Ceratops skill folders generated from the - skills repo checkout, not task worktrees. For an unpublished local preview, - use `$ceratops-repo-lifecycle` action `promote-and-deploy`; it must assemble - `release/local` before running `deploy/deploy.yml`. - - self: list-heavy - [SKILLS-STAGE-01] Stage skill-source changes into `release/local` only when the task explicitly requests staging, shipping, or local preview sync. - - limits: SKILLS-PREVIEW-01 - self: gate - [SKILLS-SHIP-01] Skills-repo changes must ship from `release/local`, never directly from task or feature branches. @@ -49,20 +43,11 @@ Project-specific rules for this skills repository. `$ceratops-repo-lifecycle` action `promote-and-deploy` and finish with deployment verification. - overrides: SKILLS-STAGE-01 -- [SKILLS-SHIP-02] For promotion or shipping, use - `$ceratops-repo-lifecycle` action `promote`, `promote-and-deploy`, or `ship`. - `ship` must update the checkout from `origin/main` after merge, run - `deploy/deploy.yml`, and report retained worktrees or release branches. - - self: list-heavy - [SKILLS-BATCH-01] Treat an explicit request to promote or ship `release/local` as authorization for every commit currently on that branch; do not request per-commit inclusion confirmation. - [SKILLS-SHIP-03] Treat GitHub replies, thread resolutions, and review submissions required by the active Ceratops workflow as pre-approved. -- [SKILLS-DEPLOY-01] Use `deploy/deploy.yml` as this repository's only - executable deployment contract. Treat - `skills/ceratops-repo-lifecycle/references/deploy-template.yml` as a reusable, - non-executable template; never execute prose deployment instructions. - [SKILLS-SECTIONS-01] Keep the live section manifest at `skills/skill-sections.json`, its declared sources under `skills/sections/`, and the reusable `skill-sections-template.json` in diff --git a/CHANGELOG.md b/CHANGELOG.md index d84b341..f5ca621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Made update execution collect every declared pytest node during prepare so + missing classes or functions fail before source edits without running tests. - Made artifact classification treat Python project manifests as buildable but non-publishing until a publish workflow or explicit artifact contract supplies external intent, so no-artifact repositories skip registry identity diff --git a/README.md b/README.md index 247adfe..2d53485 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Reusable Ceratops skills for Codex and other `SKILL.md`-compatible agents. | `ceratops-repo-lifecycle` | Route repository lifecycle work across local promotion, structured deployment, guarded shipping, GitHub creation, contracts, health, dependencies, and PR merge actions. | | `ceratops-governance-lifecycle` | Route prompt optimization, advisory skill optimization, regression-safe instruction updates, and cross-scope governance consistency audits across action references. | | `ceratops-credit-savings-analysis` | Analyze recent Codex runs for avoidable credit spend and recommend low-maintenance controls. | -| `ceratops-skill-lifecycle` | Route skill-domain work across create, make-repo-compatible, preferred eligible fast-change, update, skills-contract-review, and skills-consistency-review actions. | +| `ceratops-skill-lifecycle` | Route skill-domain work across create, make-repo-compatible, deploy, preferred eligible fast-change, update, skills-contract-review, and skills-consistency-review actions. | | `ceratops-automation-run` | Run recurring automations with shared Ceratops alert, memory, and completion policy. | | `ceratops-task-lifecycle` | Route same-thread task resume, whole-task handoff, and closure-check work across action references. | | `ceratops-code-consistency-audit` | Audit merged refactors for contradictions, docs drift, comment sufficiency, stale follow-through, and merged-only edge cases. | @@ -34,9 +34,9 @@ skills/ deploy/ deploy.yml -skills/ceratops-repo-lifecycle/references/ +skills/ceratops-skill-lifecycle/references/templates/ deploy-template.yml -skills/ceratops-skill-lifecycle/scripts/templates/ + install-skills-bootstrap-template.py skill-sections-template.json ``` @@ -53,7 +53,8 @@ Skill names are independent of the profile and need no `ceratops-` prefix. skills that select among multiple action references. The `skills/` tree is authoritative skill source for this repository. `deploy/deploy.yml` is its authoritative structured deployment definition. -The templates under `skills/ceratops-repo-lifecycle/references/` are reusable +The templates under +`skills/ceratops-skill-lifecycle/references/templates/` are reusable skeletons to copy into other repositories, not live configuration. `agents/openai.yaml` is Codex UI metadata and may be ignored by other agents. Each Ceratops skill declares the runtime-local icon path @@ -72,9 +73,10 @@ The `skills-consistency-review` action audits one direct manifest-backed installed skill, regardless of its name, against the contracts and checks its coupled metadata, action references, automation consumers, helpers, installer, generated runtime, source, and docs. Each runtime manifest records schema, -skill, source identity, source path, local source-repository root, validation -profile, and installer version. Skill consistency review compares only parsed -`INSTALLER_VERSION` values before the selected installed-skill check. +skill, source identity, source path, local source-repository root, and +validation profile. Bootstrap synchronization compares only parsed +`INSTALLER_VERSION` values; ordinary runtime compatibility uses the manifest +schema. The `global-skills-consistency-review` automation uses the lifecycle runtime inventory helper to enumerate every direct manifest-backed skill under `$CODEX_HOME/skills`, then invokes the single-skill action once per valid entry @@ -84,15 +86,15 @@ without repository deduplication. | Script | Caller And Timing | | --- | --- | -| `scripts/install-skills.py` | Executes a temporary snapshot of the installed lifecycle runtime outside the managed destination for AI-Agent-Skills, then runs one independent checkout fallback when that runtime is unavailable or unsuccessful. | +| `scripts/install-skills-bootstrap.py` | Self-contained first-install bootstrap; stages and validates one complete selected batch under the install root and never calls lifecycle runtime code. | | `scripts/validate-repository.py` | Sole full local-and-CI validation owner; captures child output and writes first-failure evidence to a caller-selected file. | -| `skills/ceratops-skill-lifecycle/scripts/templates/install-skills-template.py` | Authoritative standard-library-only installer copied into compatible repositories as `scripts/install-skills.py`; it only resolves shared sections and copies rendered skills. | -| `skills/ceratops-skill-lifecycle/scripts/materialize-compatible-repo.py` | Deterministically instantiates a compatible repository's live section manifest, preserves valid target identity and custom assignments, aligns canonical sections and generated markers, then delegates installer synchronization and validation with rollback on caught blockers. | -| `skills/ceratops-skill-lifecycle/scripts/templates/skill-sections-template.json` | Repository-neutral template for materializing a target repository's live `skills/skill-sections.json`; never a live manifest. | +| `skills/ceratops-skill-lifecycle/references/templates/install-skills-bootstrap-template.py` | Authoritative standard-library-only bootstrap copied into compatible skill repositories as `scripts/install-skills-bootstrap.py`. | +| `skills/ceratops-skill-lifecycle/scripts/materialize-compatible-repo.py` | Deterministically instantiates compatible surfaces and their default managed-skill handoff, synchronizes and source-validates only skill-bearing repositories, and rolls back caught blockers. | +| `skills/ceratops-skill-lifecycle/references/templates/skill-sections-template.json` | Repository-neutral template for materializing a target repository's live `skills/skill-sections.json`; never a live manifest. | | `skills/ceratops-skill-lifecycle/scripts/runtime/install-managed-skills.py` | Classifies explicit, promotion-relative, or all-managed affected sets; owns direct-manifest inventory; and invokes one runtime transaction without source validation. | -| `skills/ceratops-skill-lifecycle/scripts/runtime/synchronize-installers.py` | Copies the authoritative installer into an approved task worktree only when its parsed version is missing or lower, then runs explicit full source validation. | +| `skills/ceratops-skill-lifecycle/scripts/synchronize-bootstrap-installer.py` | Copies the authoritative bootstrap into an approved task worktree only when its parsed version is missing or lower; callers own validation. | | `skills/ceratops-skill-lifecycle/scripts/runtime/managed_runtime_builder.py` | Stages, activates, rolls back, recovers, and cleans one locked selected-skill runtime transaction. | -| `skills/ceratops-skill-lifecycle/scripts/update-execution.py` | Records a task-worktree baseline, verifies declared cohesive update paths, runs closed structured checks once, and writes detailed evidence while preserving unrelated dirty state. | +| `skills/ceratops-skill-lifecycle/scripts/update-execution.py` | Collects declared pytest nodes before edits, records a task-worktree baseline, verifies declared cohesive update paths, runs closed structured checks once, and writes detailed evidence while preserving unrelated dirty state. | | `skills/ceratops-credit-savings-analysis/scripts/model-call-ledger.py` | Writes fingerprint, usage, and opt-in selected-semantic evidence; emits compact rankings or receipts; and preserves artifact-free closure and classification modes. | | `skills/ceratops-task-lifecycle/scripts/closure_snapshot.py` | Emits one compact snapshot for explicitly named closure targets and optionally removes exact task-created files validated inside the task temp root. | | `skills/ceratops-governance-lifecycle/scripts/apply_rules_update.py` | Applies one approved coupled rules/history request with stale-text checks, shared validation, rollback, and compact output. | @@ -101,12 +103,12 @@ without repository deduplication. | `skills/ceratops-governance-lifecycle/scripts/rule_graph.py` | Parses canonical AGENTS rules and rejects structural syntax or rule-local explicit-user override escape clauses. | | `skills/ceratops-repo-lifecycle/scripts/github_contract_engine/` | Package CLI for compact local audit snapshots, contract evaluation, shared GitHub API access, sanitized evidence, and evidence-gated CodeQL disposition. | | `skills/ceratops-repo-lifecycle/scripts/github_pr_workflow/` | Package CLI for individual PR operations, exact-commit checkpointed shipping, a scoped pending-work pre-push guard, concurrent gates, integrated post-gate admin merge, reusable-branch restoration, and terminal checkpoint cleanup. Standalone merge behavior is unchanged. | -| `skills/ceratops-repo-lifecycle/scripts/promote-repository.py` | Prepares `release/local` for fast-change, or fast-forwards selected committed task branches and either stops or runs one named deployment operation. | +| `skills/ceratops-repo-lifecycle/scripts/promote-repository.py` | Prepares `release/local`, or promotes selected branches, runs one named operation, and returns its declared handoff only when the promoted manifest has managed skills. | | `skills/ceratops-repo-lifecycle/scripts/manage-pending-work.py` | Records, checks, and progressively finalizes the exact selected branch and worktree scope used by promotion and shipping. | -| `skills/ceratops-repo-lifecycle/scripts/run-deploy-operation.py` | Validates `deploy/deploy.yml`, resolves its exact declared parameters, and executes one operation as ordered argv steps without a shell. | -| `skills/ceratops-repo-lifecycle/scripts/ship-repository.py` | Orchestrates scoped pre-push checking, guarded GitHub shipping, main synchronization, a pre-deploy recheck, checkpointed `after_ship`, and resumable selected-source cleanup. | +| `skills/ceratops-repo-lifecycle/scripts/run-deploy-operation.py` | Validates `deploy/deploy.yml`, executes ordered argv steps without a shell, and returns any optional declarative agent handoff. | +| `skills/ceratops-repo-lifecycle/scripts/ship-repository.py` | Orchestrates scoped pre-push checking, guarded GitHub shipping, main synchronization, a pre-deploy recheck, checkpointed optional `deploy`, and resumable selected-source cleanup. | | `skills/ceratops-skill-lifecycle/scripts/fast-change.py` | Classifies and owns one eligible direct-release patch through declared Markdown lint, exact helper tests, targeted installation, commit, and failure compensation. | -| `skills/ceratops-skill-lifecycle/scripts/skills-consistency-source-validator.py` | Source validator plus deterministic authoritative installer-version synchronization. | +| `skills/ceratops-skill-lifecycle/scripts/skills-consistency-source-validator.py` | Profile-aware source validator used by explicit maintenance and skill-bearing compatibility materialization. | Lifecycle helpers suppress successful subcommand output and print only compact JSON on success. This repo keeps scripts only where they add reusable safety @@ -123,9 +125,11 @@ compensation. Promotion and deployment are separate repository actions. `promote` assembles the selected branches into `release/local` without deployment; -`promote-and-deploy` additionally runs the contract's `after_promote` -operation. Integrated deployment paths execute only operations named by the -live contract. The runner never converts prose instructions into commands. +`promote-and-deploy` additionally runs the contract's optional `deploy` +operation and executes its returned handoff when the promoted manifest has +managed skills. Managed skills without a declared handoff are reported as not +deployed without changing the repository deployment result. +The runner never converts prose instructions into commands. `ship` takes either an exact pending-work scope or an explicit disabled-check mode. When enabled, the same generic scope is checked before the first remote @@ -309,35 +313,32 @@ Codex discovers personal skills from: $CODEX_HOME/skills//SKILL.md ``` -Install the lifecycle runtime dependency and run the installer from the repo -root: +Install the runtime dependency, then use the bootstrap only for the first +installation: ```powershell python -m pip install -r requirements-runtime.txt -python .\scripts\install-skills.py +python .\scripts\install-skills-bootstrap.py ``` -With installed Ceratops lifecycle skills, the installer copies their runtime to -a temporary directory and uses it for the managed transaction, allowing the -installed lifecycle itself to be replaced safely. If that path is unavailable -or unsuccessful, it runs the checkout's independent installer once. The -fallback performs an ordinary full -or explicitly selected reinstall; it does not preserve removal or -base-revision selection. +The bootstrap is self-contained and never calls installed lifecycle code. It +stages the complete selected batch in a uniquely named hidden directory under +the install root, validates it, refuses existing destinations, activates it, +and cleans only bootstrap-owned state. For every later deployment, use +`$ceratops-skill-lifecycle` `deploy`, which invokes the managed runtime +transaction directly. For another Ceratops-compatible repo, run its versioned repository installer: ```powershell -python \scripts\install-skills.py --repo-root +python \scripts\install-skills-bootstrap.py --repo-root ``` -An external repository's copied installer is independent: it uses only the +An external repository's copied bootstrap is independent: it uses only the Python standard library, reads declared skills, resolves shared sections and -payloads, fully stages the requested output, and replaces same-source -destinations. It does not locate or run Ceratops, validate repository lifecycle -policy, negotiate compatibility, recover transactions, or fall back after an -error. Managing an external repository through Ceratops is optional and -requires separately installed Ceratops skills. +payloads, fully stages the requested output under the install root, and refuses +existing destinations. It does not locate or run Ceratops, validate repository +lifecycle policy, negotiate compatibility, or fall back after an error. For report-only global routing, the runtime installer can write direct managed manifest entries and malformed-entry blockers without comparing runtime files @@ -351,16 +352,16 @@ Installed Ceratops skills should be generated from the skills repo checkout: the local skills repo checkout used as the input path for the runtime installer. The active branch only selects which repo snapshot is installed: synced `main` for normal use, or `release/local` for an active unpublished preview. -After changing the installed source snapshot, use the installed lifecycle skill -to refresh it; if that path is unavailable or unsuccessful, rerun `python -scripts/install-skills.py --repo-root ` once. +After changing the installed source snapshot, use the installed lifecycle +skill's `deploy` action to refresh it; never use bootstrap as a reinstall path. When shipping a staged batch, reuse the same `release/local` branch name locally and remotely by default. Use `$ceratops-repo-lifecycle` `promote` to assemble selected reviewed branches without installation, or `promote-and-deploy` to run -the live contract's `after_promote` operation. Use `ship` for the complete +the optional live `deploy` operation and any returned handoff. Use `ship` for +the complete scoped pre-push check, exact-commit PR publication, readiness and review gates, -final merge, main synchronization, `after_ship`, late recheck, and -selected-source cleanup workflow. +final merge, main synchronization, optional repository deployment, +returned-handoff handling, late recheck, and selected-source cleanup workflow. Restart Codex after adding new skill folders if the app does not pick them up automatically. @@ -379,23 +380,24 @@ with `$skill-name`. ## Validate -Install the declared Python and Node development dependencies, select temporary -rendering and evidence paths, then run the same repository validator used by +Install the declared Python and Node development dependencies, optionally +select a failure-evidence path, then run the same repository validator used by CI: ```powershell npm ci python -m pip install -r requirements-dev.txt -$env:REPOSITORY_VALIDATION_RENDER_DIR = Join-Path $env:TEMP "ceratops-skills" $env:REPOSITORY_VALIDATION_EVIDENCE_FILE = Join-Path $env:TEMP "repository-validation.log" python scripts/validate-repository.py ``` The validator runs Markdown and YAML lint, mypy for Linux and Win32, pytest, -full source-contract validation, managed-skill rendering, and repository -lifecycle CLI smoke checks. Full source validation checks the live deployment -contract, its repository-bounded working directories, required Ceratops -operations, and both reusable template skeletons without executing deployment. +full source-contract validation, and repository lifecycle CLI smoke checks. +Full source validation checks the kind-qualified version 1 deployment contract, +repository-bounded working directories, the Ceratops `deploy` validation step, +and reusable template skeletons without executing deployment. Runtime rendering +is owned only by bootstrap and managed deployment under the selected install +root. Targeted installation validates only explicitly selected skills and their rendering inputs: diff --git a/deploy/deploy.yml b/deploy/deploy.yml index 266b604..f23ae95 100644 --- a/deploy/deploy.yml +++ b/deploy/deploy.yml @@ -1,23 +1,11 @@ version: 1 +kind: ceratops-deploy operations: - after_promote: - parameters: - - base_revision + deploy: + handoff: ceratops-skill-lifecycle/deploy + bootstrap: steps: - - id: type-check + - id: bootstrap-skills run: - python - - -m - - mypy - - id: install-managed-skills - run: - - python - - scripts/install-skills.py - - --base-revision - - "{base_revision}" - after_ship: - steps: - - id: install-managed-skills - run: - - python - - scripts/install-skills.py + - scripts/install-skills-bootstrap.py diff --git a/scripts/install-skills-bootstrap.py b/scripts/install-skills-bootstrap.py new file mode 100644 index 0000000..0e0c2e0 --- /dev/null +++ b/scripts/install-skills-bootstrap.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +"""Bootstrap this repository's declared skills without lifecycle dependencies. + +This first-install-only helper stages one complete selected batch in a uniquely +named hidden directory under the install root. It validates that batch before +activation, never replaces an existing skill, and cleans only staging and lock +paths that it created. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import shutil +import stat +import sys +import uuid +from collections.abc import Mapping, Sequence +from typing import cast + + +INSTALLER_VERSION = 10 +MANIFEST_NAME = ".runtime-manifest.json" +RUNTIME_MANIFEST_SCHEMA = "ceratops-runtime-skill.v3" +START = "" +END = "" +SOURCE_PREFIX = "" +LOCK_NAME = ".ceratops-bootstrap.lock" +STAGE_RE = re.compile(r"^\.ceratops-bootstrap-stage-[0-9a-f]{32}$") +SKILL_NAME_RE = re.compile( + r"^(?![a-z0-9-]*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$" +) +IGNORED_NAMES = { + ".git", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "node_modules", +} + + +def fail(message: str) -> int: + """Emit one concise fatal error.""" + + print(message, file=sys.stderr) + return 1 + + +def safe_relative(value: str) -> bool: + """Accept only repository-relative manifest paths and patterns.""" + + posix = pathlib.PurePosixPath(value.replace("\\", "/")) + windows = pathlib.PureWindowsPath(value) + return bool( + value + and not posix.is_absolute() + and not windows.is_absolute() + and not windows.drive + and ".." not in posix.parts + ) + + +def unsafe_link(path: pathlib.Path) -> bool: + """Reject links and Windows reparse points from copied input.""" + + if path.is_symlink(): + return True + if os.name != "nt": + return False + attributes = getattr( + path.stat(follow_symlinks=False), "st_file_attributes", 0 + ) + return bool( + attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + ) + + +def require_inside(path: pathlib.Path, root: pathlib.Path) -> None: + """Reject any resolved path that escapes its declared root.""" + + path.resolve(strict=False).relative_to(root.resolve()) + + +def validate_tree(root: pathlib.Path) -> None: + """Reject links or reparse points anywhere in one staged tree.""" + + if unsafe_link(root): + raise ValueError(f"unsafe staged tree root: {root}") + for path in root.rglob("*"): + if unsafe_link(path): + raise ValueError(f"unsafe staged tree entry: {path}") + + +def read_manifest(repo_root: pathlib.Path) -> dict[str, object]: + """Read and validate the declarations required to render every skill.""" + + path = repo_root / "skills" / "skill-sections.json" + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("skill-sections.json must contain an object") + source_id = value.get("runtime_source_id") + profile = value.get("validation_profile", "ceratops-compatible") + sections = value.get("sections") + skills = value.get("skills") + payloads = value.get("runtime_payloads", {}) + if not isinstance(source_id, str) or not source_id.strip(): + raise ValueError("runtime_source_id must be a nonempty string") + if profile not in {"ceratops", "ceratops-compatible"}: + raise ValueError("validation_profile is unsupported") + if not isinstance(sections, dict) or not all( + isinstance(name, str) and isinstance(relative, str) + for name, relative in sections.items() + ): + raise ValueError("sections must map strings to strings") + if not isinstance(skills, dict) or not all( + isinstance(name, str) and isinstance(selected, list) + for name, selected in skills.items() + ): + raise ValueError("skills must map names to section lists") + if not isinstance(payloads, dict): + raise ValueError("runtime_payloads must be an object") + source_names = { + skill.parent.name + for skill in (repo_root / "skills").glob("*/SKILL.md") + } + if set(skills) != source_names: + raise ValueError("skill assignments must match source SKILL.md folders") + return value + + +def declared_skills( + manifest: Mapping[str, object], requested: Sequence[str] +) -> list[str]: + """Resolve the exact declared skill set before staging output.""" + + assignments = cast(Mapping[str, object], manifest["skills"]) + names = list(requested) if requested else sorted(assignments) + if len(names) != len(set(names)): + raise ValueError("duplicate --skill selection") + for name in names: + if not isinstance(name, str) or not SKILL_NAME_RE.fullmatch(name): + raise ValueError(f"invalid skill name: {name!r}") + if name not in assignments: + raise ValueError(f"undeclared skill: {name}") + return names + + +def section_block( + repo_root: pathlib.Path, + manifest: Mapping[str, object], + skill: str, +) -> str: + """Resolve one skill's shared sections without lifecycle runtime code.""" + + sections = cast(Mapping[str, object], manifest["sections"]) + assignments = cast(Mapping[str, object], manifest["skills"]) + selected = assignments[skill] + if not isinstance(selected, list) or not selected: + raise ValueError(f"{skill}: section assignment must be a nonempty list") + rendered: list[str] = [] + for name in selected: + if not isinstance(name, str) or name not in sections: + raise ValueError(f"{skill}: unresolved section {name!r}") + relative = sections[name] + if not isinstance(relative, str) or not safe_relative(relative): + raise ValueError(f"{skill}: invalid section path {relative!r}") + path = repo_root / relative + require_inside(path, repo_root) + if not path.is_file() or unsafe_link(path): + raise ValueError(f"{skill}: unavailable section {relative}") + lines = path.read_text(encoding="utf-8").splitlines() + text = "\n".join( + line + for line in lines + if not line.strip().startswith("" +END = "" +SOURCE_PREFIX = "" +LOCK_NAME = ".ceratops-bootstrap.lock" +STAGE_RE = re.compile(r"^\.ceratops-bootstrap-stage-[0-9a-f]{32}$") +SKILL_NAME_RE = re.compile( + r"^(?![a-z0-9-]*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$" +) +IGNORED_NAMES = { + ".git", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "node_modules", +} + + +def fail(message: str) -> int: + """Emit one concise fatal error.""" + + print(message, file=sys.stderr) + return 1 + + +def safe_relative(value: str) -> bool: + """Accept only repository-relative manifest paths and patterns.""" + + posix = pathlib.PurePosixPath(value.replace("\\", "/")) + windows = pathlib.PureWindowsPath(value) + return bool( + value + and not posix.is_absolute() + and not windows.is_absolute() + and not windows.drive + and ".." not in posix.parts + ) + + +def unsafe_link(path: pathlib.Path) -> bool: + """Reject links and Windows reparse points from copied input.""" + + if path.is_symlink(): + return True + if os.name != "nt": + return False + attributes = getattr( + path.stat(follow_symlinks=False), "st_file_attributes", 0 + ) + return bool( + attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + ) + + +def require_inside(path: pathlib.Path, root: pathlib.Path) -> None: + """Reject any resolved path that escapes its declared root.""" + + path.resolve(strict=False).relative_to(root.resolve()) + + +def validate_tree(root: pathlib.Path) -> None: + """Reject links or reparse points anywhere in one staged tree.""" + + if unsafe_link(root): + raise ValueError(f"unsafe staged tree root: {root}") + for path in root.rglob("*"): + if unsafe_link(path): + raise ValueError(f"unsafe staged tree entry: {path}") + + +def read_manifest(repo_root: pathlib.Path) -> dict[str, object]: + """Read and validate the declarations required to render every skill.""" + + path = repo_root / "skills" / "skill-sections.json" + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("skill-sections.json must contain an object") + source_id = value.get("runtime_source_id") + profile = value.get("validation_profile", "ceratops-compatible") + sections = value.get("sections") + skills = value.get("skills") + payloads = value.get("runtime_payloads", {}) + if not isinstance(source_id, str) or not source_id.strip(): + raise ValueError("runtime_source_id must be a nonempty string") + if profile not in {"ceratops", "ceratops-compatible"}: + raise ValueError("validation_profile is unsupported") + if not isinstance(sections, dict) or not all( + isinstance(name, str) and isinstance(relative, str) + for name, relative in sections.items() + ): + raise ValueError("sections must map strings to strings") + if not isinstance(skills, dict) or not all( + isinstance(name, str) and isinstance(selected, list) + for name, selected in skills.items() + ): + raise ValueError("skills must map names to section lists") + if not isinstance(payloads, dict): + raise ValueError("runtime_payloads must be an object") + source_names = { + skill.parent.name + for skill in (repo_root / "skills").glob("*/SKILL.md") + } + if set(skills) != source_names: + raise ValueError("skill assignments must match source SKILL.md folders") + return value + + +def declared_skills( + manifest: Mapping[str, object], requested: Sequence[str] +) -> list[str]: + """Resolve the exact declared skill set before staging output.""" + + assignments = cast(Mapping[str, object], manifest["skills"]) + names = list(requested) if requested else sorted(assignments) + if len(names) != len(set(names)): + raise ValueError("duplicate --skill selection") + for name in names: + if not isinstance(name, str) or not SKILL_NAME_RE.fullmatch(name): + raise ValueError(f"invalid skill name: {name!r}") + if name not in assignments: + raise ValueError(f"undeclared skill: {name}") + return names + + +def section_block( + repo_root: pathlib.Path, + manifest: Mapping[str, object], + skill: str, +) -> str: + """Resolve one skill's shared sections without lifecycle runtime code.""" + + sections = cast(Mapping[str, object], manifest["sections"]) + assignments = cast(Mapping[str, object], manifest["skills"]) + selected = assignments[skill] + if not isinstance(selected, list) or not selected: + raise ValueError(f"{skill}: section assignment must be a nonempty list") + rendered: list[str] = [] + for name in selected: + if not isinstance(name, str) or name not in sections: + raise ValueError(f"{skill}: unresolved section {name!r}") + relative = sections[name] + if not isinstance(relative, str) or not safe_relative(relative): + raise ValueError(f"{skill}: invalid section path {relative!r}") + path = repo_root / relative + require_inside(path, repo_root) + if not path.is_file() or unsafe_link(path): + raise ValueError(f"{skill}: unavailable section {relative}") + lines = path.read_text(encoding="utf-8").splitlines() + text = "\n".join( + line + for line in lines + if not line.strip().startswith("" END = "" SOURCE_RE = re.compile(r"") @@ -38,6 +44,15 @@ ) +class IndentedSafeDumper(yaml.SafeDumper): + """Emit block sequences indented beneath their mapping keys.""" + + def increase_indent( + self, flow: bool = False, indentless: bool = False + ) -> object: + return super().increase_indent(flow, False) + + @dataclass(frozen=True) class FileSnapshot: """Exact recoverable state for one file the helper may change.""" @@ -54,6 +69,7 @@ class MaterializationPlan: manifest: dict[str, object] skill_updates: dict[pathlib.Path, tuple[str, str]] canonical_sources: dict[str, pathlib.Path] + deploy_contract: dict[str, object] | None skills: list[str] updated_markers: list[str] @@ -100,6 +116,17 @@ def load_mapping(path: pathlib.Path) -> dict[str, object]: return value +def load_yaml_mapping(path: pathlib.Path) -> dict[str, object]: + """Load one YAML mapping without constructing custom objects.""" + + value = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or not all( + isinstance(key, str) for key in value + ): + raise RuntimeError(f"YAML root must be a string-keyed object: {path}") + return value + + def validate_template(template: Mapping[str, object]) -> None: """Require the closed repository-neutral compatibility skeleton.""" @@ -115,6 +142,74 @@ def validate_template(template: Mapping[str, object]) -> None: raise RuntimeError("skill-sections template is not repository-neutral") +def deploy_contract( + repo_root: pathlib.Path, + *, + has_skills: bool, + materialize: bool, +) -> dict[str, object] | None: + """Preserve operations and own default skill bootstrap and handoff entries.""" + + if not materialize: + return None + reusable = load_yaml_mapping(DEPLOY_TEMPLATE) + expected = { + "version": 1, + "kind": "ceratops-deploy", + "operations": {}, + } + if reusable != expected: + raise RuntimeError("deploy template is not the empty version 1 skeleton") + target = repo_root / DEPLOY_RELATIVE + contract = load_yaml_mapping(target) if target.is_file() else dict(reusable) + if contract.get("version") != 1: + raise RuntimeError("existing deploy contract version must remain 1") + if contract.get("kind") != "ceratops-deploy": + raise RuntimeError("existing deploy contract kind must be ceratops-deploy") + operations = contract.get("operations") + if not isinstance(operations, Mapping) or not all( + isinstance(name, str) and isinstance(operation, Mapping) + for name, operation in operations.items() + ): + raise RuntimeError("existing deploy contract operations must be objects") + updated_operations = dict(operations) + if has_skills: + existing_deploy = updated_operations.get("deploy") + updated_deploy = ( + dict(existing_deploy) + if isinstance(existing_deploy, Mapping) + else {} + ) + updated_deploy.setdefault("handoff", MANAGED_SKILL_HANDOFF) + updated_operations["deploy"] = updated_deploy + updated_operations["bootstrap"] = { + "steps": [ + { + "id": "bootstrap-skills", + "run": ["python", "scripts/install-skills-bootstrap.py"], + } + ] + } + else: + updated_operations.pop("bootstrap", None) + existing_deploy = updated_operations.get("deploy") + if ( + isinstance(existing_deploy, Mapping) + and existing_deploy.get("handoff") == MANAGED_SKILL_HANDOFF + ): + updated_deploy = dict(existing_deploy) + updated_deploy.pop("handoff") + if updated_deploy: + updated_operations["deploy"] = updated_deploy + else: + updated_operations.pop("deploy") + return { + "version": 1, + "kind": "ceratops-deploy", + "operations": updated_operations, + } + + def portable_section_path(repo_root: pathlib.Path, value: object) -> pathlib.Path: """Resolve one existing portable section source inside the target repo.""" @@ -262,13 +357,12 @@ def plan_materialization( source_id: str, template: Mapping[str, object], existing: Mapping[str, object], + *, + materialize_deploy: bool, ) -> MaterializationPlan: """Validate target evidence and compose writes without changing files.""" skill_paths = sorted((repo_root / "skills").glob("*/SKILL.md")) - if not skill_paths: - raise RuntimeError("target repository has no skills/*/SKILL.md sources") - skill_names = {path.parent.name for path in skill_paths} custom_sections = existing_custom_sections(repo_root, existing) prior_assignments = existing_skill_assignments( @@ -284,7 +378,7 @@ def plan_materialization( raise RuntimeError("existing runtime_payloads must be an object") assignments: dict[str, list[str]] = {} - required_sections = {"core"} + required_sections: set[str] = {"core"} if skill_paths else set() updated_markers: list[str] = [] skill_updates: dict[pathlib.Path, tuple[str, str]] = {} for skill_path in skill_paths: @@ -339,7 +433,9 @@ def plan_materialization( selected.append(marker_section_name) assignments[skill_path.parent.name] = list(dict.fromkeys(selected)) - sections: dict[str, str] = {"core": "skills/sections/core.md"} + sections: dict[str, str] = {} + if "core" in required_sections: + sections["core"] = "skills/sections/core.md" if "multi-action-skill" in required_sections: sections["multi-action-skill"] = ( "skills/sections/multi-action-skill.md" @@ -350,11 +446,13 @@ def plan_materialization( profile = existing.get("validation_profile", template["validation_profile"]) if profile not in {"ceratops", "ceratops-compatible"}: raise RuntimeError(f"unsupported validation_profile: {profile!r}") - canonical_sections = canonical_sections_root() - canonical_sources = { - section_name: canonical_sections / f"{section_name}.md" - for section_name in required_sections - } + canonical_sources: dict[str, pathlib.Path] = {} + if required_sections: + canonical_sections = canonical_sections_root() + canonical_sources = { + section_name: canonical_sections / f"{section_name}.md" + for section_name in required_sections + } for source in canonical_sources.values(): if not source.is_file(): raise RuntimeError(f"canonical shared section is missing: {source}") @@ -374,6 +472,11 @@ def plan_materialization( manifest=manifest, skill_updates=skill_updates, canonical_sources=canonical_sources, + deploy_contract=deploy_contract( + repo_root, + has_skills=bool(skill_names), + materialize=materialize_deploy, + ), skills=sorted(assignments), updated_markers=sorted(updated_markers), ) @@ -385,10 +488,13 @@ def apply_materialization( ) -> None: """Apply one fully validated plan inside the caller's rollback boundary.""" - sections_dir = repo_root / "skills" / "sections" - sections_dir.mkdir(parents=True, exist_ok=True) - for section_name, source in sorted(plan.canonical_sources.items()): - shutil.copy2(source, sections_dir / f"{section_name}.md") + if plan.canonical_sources: + sections_dir = repo_root / "skills" / "sections" + sections_dir.mkdir(parents=True, exist_ok=True) + for section_name, source in sorted(plan.canonical_sources.items()): + destination = sections_dir / f"{section_name}.md" + if source.resolve() != destination.resolve(): + shutil.copy2(source, destination) for skill_path, (updated, newline) in plan.skill_updates.items(): skill_path.write_text( updated, @@ -402,16 +508,33 @@ def apply_materialization( encoding="utf-8", newline="\n", ) + if plan.deploy_contract is not None: + deploy_path = repo_root / DEPLOY_RELATIVE + deploy_path.parent.mkdir(parents=True, exist_ok=True) + deploy_path.write_text( + yaml.dump( + plan.deploy_contract, + Dumper=IndentedSafeDumper, + sort_keys=False, + ), + encoding="utf-8", + newline="\n", + ) def main() -> int: """Materialize compatibility inputs, synchronize installer, and validate.""" parser = argparse.ArgumentParser( - description="Materialize Ceratops-compatible shared-section sources." + description="Materialize Ceratops-compatible repository sources." ) parser.add_argument("--target-repo-root", required=True, type=pathlib.Path) parser.add_argument("--runtime-source-id") + parser.add_argument( + "--no-deploy-contract", + action="store_true", + help="Leave deploy/deploy.yml absent or unchanged.", + ) args = parser.parse_args() repo_root = args.target_repo_root.resolve() phase = "preflight" @@ -436,41 +559,87 @@ def main() -> int: source_id, template, existing, + materialize_deploy=not args.no_deploy_contract, ) skill_paths = sorted((repo_root / "skills").glob("*/SKILL.md")) - mutable_paths = [ - *skill_paths, - existing_path, - repo_root / "skills" / "sections" / "core.md", - repo_root / "skills" / "sections" / "multi-action-skill.md", - repo_root / INSTALLER_RELATIVE, - ] + mutable_paths = [*skill_paths, existing_path] + mutable_paths.extend( + repo_root / "skills" / "sections" / f"{section_name}.md" + for section_name in plan.canonical_sources + ) + if plan.skills: + mutable_paths.append(repo_root / INSTALLER_RELATIVE) + if plan.deploy_contract is not None: + mutable_paths.append(repo_root / DEPLOY_RELATIVE) snapshots = [snapshot_file(path) for path in dict.fromkeys(mutable_paths)] created_dirs = [ path for path in ( + repo_root / "skills", repo_root / "skills" / "sections", repo_root / "scripts", + repo_root / "deploy", ) if not path.exists() + and ( + path.name != "sections" or bool(plan.canonical_sources) + ) + and ( + path.name not in {"scripts"} or bool(plan.skills) + ) + and ( + path.name not in {"deploy"} + or plan.deploy_contract is not None + ) ] phase = "materialization" mutation_started = True apply_materialization(repo_root, plan) - phase = "installer_validation" - result = subprocess.run( - [sys.executable, str(SYNCHRONIZER), "--target-repo-root", str(repo_root)], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - raise RuntimeError((result.stderr or result.stdout).strip()) - installer = json.loads(result.stdout) - if not isinstance(installer, Mapping) or not isinstance( - installer.get("status"), str - ): - raise RuntimeError("installer synchronizer returned invalid JSON") + bootstrap_status = "skipped" + if plan.skills: + phase = "bootstrap_synchronization" + synchronization = subprocess.run( + [ + sys.executable, + str(SYNCHRONIZER), + "--target-repo-root", + str(repo_root), + ], + capture_output=True, + text=True, + check=False, + ) + if synchronization.returncode != 0: + raise RuntimeError( + (synchronization.stderr or synchronization.stdout).strip() + ) + bootstrap = json.loads(synchronization.stdout) + bootstrap_status_value = ( + bootstrap.get("status") if isinstance(bootstrap, Mapping) else None + ) + if not isinstance(bootstrap_status_value, str): + raise RuntimeError("bootstrap synchronizer returned invalid JSON") + bootstrap_status = bootstrap_status_value + + phase = "source_validation" + validation = subprocess.run( + [ + sys.executable, + str(VALIDATOR), + "--repo-root", + str(repo_root), + "--mode", + "full", + ], + capture_output=True, + text=True, + check=False, + ) + if validation.returncode != 0: + detail = (validation.stderr or validation.stdout).strip() + raise RuntimeError( + f"full source-repository validation failed: {detail}" + ) except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: reason = str(exc) if mutation_started: @@ -496,7 +665,12 @@ def main() -> int: print( json.dumps( { - "installer": installer["status"], + "bootstrap": bootstrap_status, + "deploy_contract": ( + "materialized" + if plan.deploy_contract is not None + else "unchanged" + ), "markers_removed": plan.updated_markers, "rollback": "not_needed", "runtime_source_id": source_id, diff --git a/skills/ceratops-skill-lifecycle/scripts/runtime/install-managed-skills.py b/skills/ceratops-skill-lifecycle/scripts/runtime/install-managed-skills.py index 86d334e..3f18616 100644 --- a/skills/ceratops-skill-lifecycle/scripts/runtime/install-managed-skills.py +++ b/skills/ceratops-skill-lifecycle/scripts/runtime/install-managed-skills.py @@ -39,10 +39,12 @@ "skills", } GLOBAL_RUNTIME_PATHS = { - "scripts/install-skills.py", + "scripts/install-skills-bootstrap.py", "skills/ceratops-skill-lifecycle/scripts/materialize-compatible-repo.py", - "skills/ceratops-skill-lifecycle/scripts/templates/install-skills-template.py", - "skills/ceratops-skill-lifecycle/scripts/templates/skill-sections-template.json", + "skills/ceratops-skill-lifecycle/references/templates/" + "install-skills-bootstrap-template.py", + "skills/ceratops-skill-lifecycle/references/templates/" + "skill-sections-template.json", "skills/ceratops-skill-lifecycle/scripts/runtime/install-managed-skills.py", "skills/ceratops-skill-lifecycle/scripts/runtime/managed_runtime_builder.py", } @@ -427,7 +429,6 @@ def _routing_manifest_errors( "source_path", "source_repository_root", "validation_profile", - "installer_version", } missing = sorted(required - set(manifest)) if missing: @@ -456,13 +457,6 @@ def _routing_manifest_errors( errors.append("source_repository_root must be an absolute path") if manifest.get("validation_profile") not in VALIDATION_PROFILES: errors.append("unsupported runtime validation profile") - version = manifest.get("installer_version") - if ( - not isinstance(version, int) - or isinstance(version, bool) - or version < 1 - ): - errors.append("installer_version must be a positive integer") return errors @@ -542,7 +536,6 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--repo-root", type=pathlib.Path) parser.add_argument("--install-root", type=pathlib.Path) - parser.add_argument("--installer-version", type=int) parser.add_argument("--skill", action="append") parser.add_argument("--remove-skill", action="append") parser.add_argument("--base-revision") @@ -560,7 +553,6 @@ def main(argv: list[str] | None = None) -> int: value is not None for value in ( args.repo_root, - args.installer_version, args.skill, args.remove_skill, args.base_revision, @@ -585,12 +577,8 @@ def main(argv: list[str] | None = None) -> int: print("OK") return 0 - if args.repo_root is None or args.installer_version is None: - raise SystemExit( - "--repo-root and --installer-version are required for installation" - ) - if args.installer_version < 1: - raise SystemExit("--installer-version must be positive") + if args.repo_root is None: + raise SystemExit("--repo-root is required for installation") if args.base_revision is not None and ( args.skill is not None or args.remove_skill is not None ): @@ -613,7 +601,6 @@ def main(argv: list[str] | None = None) -> int: result = runtime_builder.install_transaction( repo_root, install_root, - args.installer_version, selected=() if affected.all_managed else affected.deploy, remove=affected.remove, all_managed=affected.all_managed, diff --git a/skills/ceratops-skill-lifecycle/scripts/runtime/managed_runtime_builder.py b/skills/ceratops-skill-lifecycle/scripts/runtime/managed_runtime_builder.py index 58bf304..0d536ae 100644 --- a/skills/ceratops-skill-lifecycle/scripts/runtime/managed_runtime_builder.py +++ b/skills/ceratops-skill-lifecycle/scripts/runtime/managed_runtime_builder.py @@ -457,7 +457,6 @@ def write_expected_skill( skill_name: str, target_skill: pathlib.Path, manifest: Mapping[str, object], - installer_version: int, *, source_repository_root: pathlib.Path | None = None, ) -> None: @@ -494,7 +493,6 @@ def write_expected_skill( "validation_profile": validation_profile, "source_path": source_dir.relative_to(ROOT).as_posix(), "source_repository_root": str(source_repository_root or ROOT), - "installer_version": installer_version, "generated_from": SECTION_MANIFEST.relative_to(ROOT).as_posix(), "payload_patterns": payload_patterns_for(skill_name, manifest), } @@ -785,7 +783,6 @@ def _rollback( def install_transaction( repo_root: pathlib.Path, install_root: pathlib.Path, - installer_version: int, *, selected: Sequence[str] = (), remove: Sequence[str] = (), @@ -793,10 +790,6 @@ def install_transaction( ) -> TransactionResult: """Install one exact selected batch under a single writer transaction.""" - if installer_version < 1: - raise TransactionError( - "installer version must be positive", phase="preflight" - ) configure_repo(repo_root) manifest = load_manifest() source_names = set(source_skill_names()) @@ -883,9 +876,7 @@ def install_transaction( current_skill = skill staged = install_root / f".{skill}-deployed-{transaction_id}" deployed_paths[skill] = staged - write_expected_skill( - skill, staged, manifest, installer_version - ) + write_expected_skill(skill, staged, manifest) enable_windows_acl_inheritance(staged) staged_manifest = read_runtime_manifest(staged) if ( @@ -961,7 +952,6 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--repo-root", required=True, type=pathlib.Path) parser.add_argument("--install-root", required=True, type=pathlib.Path) - parser.add_argument("--installer-version", required=True, type=int) parser.add_argument("--skill", action="append") parser.add_argument("--remove-skill", action="append") parser.add_argument("--all-managed", action="store_true") @@ -976,7 +966,6 @@ def main(argv: list[str] | None = None) -> int: result = install_transaction( args.repo_root.resolve(), args.install_root.resolve(), - args.installer_version, selected=args.skill or (), remove=args.remove_skill or (), all_managed=args.all_managed, diff --git a/skills/ceratops-skill-lifecycle/scripts/skills-consistency-source-validator.py b/skills/ceratops-skill-lifecycle/scripts/skills-consistency-source-validator.py index dfb082c..af2b412 100644 --- a/skills/ceratops-skill-lifecycle/scripts/skills-consistency-source-validator.py +++ b/skills/ceratops-skill-lifecycle/scripts/skills-consistency-source-validator.py @@ -36,14 +36,18 @@ PROFILE_COMPATIBLE = "ceratops-compatible" VALIDATION_PROFILES = {PROFILE_CERATOPS, PROFILE_COMPATIBLE} ALLOWED_EXTERNAL_PYTHON_MODULES = {"mypy", "pytest", "yamllint"} -BOOTSTRAP_INSTALLER = ROOT / "scripts" / "install-skills.py" -INSTALLER_TEMPLATE = LIFECYCLE_BUNDLE_ROOT / "scripts" / "templates" / "install-skills-template.py" +BOOTSTRAP_INSTALLER = ROOT / "scripts" / "install-skills-bootstrap.py" +INSTALLER_TEMPLATE = ( + LIFECYCLE_BUNDLE_ROOT + / "references" + / "templates" + / "install-skills-bootstrap-template.py" +) DEPLOY_CONTRACT = ROOT / "deploy" / "deploy.yml" DEPLOY_TEMPLATE = ( - ROOT - / "skills" - / "ceratops-repo-lifecycle" + LIFECYCLE_BUNDLE_ROOT / "references" + / "templates" / "deploy-template.yml" ) SKILL_SECTIONS_TEMPLATE = INSTALLER_TEMPLATE.parent / "skill-sections-template.json" @@ -228,7 +232,10 @@ def check_source_installer(profile: str) -> list[str]: errors: list[str] = [] source_version = installer_version(BOOTSTRAP_INSTALLER) if source_version is None: - errors.append("scripts/install-skills.py must declare one positive integer INSTALLER_VERSION") + errors.append( + "scripts/install-skills-bootstrap.py must declare one positive " + "integer INSTALLER_VERSION" + ) if profile != PROFILE_CERATOPS: return errors template_version = installer_version(INSTALLER_TEMPLATE) @@ -286,11 +293,15 @@ def check_deployment_contract( errors.append(f"{schema_label}: invalid deploy contract schema: {exc}") return errors - contract, contract_errors = load_yaml_mapping( - DEPLOY_CONTRACT, - "deploy/deploy.yml", - ) - errors.extend(contract_errors) + contract: dict[str, object] | None = None + if DEPLOY_CONTRACT.is_file(): + contract, contract_errors = load_yaml_mapping( + DEPLOY_CONTRACT, + "deploy/deploy.yml", + ) + errors.extend(contract_errors) + elif profile == PROFILE_CERATOPS: + errors.append("missing deploy/deploy.yml") if contract is not None: validation_errors = sorted( validator.iter_errors(contract), @@ -307,7 +318,7 @@ def check_deployment_contract( operations = cast(Mapping[str, object], contract["operations"]) for operation_name, operation_value in operations.items(): operation = cast(Mapping[str, object], operation_value) - steps = cast(Sequence[object], operation["steps"]) + steps = cast(Sequence[object], operation.get("steps", [])) for index, step_value in enumerate(steps): step = cast(Mapping[str, object], step_value) cwd = step.get("cwd") @@ -327,107 +338,60 @@ def check_deployment_contract( f"{operation_name}.steps[{index}].cwd must be " "repository-relative" ) - if profile == PROFILE_CERATOPS: - for operation_name in ("after_promote", "after_ship"): - if operation_name not in operations: - errors.append( - "deploy/deploy.yml: Ceratops profile requires " - f"operation {operation_name}" - ) - after_promote = operations.get("after_promote") - if isinstance(after_promote, Mapping): - if after_promote.get("parameters") != ["base_revision"]: - errors.append( - "deploy/deploy.yml: after_promote must declare " - "base_revision" - ) - promote_runs = [ - step.get("run") - for step in cast( - Sequence[Mapping[str, object]], - after_promote.get("steps", []), - ) - ] - if not any( - isinstance(argv, list) - and "scripts/install-skills.py" in argv - and "--base-revision" in argv - and "{base_revision}" in argv - for argv in promote_runs - ): - errors.append( - "deploy/deploy.yml: after_promote must pass its " - "base revision to the installer" - ) - for operation_name in ("after_promote", "after_ship"): - selected_operation = operations.get(operation_name) - if not isinstance(selected_operation, Mapping): - continue - runs = [ - step.get("run") - for step in cast( - Sequence[Mapping[str, object]], - selected_operation.get("steps", []), - ) - ] - installer_runs = [ - argv - for argv in runs - if isinstance(argv, list) - and "scripts/install-skills.py" in argv - ] - if len(installer_runs) != 1: - errors.append( - "deploy/deploy.yml: " - f"{operation_name} must run the installer once" - ) - + assignments = manifest.get("skills") + has_skills = isinstance(assignments, Mapping) and bool(assignments) + bootstrap = operations.get("bootstrap") + expected_bootstrap = { + "steps": [ + { + "id": "bootstrap-skills", + "run": [ + "python", + "scripts/install-skills-bootstrap.py", + ], + } + ] + } + if has_skills and bootstrap != expected_bootstrap: + errors.append( + "deploy/deploy.yml: repositories with skills must declare " + "the canonical bootstrap operation" + ) + if not has_skills and bootstrap is not None: + errors.append( + "deploy/deploy.yml: repositories without skills must not " + "declare the bootstrap operation" + ) if profile != PROFILE_CERATOPS: return errors template, template_errors = load_yaml_mapping( DEPLOY_TEMPLATE, - "skills/ceratops-repo-lifecycle/references/deploy-template.yml", + "skills/ceratops-skill-lifecycle/references/templates/" + "deploy-template.yml", ) errors.extend(template_errors) - if template is not None and template != {"version": 1, "operations": {}}: + if template is not None and template != { + "version": 1, + "kind": "ceratops-deploy", + "operations": {}, + }: errors.append( - "skills/ceratops-repo-lifecycle/references/deploy-template.yml " - "must be the empty version 1 " - "deployment skeleton" + "lifecycle deploy template must be the empty version 1 " + "ceratops-deploy skeleton" ) payloads = manifest.get("runtime_payloads") - repository_lifecycle_payloads = ( - payloads.get("ceratops-repo-lifecycle") - if isinstance(payloads, dict) - else None - ) lifecycle_payloads = ( payloads.get("ceratops-skill-lifecycle") if isinstance(payloads, dict) else None ) - deploy_template_path = str(DEPLOY_TEMPLATE.relative_to(ROOT)).replace("\\", "/") - required_repository_payloads = {deploy_template_path} required_lifecycle_payloads = { - deploy_template_path, "skills/sections/*.md", "skills/ceratops-repo-lifecycle/references/schemas/" "deploy-contract.schema.json", } - if not isinstance(repository_lifecycle_payloads, list): - errors.append( - "runtime_payloads.ceratops-repo-lifecycle must include its " - "reusable deployment template" - ) - else: - for payload_path in sorted(required_repository_payloads): - if payload_path not in repository_lifecycle_payloads: - errors.append( - "runtime_payloads.ceratops-repo-lifecycle is missing " - f"{payload_path}" - ) if not isinstance(lifecycle_payloads, list): errors.append( "runtime_payloads.ceratops-skill-lifecycle must include reusable " @@ -1009,7 +973,7 @@ def check_section_sources(manifest: dict[str, object], skill_dirs: list[pathlib. if not isinstance(assignments, dict): errors.append("section manifest skills must be an object") return errors - if "core" not in sections: + if skill_names and "core" not in sections: errors.append("section manifest must define core") for section_name, rel_path in sections.items(): if not isinstance(rel_path, str): @@ -1112,6 +1076,7 @@ def check_resource_layout(skill_dir: pathlib.Path, profile: str) -> list[str]: references_dir, references_dir / "contracts", references_dir / "schemas", + references_dir / "templates", } for path in references_dir.rglob("*"): if path.is_file() and path.parent not in allowed_parents: @@ -1259,7 +1224,7 @@ def main() -> int: global ROOT, SKILLS_DIR, README, SECTION_MANIFEST, CERATOPS_ICON_SOURCE global BOOTSTRAP_INSTALLER - global DEPLOY_CONTRACT, DEPLOY_TEMPLATE + global DEPLOY_CONTRACT parser = argparse.ArgumentParser(description="Validate Ceratops-compatible skill source and runtime-generation inputs.") parser.add_argument("--repo-root", type=pathlib.Path, help="Source skills repository root.") @@ -1278,15 +1243,8 @@ def main() -> int: README = ROOT / "README.md" SECTION_MANIFEST = ROOT / "skills" / "skill-sections.json" CERATOPS_ICON_SOURCE = ROOT / "assets" / "ceratops-logo-500.png" - BOOTSTRAP_INSTALLER = ROOT / "scripts" / "install-skills.py" + BOOTSTRAP_INSTALLER = ROOT / "scripts" / "install-skills-bootstrap.py" DEPLOY_CONTRACT = ROOT / "deploy" / "deploy.yml" - DEPLOY_TEMPLATE = ( - ROOT - / "skills" - / "ceratops-repo-lifecycle" - / "references" - / "deploy-template.yml" - ) errors: list[str] = [] if not SKILLS_DIR.is_dir(): @@ -1314,7 +1272,8 @@ def main() -> int: print(f"ok: skill {len(selected_skill_names)}") return 0 - errors.extend(check_source_installer(profile)) + if skill_dirs: + errors.extend(check_source_installer(profile)) if args.mode == "sections": errors.extend(check_section_sources(manifest, skill_dirs)) errors.extend(check_multi_action_skill_contract(manifest)) @@ -1347,7 +1306,7 @@ def main() -> int: errors.append("section manifest sections must be an object") if not isinstance(assignments_obj, dict): errors.append("section manifest skills must be an object") - if "core" not in sections: + if skill_dirs and "core" not in sections: errors.append("section manifest must define core") if not isinstance(workflow_hints_obj, dict): errors.append("section manifest maintenance_workflows must be an object") @@ -1380,9 +1339,6 @@ def main() -> int: readme_text = README.read_text(encoding="utf-8") if README.is_file() else "" readme_rows = readme_skill_rows(readme_text) - if not skill_dirs: - errors.append("no skill directories found") - skill_names = {skill_dir.name for skill_dir in skill_dirs} if isinstance(workflow_hints, dict): for workflow_name, commands in workflow_hints.items(): diff --git a/skills/ceratops-skill-lifecycle/scripts/runtime/synchronize-installers.py b/skills/ceratops-skill-lifecycle/scripts/synchronize-bootstrap-installer.py similarity index 68% rename from skills/ceratops-skill-lifecycle/scripts/runtime/synchronize-installers.py rename to skills/ceratops-skill-lifecycle/scripts/synchronize-bootstrap-installer.py index 054ea67..4d857df 100644 --- a/skills/ceratops-skill-lifecycle/scripts/runtime/synchronize-installers.py +++ b/skills/ceratops-skill-lifecycle/scripts/synchronize-bootstrap-installer.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Synchronize the versioned bootstrap installer into a target task worktree. +"""Synchronize the versioned first-install bootstrap installer into a task worktree. Only the parsed integer ``INSTALLER_VERSION`` controls replacement. Missing or lower-version targets are copied from the authoritative template; same- or -higher-version files are retained even when their contents differ. A full -target-repository validation follows every synchronization decision. +higher-version files are retained even when their contents differ. Repository +validation belongs to the caller. """ from __future__ import annotations @@ -14,14 +14,17 @@ import pathlib import re import shutil -import subprocess import sys -BUNDLE_ROOT = pathlib.Path(__file__).resolve().parents[2] -TEMPLATE = BUNDLE_ROOT / "scripts" / "templates" / "install-skills-template.py" -VALIDATOR = BUNDLE_ROOT / "scripts" / "skills-consistency-source-validator.py" -TARGET_RELATIVE = pathlib.Path("scripts/install-skills.py") +BUNDLE_ROOT = pathlib.Path(__file__).resolve().parents[1] +TEMPLATE = ( + BUNDLE_ROOT + / "references" + / "templates" + / "install-skills-bootstrap-template.py" +) +TARGET_RELATIVE = pathlib.Path("scripts/install-skills-bootstrap.py") INSTALLER_VERSION_RE = re.compile( r"^[ \t]*INSTALLER_VERSION[ \t]*=[ \t]*" r"(?P[1-9][0-9]*)[ \t]*(?:#.*)?$", @@ -53,24 +56,12 @@ def require_linked_worktree(repo_root: pathlib.Path) -> None: raise RuntimeError(f"target repository must be a linked task worktree: {repo_root}") -def run_validation(repo_root: pathlib.Path) -> None: - """Run full target validation after the installer decision.""" - - result = subprocess.run( - [sys.executable, str(VALIDATOR), "--repo-root", str(repo_root), "--mode", "full"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError(f"full source-repository validation failed: {detail}") - - def main() -> int: """Update one task-worktree installer when its parsed version is outdated.""" - parser = argparse.ArgumentParser(description="Synchronize a compatible-repo installer by version.") + parser = argparse.ArgumentParser( + description="Synchronize a compatible-repo bootstrap by version." + ) parser.add_argument("--target-repo-root", required=True, type=pathlib.Path) args = parser.parse_args() repo_root = args.target_repo_root.resolve() @@ -86,7 +77,6 @@ def main() -> int: if updated: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(TEMPLATE, target) - run_validation(repo_root) except (OSError, RuntimeError) as exc: print(str(exc), file=sys.stderr) return 1 @@ -94,7 +84,7 @@ def main() -> int: print( json.dumps( { - "installer_version": source_version, + "bootstrap_version": source_version, "previous_version": target_version, "status": "updated" if updated else "retained", }, diff --git a/skills/ceratops-skill-lifecycle/scripts/templates/install-skills-template.py b/skills/ceratops-skill-lifecycle/scripts/templates/install-skills-template.py deleted file mode 100644 index 4431c22..0000000 --- a/skills/ceratops-skill-lifecycle/scripts/templates/install-skills-template.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -"""Install this repository's declared skills without Ceratops runtime dependencies.""" - -from __future__ import annotations - -import argparse -import json -import os -import pathlib -import re -import shutil -import stat -import sys -import tempfile -from collections.abc import Mapping, Sequence -from typing import cast - - -INSTALLER_VERSION = 9 -MANIFEST_NAME = ".runtime-manifest.json" -RUNTIME_MANIFEST_SCHEMA = "ceratops-runtime-skill.v3" -START = "" -END = "" -SOURCE_PREFIX = "" -SKILL_NAME_RE = re.compile(r"^(?![a-z0-9-]*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") -IGNORED_NAMES = {".git", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", "node_modules"} - - -def fail(message: str) -> int: - """Emit one concise fatal error.""" - print(message, file=sys.stderr) - return 1 - - -def safe_relative(value: str) -> bool: - """Accept only repository-relative manifest paths and patterns.""" - posix = pathlib.PurePosixPath(value.replace("\\", "/")) - windows = pathlib.PureWindowsPath(value) - return bool(value and not posix.is_absolute() and not windows.is_absolute() and not windows.drive and ".." not in posix.parts) - - -def unsafe_link(path: pathlib.Path) -> bool: - """Reject links and Windows reparse points from copied input.""" - if path.is_symlink(): - return True - if os.name != "nt": - return False - attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) - return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) - - -def require_inside(path: pathlib.Path, root: pathlib.Path) -> None: - """Reject any resolved path that escapes the source repository.""" - path.resolve(strict=False).relative_to(root.resolve()) - - -def read_manifest(repo_root: pathlib.Path) -> dict[str, object]: - """Read the minimal declarations required to render skills.""" - path = repo_root / "skills" / "skill-sections.json" - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - raise ValueError("skill-sections.json must contain an object") - source_id = value.get("runtime_source_id") - sections = value.get("sections") - skills = value.get("skills") - payloads = value.get("runtime_payloads", {}) - if not isinstance(source_id, str) or not source_id.strip(): - raise ValueError("runtime_source_id must be a nonempty string") - if not isinstance(sections, dict) or not isinstance(skills, dict): - raise ValueError("sections and skills must be objects") - if not isinstance(payloads, dict): - raise ValueError("runtime_payloads must be an object") - return value - - -def declared_skills(manifest: Mapping[str, object], requested: Sequence[str]) -> list[str]: - """Resolve the exact declared skill set before staging output.""" - assignments = cast(Mapping[str, object], manifest["skills"]) - names = list(requested) if requested else sorted(assignments) - if len(names) != len(set(names)): - raise ValueError("duplicate --skill selection") - for name in names: - if not isinstance(name, str) or not SKILL_NAME_RE.fullmatch(name): - raise ValueError(f"invalid skill name: {name!r}") - if name not in assignments: - raise ValueError(f"undeclared skill: {name}") - return names - - -def section_block(repo_root: pathlib.Path, manifest: Mapping[str, object], skill: str) -> str: - """Resolve one skill's shared sections without lifecycle validation.""" - sections = cast(Mapping[str, object], manifest["sections"]) - assignments = cast(Mapping[str, object], manifest["skills"]) - selected = assignments[skill] - if not isinstance(selected, list) or not selected: - raise ValueError(f"{skill}: section assignment must be a nonempty list") - rendered: list[str] = [] - for name in selected: - if not isinstance(name, str) or name not in sections: - raise ValueError(f"{skill}: unresolved section {name!r}") - relative = sections[name] - if not isinstance(relative, str) or not safe_relative(relative): - raise ValueError(f"{skill}: invalid section path {relative!r}") - path = repo_root / relative - require_inside(path, repo_root) - if not path.is_file() or unsafe_link(path): - raise ValueError(f"{skill}: unavailable section {relative}") - lines = path.read_text(encoding="utf-8").splitlines() - text = "\n".join(line for line in lines if not line.strip().startswith("