From d06aaccfa251e6206e929e358c263beee725e4b8 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Fri, 10 Jul 2026 22:17:17 +0200 Subject: [PATCH 01/10] feat: add handoff document patch and update SKILL.md to use repo-local tmp directory --- .../patches/mattpocock-handoff-tmp-path.patch | 12 ++++++++++++ .../references/imported-asset-overrides.yaml | 14 ++++++++++++++ .github/skills/mattpocock-handoff/SKILL.md | 2 +- .../scripts/test_manifest.py | 19 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .github/skills/local-agent-sync-external-resources/patches/mattpocock-handoff-tmp-path.patch diff --git a/.github/skills/local-agent-sync-external-resources/patches/mattpocock-handoff-tmp-path.patch b/.github/skills/local-agent-sync-external-resources/patches/mattpocock-handoff-tmp-path.patch new file mode 100644 index 00000000..6a1db043 --- /dev/null +++ b/.github/skills/local-agent-sync-external-resources/patches/mattpocock-handoff-tmp-path.patch @@ -0,0 +1,12 @@ +diff --git a/.github/skills/mattpocock-handoff/SKILL.md b/.github/skills/mattpocock-handoff/SKILL.md +index 37e6be0..0000000 100644 +--- a/.github/skills/mattpocock-handoff/SKILL.md ++++ b/.github/skills/mattpocock-handoff/SKILL.md +@@ -5,6 +5,6 @@ argument-hint: "What will the next session be used for?" + disable-model-invocation: true + --- + +-Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace. ++Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save it under `tmp/handoff/` in the current workspace, creating that directory if needed. + + Include a "suggested skills" section in the document, which suggests skills that the agent should invoke. diff --git a/.github/skills/local-agent-sync-external-resources/references/imported-asset-overrides.yaml b/.github/skills/local-agent-sync-external-resources/references/imported-asset-overrides.yaml index 79883ff4..79d122e3 100644 --- a/.github/skills/local-agent-sync-external-resources/references/imported-asset-overrides.yaml +++ b/.github/skills/local-agent-sync-external-resources/references/imported-asset-overrides.yaml @@ -8,6 +8,20 @@ policy: --3way` fallback when upstream text drift is compatible. Stop for review if neither path applies cleanly. overrides: +- id: mattpocock-handoff-tmp-path + target_path: .github/skills/mattpocock-handoff/SKILL.md + source_family: mattpocock/skills + lifecycle_mode: post-refresh-patch + apply_strategy: git-apply + approval: explicit-user-counter-validated + reason: Force handoff documents imported from mattpocock/skills to use the + repository-local tmp/handoff directory instead of the operating system + temporary directory. + patch_path: patches/mattpocock-handoff-tmp-path.patch + expected_content_hash: c41182a1631c303fd024b4acd83124791aeaf4bc28a7ba1af5eba48eb26a5cfa + baseline_repo_commit: efa058a + validation_note: Stop the refresh if the patch does not apply cleanly; review + whether the repository-local handoff path remains the intended contract. - id: grill-me-bulk-recommended-questions target_path: .github/skills/grill-me/SKILL.md source_family: mattpocock/skills diff --git a/.github/skills/mattpocock-handoff/SKILL.md b/.github/skills/mattpocock-handoff/SKILL.md index a31b4763..cd62774d 100644 --- a/.github/skills/mattpocock-handoff/SKILL.md +++ b/.github/skills/mattpocock-handoff/SKILL.md @@ -5,7 +5,7 @@ argument-hint: "What will the next session be used for?" disable-model-invocation: true --- -Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace. +Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save it under `tmp/handoff/` in the current workspace, creating that directory if needed. Include a "suggested skills" section in the document, which suggests skills that the agent should invoke. diff --git a/tests/github/skills/local-agent-sync-external-resources/scripts/test_manifest.py b/tests/github/skills/local-agent-sync-external-resources/scripts/test_manifest.py index bc0513ef..68118128 100644 --- a/tests/github/skills/local-agent-sync-external-resources/scripts/test_manifest.py +++ b/tests/github/skills/local-agent-sync-external-resources/scripts/test_manifest.py @@ -117,3 +117,22 @@ def test_live_override_targets_sit_under_managed_assets(repo_root: Path) -> None f"Override {override.override_id} target {override.target_path} " f"does not sit under any managed local asset" ) + + +def test_live_handoff_override_forces_repo_tmp_handoff(repo_root: Path) -> None: + overrides_path = ( + repo_root + / ".github/skills/local-agent-sync-external-resources/references/imported-asset-overrides.yaml" + ) + bundle_root = repo_root / ".github/skills/local-agent-sync-external-resources" + + overrides = load_overrides(overrides_path) + handoff = next( + override + for override in overrides + if override.target_path == ".github/skills/mattpocock-handoff/SKILL.md" + ) + + assert handoff.override_id == "mattpocock-handoff-tmp-path" + patch_text = (bundle_root / handoff.patch_path).read_text(encoding="utf-8") + assert "tmp/handoff/" in patch_text From ccebdebd4b3e20677b7aa0e6545b02c0b1d4ae16 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Fri, 10 Jul 2026 22:46:31 +0200 Subject: [PATCH 02/10] feat: remove internal context handoff skill and related references --- .github/INVENTORY.md | 1 - .../skills/internal-context-handoff/SKILL.md | 42 ---------- .../agents/openai.yaml | 4 - .../references/handoff-template.md | 82 ------------------- 4 files changed, 129 deletions(-) delete mode 100644 .github/skills/internal-context-handoff/SKILL.md delete mode 100644 .github/skills/internal-context-handoff/agents/openai.yaml delete mode 100644 .github/skills/internal-context-handoff/references/handoff-template.md diff --git a/.github/INVENTORY.md b/.github/INVENTORY.md index 2e4f734b..8f1ec343 100644 --- a/.github/INVENTORY.md +++ b/.github/INVENTORY.md @@ -75,7 +75,6 @@ This file is the exact path inventory for the live GitHub Copilot catalog in thi - `.github/skills/internal-bash/SKILL.md` - `.github/skills/internal-changelog-automation/SKILL.md` - `.github/skills/internal-cloud-policy/SKILL.md` -- `.github/skills/internal-context-handoff/SKILL.md` - `.github/skills/internal-copilot-audit/SKILL.md` - `.github/skills/internal-copilot-docs-research/SKILL.md` - `.github/skills/internal-ddd/SKILL.md` diff --git a/.github/skills/internal-context-handoff/SKILL.md b/.github/skills/internal-context-handoff/SKILL.md deleted file mode 100644 index e7e5ea19..00000000 --- a/.github/skills/internal-context-handoff/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: internal-context-handoff -description: Use when the user asks to create a context handoff, prepare resume context, save context for a new chat, or reconstruct a clean continuation package after long, polluted, or contradictory chat context. ---- - -# Internal Context Handoff - -## Referenced skills - -- `internal-agent-support-next-step`: compact owner-transition package; this skill separates cross-chat reconstruction from owner transitions. - -Produce a token-efficient, paste-ready Markdown handoff that another agent can use as its first message in a new chat. Keep the workflow manual and handoff-only: do not auto-open sessions, continue the handed-off task, or silently write files. - -## When to use - -- The user asks to create a context handoff, prepare resume context, save context for a new chat, or reconstruct a continuation package. -- Chat context is long, polluted, contradictory, or compromised and a clean restart package is needed. -- Any discussion that needs a portable snapshot, not only repository work. - -## When not to use - -- The user needs a compact transition between already-selected owners. Use `internal-agent-support-next-step` instead. -- The user needs a Decision Brief for retained planning. Use `internal-agent-support-next-step` for the Decision Brief contract. -- The task is a gateway phase change or non-terminal exit. Use the owning gateway skill. - -## Workflow - -1. Pick compression level. Default `compact`; use `standard` for multi-step, technical, or long polluted continuation; use `deep` for complex, high-risk, or contradictory state. See `references/handoff-template.md` for field shape and selection rules. - -2. Gather only continuation-critical fields. Omit non-applicable fields entirely; do not write `None.` for absent fields. The template lists available fields. If source priority cannot be inferred from evidence and omission would misroute the next agent, ask one optional source-priority question. - -3. For technical tasks, inspect machine evidence: files, Git status, validation results, active processes. Record checked sources, material deltas, and the next check when they reduce restart cost. Declare evidence gaps when inspection is impossible. - -4. Exclude transcript narrative, superseded attempts, and easily recoverable detail unless they explain an active risk. Redact secrets, credentials, tokens, and sensitive values with `[REDACTED]`. Preserve unresolved contradictions under `Open uncertainties`; do not choose silently. - -5. Emit as paste-ready Markdown in chat by default. Persist to `tmp/context-handoffs/-.md` only when the user explicitly requests it; append a numeric suffix if the target path already exists. - -6. Add brief `Resume instructions`. Stop after handoff creation. Do not execute the next task or open another session. - -## Output boundary - -The handoff is a portable reconstruction package. `internal-agent-support-next-step` remains the compact owner for transitions between already-selected owners. This skill does not choose the next owner, auto-dispatch, or bypass user approval. diff --git a/.github/skills/internal-context-handoff/agents/openai.yaml b/.github/skills/internal-context-handoff/agents/openai.yaml deleted file mode 100644 index 7d420ee5..00000000 --- a/.github/skills/internal-context-handoff/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Internal Context Handoff" - short_description: "Create paste-ready, token-efficient cross-chat resume context" - default_prompt: "Use $internal-context-handoff to create a compact, paste-ready context handoff for a new chat. Default to compact compression. Redact secrets. Include only continuation-critical fields." diff --git a/.github/skills/internal-context-handoff/references/handoff-template.md b/.github/skills/internal-context-handoff/references/handoff-template.md deleted file mode 100644 index 6b312dfd..00000000 --- a/.github/skills/internal-context-handoff/references/handoff-template.md +++ /dev/null @@ -1,82 +0,0 @@ -# Handoff Template - -Load this reference when the skill body directs field-selection rules or adaptive compression shape. - -## Field-selection rules (all profiles) - -- Include only fields that apply to the current discussion. -- Prefer concrete over speculative: include only what is known, decided, or observably pending. -- Redact secrets, credentials, tokens, keys, and sensitive values with `[REDACTED]`. -- Cite file paths, command output, or validation evidence when available. -- Declare evidence gaps explicitly instead of guessing. -- For long, polluted, or contradictory context, prefer checked sources and - state deltas over transcript recap. - -## Compact (default) - -Use for single-step discussions, simple questions, or when the next agent needs minimal context. - -```markdown -## Context handoff - -**Goal:** [one-line primary objective] -**Current state:** [one-line status] -**Key decisions:** [bulleted list or "None."] -**Anti-scope:** [what to avoid, or "None."] -**Next step:** [one concrete action] -**Resume instructions:** [one-line guidance for the next agent] -``` - -## Standard - -Use for multi-step technical work, repository tasks, or when evidence gaps matter. - -Add these fields after `Key decisions` and before `Anti-scope` when they -materially reduce restart cost: - -```markdown -**Primary source to trust first:** [authoritative artifact, file, command, or "Not established."] -**Checked sources:** [files, commands, branches, logs, or "None inspected."] -**Delta since last stable state:** [what changed since the last reliable checkpoint, or "None."] -**Evidence:** - - [validations run, command output, or "None available."] - - Git: [branch, status summary, or "Not inspected."] -**Unfinished work:** [bulleted list or "None."] -**Validation path:** [remaining checks, commands, or "Not yet defined."] -**Next check:** [first file, command, or question the next agent should use] -**Risks:** [active risks or "None identified."] -``` - -`Compact` fields remain. `Primary source to trust first`, `Checked sources`, -`Delta since last stable state`, `Evidence`, `Unfinished work`, -`Validation path`, `Next check`, and `Risks` are additions. - -## Deep - -Use for complex, high-risk, or contradictory state that `standard` cannot safely compress. - -Add these fields after `Risks` and before `Anti-scope`: - -```markdown -**Open uncertainties:** [contradictions, unknowns, or ambiguous outcomes that must be preserved without choosing silently.] -``` - -`Compact` and `standard` fields remain. `Open uncertainties` is the addition. - -## Redaction notation - -Replace any detected secret, credential, token, key, or sensitive value with: - -```text -[REDACTED] -``` - -Never include the original value. Report the field type (e.g., `[REDACTED: API key]`) only when identifying the type adds safety without leaking the value. - -## Optional persistence note - -When file persistence is requested, append this line before `Resume instructions`: - -```markdown -**Persisted to:** `tmp/context-handoffs/-.md` -``` From 1245cc780c74819e897cc3a5fefd56bc6d7acd1d Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Sun, 12 Jul 2026 13:45:46 +0200 Subject: [PATCH 03/10] feat: remove .graphify_version file --- .github/skills/graphify/.graphify_version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/skills/graphify/.graphify_version diff --git a/.github/skills/graphify/.graphify_version b/.github/skills/graphify/.graphify_version deleted file mode 100644 index 86a6ea4f..00000000 --- a/.github/skills/graphify/.graphify_version +++ /dev/null @@ -1 +0,0 @@ -0.8.16 \ No newline at end of file From 64884e4c1640960ff5719b8973a97532bc948047 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Sun, 12 Jul 2026 16:55:02 +0200 Subject: [PATCH 04/10] Refactor tests for home sync functionality and enhance error handling - Introduced new tests for skill link assessment and planning, ensuring deterministic behavior when assessing skill links. - Updated test cases to validate the handling of symlink operations and the application of home sync plans. - Enhanced the manifest loading tests to reject unsupported schemas and validate the integrity of manifest rows. - Removed deprecated bisync-related tests and streamlined the test suite for clarity and maintainability. - Added error handling for malformed manifests and improved assertions in existing tests. --- .../SKILL.md | 272 ++--- .../agents/openai.yaml | 4 +- .../references/error-codes.md | 62 +- .../references/sync-contract.md | 395 ++---- .../scripts/bisync_skills.py | 704 ----------- .../scripts/home_syncing.py | 325 ++++- .../scripts/sync_home_ai_resources.py | 93 +- .../scripts/sync_output.py | 1055 +++-------------- .../scripts/test_apply_paths.py | 258 +++- .../scripts/test_apply_safety.py | 22 +- .../scripts/test_contracts.py | 404 ++++--- .../scripts/test_error_handling.py | 14 +- 12 files changed, 1084 insertions(+), 2524 deletions(-) delete mode 100644 .github/skills/local-agent-sync-install-ai-resources/scripts/bisync_skills.py diff --git a/.github/skills/local-agent-sync-install-ai-resources/SKILL.md b/.github/skills/local-agent-sync-install-ai-resources/SKILL.md index d5dfc895..0c3b8771 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/SKILL.md +++ b/.github/skills/local-agent-sync-install-ai-resources/SKILL.md @@ -1,211 +1,89 @@ --- name: local-agent-sync-install-ai-resources -description: Use when planning, auditing, or applying allowlisted home-directory sync of repository-owned AI runtime resources to local Codex, Copilot, or OpenCode targets. +description: Use when planning, auditing, or applying allowlisted repository-owned AI resources to local Codex, Copilot, or OpenCode runtimes. --- # Local Agent Sync Home AI Resources -## Referenced skills - -- None. - Use this skill as the operating engine for `.github/agents/local-sync-install-ai-resources.agent.md`. -The paired agent is only a thin UX wrapper; this skill owns mode selection, -approval posture, safety gates, and report interpretation for repo-to-home sync -and bisync. Keep user-visible output deterministic, bounded, summary-first, -and emoji-led in chat. - -Canonical command examples use `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh`. -The CLI defaults to `--format compact` for AI/tool iteration. Use -`--format report` only when a human-readable command report is explicitly -needed; in chat, summarize compact output as concise Markdown for the user. - -## When to use - -- Plan a local home-directory sync for supported AI runtime resources, including shared skills and runtime-specific agents. -- Audit drift between repository-managed resources and the local runtime copies under the user home directory. -- Run readiness or doctor checks before touching runtime-owned directories. -- Apply an already reviewed plan for supported direct-copy skill families and allowlisted agent translations. -- Run bidirectional drift detection and reconciliation between `.github/skills/` and `~/.agents/skills/`. - -## When not to use - -- Source-side catalog governance in this repository; use `local-sync-external-resources` instead. -- Consumer-repository baseline sync; use `local-sync-global-copilot-configs-into-repo` instead. -- Personal configuration merge, runtime adapter generation, or general dotfiles management. -- Undocumented runtime families outside the allowlisted direct-copy skills and translated agents for OpenCode and Codex. - -## Deterministic Operator Protocol - -Every mode has exactly one command. Do not infer the mode, do not skip blockers, and do not treat `next_action` as user approval for `apply`. Plain `apply` and `bisync apply` still require an explicit user request. The `sync` command is the only auto-execute exception: it may write only through the install lane after a zero-blocker, no-drift preflight. - -### Command Map - -| User request | Lane | Command | -| --- | --- | --- | -| Generic `sync`, `repoβ†’home`, or `repo wins` | Auto-run safe repo-to-home install for `skills`, then review only home-owned or ambiguous bisync drift | `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh sync --targets skills` | -| Explicit `homeβ†’repo` | Review home-newer drift, then use explicit bisync commands with a git-clean repo | `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh bisync plan` then `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh bisync apply` | -| Readiness check | Verify roots, support matrix, catalog, and state root without writes | `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh doctor --targets skills` | -| Dry install review | Show repo-to-home changes without writes | `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh plan --targets skills` | -| Explicit install write | Materialize a reviewed install plan | `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh apply --targets skills` | - -The repository dispatcher `./.github/scripts/run.sh sync_home_ai_resources ...` -may be used for compatibility; it delegates to the bundled skill runner. - -### Mode Selection - -- `sync`: default safe automation for shared skills. Auto-apply clean repo-to-home install work, then stop only on home-owned or ambiguous bisync drift. -- `doctor`: read-only readiness checks for runtime roots, support matrix, catalog paths, and sync state. -- `plan` or `dry-run`: install-lane dry run. -- `audit`: compare source, manifest, and managed target paths without writing runtime files. -- `--fast`: read-only shortcut for `plan` and `audit` only. Write modes still evaluate the full source catalog. -- `apply`: explicit install-lane materialization. Never run from `next_action` alone. -- `bisync plan`: read-only drift detection between `.github/skills/` and `~/.agents/skills/`. -- `bisync apply`: explicit bidirectional drift resolution after a reviewed matching `bisync plan` snapshot and clean repo preflight. - -### Default Sync Sequence - -When the user says "sync" without a mode: - -1. Run `sync` for the default `skills` target. -2. The command builds an install-lane `apply` plan and stops before writing when blockers, missing directory creation, stale managed resources, destructive cleanup, or other manual gates are present. -3. If the install lane is clean, the command applies repo-to-home materialization and reports copied, skipped, validation, state, and manifest evidence. -4. After install, the command runs `bisync plan` as a review gate. Stop and ask for user direction only when bisync reports `home-to-repo`, `only-home`, or `equal-mtime` drift, or another non-safe blocker. -5. `repo-to-home` and `only-repo` bisync entries are safe informational leftovers for the default lane. Report them, but do not stop the sync run for them. Do not run `bisync apply` automatically. - -Install must run before bisync because bisync modifies `~/.agents/skills/` directories that the install manifest tracks. Running install first copies fresh content from the repo with matching manifest hashes; bisync then finds both sides already aligned, avoiding spurious `target-modified-managed` blockers. - -### Stop Conditions - -Stop and report when any of these occur: - -- A blocker code is present in the output. -- `next_action.allowed` is `false`, except for `sync` reports that have already completed their safe install-lane work and are reporting `done`. -- `next_action.requires_explicit_approval` is `true` and the user has not explicitly approved, except for the `sync` command's built-in install-lane auto-execute path. -- `sync` reports install-lane residual drift, missing directory creation without `--create-missing-dirs`, stale managed resources, or any install blocker. -- `sync` reports `home-to-repo`, `only-home`, `equal-mtime`, or another non-safe bisync blocker after install. Treat this as a review state, not an apply failure. -- `bisync apply` was requested without a prior matching reviewed `bisync plan` snapshot. -- The source repository has uncommitted or untracked changes during `bisync apply`. -- After `bisync apply` modifies `~/.agents/skills/` files that the install lane also manages, re-run install `plan`. Verified repo-to-home bisync copies refresh the manifest state; if `target-modified-managed` still appears, treat it as a real local divergence and review the path instead of deleting it as a routine recovery step. -- If `bisync apply` is blocked by `bisync-repo-dirty` and the local workspace has unrelated uncommitted changes, run bisync from a clean detached worktree at the same commit and pass it through `--source-root`. - -## Core Operating Contract - -- For the install lane, treat this repository as the source of truth for allowlisted home-sync resources. -- Install sync is unidirectional: repo -> home only. Block any attempt to sync from home to repo. -- Default generic sync requests to `sync`; keep plain `apply`, prune, directory creation, and all `bisync apply` writes explicit unless the user provided the matching flags or request. -- Limit v1 materialization to documented direct-copy skill families and allowlisted agent translations for Codex and OpenCode. -- Preserve unmanaged target-local files and directories. -- Prune stale managed assets only when explicit approval is present and the manifest entry passes schema validation, path confinement, and content-hash drift checks. -- Keep local sync state under `~/.sync/cloud-strategy-governance/home-ai-resources/`. -- Block writes when runtime support is undocumented, target paths are unsafe, ownership evidence is missing, the manifest is corrupt, or the source root sits under home sync state. -- Use `--retire-targets` when the managed target set should shrink, for example removing `opencode` while keeping `codex` and `copilot`. -- Accept `codex`, `copilot`, `opencode`, comma-separated combinations, `cross`, `all`, or `tutto`; normalize and order targets deterministically. -- Keep `references/home-sync-catalog.yaml` as policy and explicit non-skill resources only; skill bundles are auto-discovered from `.github/skills/` when `include_unlisted_skills` is true. Use catalog defaults to exclude home-kept skills from future install and bisync lanes and to declare whether unmanaged home skill bundles must block or be adopted with repo-wins behavior. - -## Bisync Lane - -The `bisync` lane provides explicit bidirectional synchronization between `.github/skills/` and `~/.agents/skills/`. It is a separate lane from install sync. - -- Blocks `apply` when the source repository has uncommitted or untracked changes. -- Blocks `apply` when any `only-home` or `equal-mtime` entry exists. -- Blocks `apply` when post-copy hash verification fails. -- Blocks `apply` when post-apply plan still shows residual drift. -- Excludes all `local-*` bundles and runtime artifacts (`.venv`, `__pycache__`, `.pytest_cache`, `.pyc`, `.pyo`) from scanning and copying. -- `only-repo`: when not excluded, `bisync apply` can create the bundle in home from the repository side. -- `only-home`: manual intervention required. Decide whether to keep it only in home, remove it, or add it to the repository. -- `equal-mtime`: hashes differ but mtime is equal. Manual decision required because the winner cannot be determined from timestamps alone. - -## Reporting Contract - -Use compact output for model-facing runs. Do not dump raw JSON unless the user -explicitly asks for it. Machine-readable output is available as `--format compact` -or `--compact` by default; full raw state remains available as `--format json` -for debugging. Use `--format report` only for a human-readable command report. - -Every report must be summary-first and start with one status line that includes mode or lane, selected targets, overall status, blocker count, and `next_action.action`. - -Then follow the exact text layout in `references/sync-contract.md`: - -- `doctor`: `Status`, `Summary`, `Readiness`, `Validation`, and `Next`. Show non-ok readiness checks and tell the user what blocks the next write. -- `sync`: `Status`, `Summary`, `Auto-applied` or `Planned repo-to-home copies`, `Stopped on`, `Validation`, and `Next`. Summarize counts first, then show only the copied resources when writes occurred, or the planned copies when install review stopped the run before writing, plus the exact drift or blockers that stopped completion. -- `plan`, `audit`, and `bisync plan`: a compact summary, a change table when there are changes, and an attention table when there are blockers or drift decisions. For every proposed modification, explain the decision cause, for example repo copy is newer, home copy is newer, a managed resource is stale, or runtime support is not documented enough for apply. -- `apply` and `bisync apply`: a compact summary, an actions-performed table for writes, and a residual-issues table when needed. List copied, updated, pruned, or created resources and state why they were handled that way and how they were verified. Summarize unchanged managed resources by count instead of listing every skip. - -Never report blocker codes alone. Translate each code into a plain-language reason and required follow-up. Never say a resource will change without stating what evidence selected the winner or triggered the recommendation. Bounded chat reports may omit excess change rows, but they must keep all blocker and attention rows visible and point to `--format json` for full detail. - -### Canonical Chat Report Template - -When answering the user in chat, use this structure so the report stays easy to scan and easy to answer. Use emoji-led headings in chat: - -1. `🚦 Status` - - One short line with the overall result. - - Prefer `completata`, `in corso`, `bloccata`, or `no-op`. -2. `πŸ“Œ What is happening` - - One short paragraph that explains the current state in plain language. -3. `πŸ” Differences` - - List only the actionable changes. - - Number the items when the user may need to choose between them. - - Summarize unchanged or skipped resources by count unless the user asked for the full list. -4. `β›” Why it stops` - - Explain the smallest real blocker, not the internal code name. - - Include only the drift or policy item that prevents the next step. -5. `🎯 Choices` - - Present only the actions the user can actually take now. - - Use numbered options. - - Put the recommended choice first. - - Keep each option to one line when possible. - -### Canonical Completion Template - -When the work is finished, close with this structure instead of repeating the full diff. Use emoji-led headings in chat: - -1. `βœ… Result` - - Say whether the run completed, applied changes, or ended as no-op. -2. `πŸ› οΈ What changed` - - One short paragraph with the concrete outcome. -3. `πŸ”Ž Final verification` - - State the strongest evidence available, such as a clean plan, hash match, or zero residual drift. -4. `🧾 Residuals` - - Include only if something still needs attention. - - If nothing remains, say `none`. -5. `➑️ Next step` - - If the run is done, say there is nothing else to do. - - If the run is blocked, name the single next action. - -## Bundled Automation - -- Prefer `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh` for deterministic `plan`, `audit`, `doctor`, `apply`, and `bisync plan|apply` behavior. -- Use compact output for automation and AI loops. Convert compact results into concise Markdown in chat when the user needs to decide what to do. -- The bundled runner installs locked dependencies from `scripts/requirements.txt` and suppresses bootstrap noise when compact output is selected. -- Keep orchestration inside `scripts/sync_home_ai_resources.py`, install behavior inside `scripts/home_syncing.py`, bisync behavior inside `scripts/bisync_skills.py`, report rendering inside `scripts/sync_output.py`, and reference loading inside `scripts/home_sync_contract.py`. - -## Conflict Resolution - -When plan, audit, or sync reports blocked paths, resolve them before apply: - -- `target-exists-unmanaged`: target content exists at home but is not manifest-managed. Review and move or remove it manually before rerunning plan unless the active catalog policy explicitly adopts unmanaged skills with repo-wins behavior. -- `target-modified-managed`: manifest-managed content diverged from the recorded hash. If home is clearly newer, let bisync surface the explicit home-to-repo decision; if it persists after verified bisync reconciliation, treat it as real local divergence. -- `stale-managed`: previously managed content is no longer planned. Re-run with `--prune-managed` only after review. -- `retire-target-overlap`: the same target was requested as active and retired. Remove the overlap and rerun. -- `bisync-only-home`: decide whether to keep it only in home, remove it, or add it to the repository. -- `bisync-equal-mtime`: choose the winning side and touch the winner to advance mtime. -- `bisync-repo-dirty`: commit or stash, or run `bisync apply` from a clean detached worktree with `--source-root`. - -After any manual cleanup, re-run `plan` or `bisync plan` and require zero blockers before any explicit apply. +The repository is the only source of truth for managed resources. Home is a +runtime projection: a write through a managed skill link writes the repository +bundle directly. + +## Scope + +- Repository skill bundles are materialized only as absolute links under + `~/.agents/skills/`. +- Translated agent resources retain the existing repository-to-home copy path. +- Home-only skills are unmanaged and preserved. This includes catalog-excluded + `graphify` and every `local-*` bundle. +- Reverse synchronization, reconciliation, and copied-skill fallback are + forbidden. + +## Commands + +Use `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh`. + +| Request | Command | +| --- | --- | +| Default repository-to-home sync | `sync --targets skills` | +| Dry review | `plan --targets skills` | +| Explicit materialization | `apply --targets skills` | +| Drift inspection | `audit --targets skills` | +| Readiness check | `doctor --targets skills` | + +`dry-run` is an alias for `plan`. The repository dispatcher +`./.github/scripts/run.sh sync_home_ai_resources ...` remains a delegating +compatibility entrypoint. + +## Operating Contract + +- Keep `~/.agents/skills/` a real directory. Never replace the root with a + link. +- Create one canonical absolute link for every eligible repository skill. +- A colliding home directory with an eligible repository skill ID is removed + without backup and replaced by that link. +- A matching unmanaged link is adopted into the manifest without replacement. +- A broken link or a link to another checkout blocks the operation. +- Manifest-v2 stale managed skill links are unlinked automatically; copied + agents retain explicit `--prune-managed` safety. +- Unsupported symlink capability blocks the operation. Never copy a skill as a + fallback. +- If the repository checkout moves, rerun sync so links point to the new + canonical source paths. +- Do not run a real-home command unless the user requested a home change. Use + a temporary home for tests and acceptance checks. + +## Mode Selection + +- `sync` may auto-apply clean repository-to-home work. It stops for blockers, + missing-directory approval, or copied-agent prune gates. +- `plan` and `audit` are read-only. +- `apply` needs an explicit request; `--create-missing-dirs` and + `--prune-managed` remain explicit. +- `doctor` is read-only and checks roots, support, catalog sources, and state. + +## Reporting + +Use `--format compact` for automation. Reports must summarize linked skills, +unlinked skills, copied agents, unchanged resources, and blockers. Do not list +all unchanged skills. Translate blocker codes into a plain-language next +action; see `references/error-codes.md`. ## Load On Demand -- Read `references/runtime-support-matrix.yaml` when the runtime family or support level decides the mode. -- Read `references/sync-contract.md` for state files, manifest fields, materialization rules, doctor readiness, bisync contract, and reporting requirements. -- Read `references/error-codes.md` when the correct blocking code or remediation must be surfaced. -- Read `references/home-sync-catalog.yaml` only when changing default discovery policy or explicit agent resources. +- Read `references/sync-contract.md` for manifest, planning, path safety, and + verification details. +- Read `references/error-codes.md` when a blocker or remediation is relevant. +- Read `references/home-sync-catalog.yaml` only when changing discovery policy + or explicit agent resources. +- Read `references/runtime-support-matrix.yaml` when runtime support decides a + mode. ## Validation -- Rebuild `.github/INVENTORY.md` when the bundle or related scripts change by using `./.github/scripts/run.sh build_inventory --root .`. -- Run `./.github/scripts/run.sh check_catalog_consistency --root . --include-token-risks` after bundle or automation changes. +- Run focused tests under + `tests/github/skills/local-agent-sync-install-ai-resources/scripts`. - Run `bash -n .github/skills/local-agent-sync-install-ai-resources/scripts/run.sh .github/scripts/run.sh` after shell entrypoint changes. -- Run `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh plan --targets skills,copilot,codex --compact` after output or CLI changes to confirm low-token output remains valid. -- Run focused agent or skill contract tests for this bundle. -- Run focused sync tests for report layout, target parsing, support-matrix policy, manifest handling, overwrite gates, bisync protocol, and missing-directory behavior when automation changes. +- Rebuild `.github/INVENTORY.md` with `./.github/scripts/run.sh build_inventory --root .` after bundle changes. +- Run `./.github/scripts/run.sh check_catalog_consistency --root . --include-token-risks` after bundle or automation changes. diff --git a/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml b/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml index d0439e4b..cddf9557 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml +++ b/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Home AI Resource Sync" - short_description: "Plan, apply, audit, doctor, or bisync local AI home sync" - default_prompt: "Use $local-agent-sync-install-ai-resources to run safe repo-to-home sync with compact output by default; require explicit approval for apply or bisync apply; when replying in chat, follow the canonical Problem Report or Completion Report template from the skill, keep differences and blockers plain-language, and render user choices as numbered options with the recommended choice first." + short_description: "Plan, apply, audit, or doctor repository-owned AI home sync" + default_prompt: "Use $local-agent-sync-install-ai-resources for repository-to-home sync. Repository skills use managed absolute links and agents remain copied translations; never sync home content into the repository. Default to compact output, require explicit approval for apply, and explain each blocker in plain language." diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md b/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md index 2f38d040..9679e3d6 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md +++ b/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md @@ -1,39 +1,29 @@ # Error Codes -Use these stable error identifiers for planner, doctor, audit, and apply output. +Convert every active code into its plain-language route. Do not surface a bare +code in an operator report. -When rendering a user-visible report, convert each active code into a plain-language `why blocked` or `why skipped` explanation by combining the `Meaning` and `Rationale` columns. Do not surface a bare code without the policy reason behind it. - -| Code | Meaning | Default route | Rationale | -| --- | --- | --- | --- | -| `unknown-target` | The selected runtime target is not supported by the parser. | Stop and correct the target selection. | Prevents misconfigured or typoed targets from being silently ignored. | -| `unsupported-family` | The source resource family is not supported for the selected target. | Skip or block according to mode. | Only allowlisted families per target are materialized to avoid incompatible or untested runtime files. | -| `docs-unverified` | Runtime support exists only as an unverified claim. | Allow `plan`, `audit`, and `doctor`; block `apply`. | Materialization requires explicit evidence in the runtime support matrix to prevent untested runtime corruption. | -| `missing-target-root` | The runtime root directory does not exist. | Show remediation and block `apply` unless creation is approved. | Avoids creating unexpected directory trees without user confirmation. | -| `needs-directory-create` | The target directory tree can be created safely but does not exist yet. | Plan or doctor can suggest creation; `apply` needs explicit approval. | Home runtime directories are user-owned; creation must be an explicit decision. | -| `permission-denied` | The runtime path exists but is not writable or readable enough for the selected mode. | Stop and surface the failing path. | Prevents runtime I/O errors and partial materialization that could leave the home state inconsistent. | -| `unsafe-home-path` | A resolved path escapes the expected home root or lands in an unsafe location. | Block immediately. | Path confinement is a core safety gate to prevent writing outside the intended home runtime tree. | -| `symlink-not-allowed` | The resolved target path crosses a disallowed symlink boundary. | Block immediately. | Symlink hops can escape the home root or point to unintended destinations; they are treated as unsafe paths. | -| `manifest-missing` | A manifest-backed mode needs state that does not exist yet. | Fall back to first-run planning or doctor guidance. | Without a manifest, the tool cannot distinguish managed from unmanaged targets and must default to cautious first-run behavior. | -| `manifest-corrupt` | The manifest exists but cannot be parsed or trusted. | Block apply and require remediation. | A corrupt manifest could hide stale entries or misrepresent ownership; applying against it risks data loss. | -| `target-exists-unmanaged` | A target path already exists but is not manifest-managed. | Block overwrite unless the active catalog policy explicitly adopts unmanaged skills with repo-wins behavior. | Protects user-created or locally-installed files from being silently overwritten by repository-managed copies unless the selected skill policy intentionally promotes the repo copy to source of truth. | -| `target-modified-managed` | A manifest-managed target diverged from the last recorded content hash. | Block overwrite until reviewed. Re-run install plan after verified bisync reconciliation; if the blocker persists, treat it as real local divergence. | Prevents losing local edits or runtime-generated changes that occurred after the last sync. | -| `source-missing` | A catalog entry points to a source path that no longer exists. | Block that resource and flag catalog drift. | Materializing a missing source would create a stale or incomplete runtime copy; instead, surface catalog inconsistency. | -| `source-invalid-skill` | A source skill bundle is incomplete for direct-copy sync. | Block that resource and fix the bundle. | A valid skill bundle must contain `SKILL.md`; copying an incomplete bundle would produce an unusable runtime skill. | -| `stale-managed` | A previously managed target is no longer planned. | Mark for prune, but do not delete automatically. | Removal is opt-in (`--prune-managed`) to prevent accidental deletion of files that were previously managed. | -| `prune-not-approved` | A stale managed resource could be removed, but prune was not approved. | Keep the file and report the follow-up. | Default policy preserves stale managed files until the user explicitly approves cleanup to avoid surprise data loss. | -| `stale-content-drifted` | A stale managed file was modified since its last manifest entry. | Block delete until reviewed. | Even when pruning is approved, a locally modified stale file may contain valuable changes that must be preserved. | -| `stale-path-unresolvable` | A stale managed path from the manifest cannot be resolved safely. | Block delete and surface the path. | Prevents deleting files at paths that may have been moved, symlinked, or otherwise made unsafe since the last sync. | -| `reverse-sync-blocked` | Source root is under the home sync state directory (`~/.sync/...`), indicating attempted reverse sync. | Block immediately. Sync must be repo β†’ home only. | Unidirectional sync preserves the repository as the single source of truth and prevents home state from polluting the repo. | -| `retire-target-overlap` | The same runtime target was requested as both active and retired. | Stop and correct the target selection. | Target retirement must be explicit and unambiguous, otherwise the tool cannot derive the final managed target set safely. | -| `bisync-source-missing` | The source `.github/skills/` directory does not exist or is not readable. | Block bisync plan and apply. | Bisync requires both source and home skill roots to exist for meaningful comparison. | -| `bisync-home-missing` | The home `~/.agents/skills/` directory does not exist or is not readable. | Block bisync plan and apply. | A missing home root prevents meaningful comparison and safe bidirectional reconciliation. | -| `bisync-repo-dirty` | The source repository has uncommitted or untracked changes detected by `git status --porcelain --untracked-files=all`. | Block bisync apply. | Writing to a dirty repository risks overwriting uncommitted work and makes post-apply verification unreliable. | -| `bisync-repo-git-failed` | The `git status` command failed on the source repository. | Block bisync apply. | Without a reliable dirty-repo check, bisync cannot guarantee safe writes to the repository side. | -| `bisync-only-repo` | A skill bundle exists in the source repo but not in the home directory. | Non-blocking bisync status; explicit apply may create the home bundle. | Repo-to-home creation is allowed for valid non-excluded bundles, but still requires explicit apply approval. | -| `bisync-plan-required` | No reviewed matching bisync plan snapshot exists for the current repo/home drift set. | Block bisync apply and rerun `bisync plan`. | Bisync apply must write only from a drift set that was just reviewed; otherwise the write could target stale or changed drift decisions. | -| `bisync-only-home` | A skill bundle exists in the home directory but not in the source repo. | Block bisync apply; require manual resolution. | Automatic resolution would mean deleting the home copy or creating a new repo copy without user intent. | -| `bisync-equal-mtime` | Hashes differ between repo and home, but mtime is equal for both sides. | Block bisync apply; require manual resolution. | When mtime is equal, the tool cannot determine which side is newer; the user must decide the direction. | -| `bisync-verify-failed` | Post-copy hash verification failed for a specific skill. | Block bisync apply; report the failing skill. | A hash mismatch after copy indicates a copy error, filesystem issue, or concurrent modification during apply. | -| `bisync-manifest-reconcile-failed` | Bisync copied a repo-wins bundle, but the install manifest entry could not be safely refreshed to match it. | Block bisync apply and keep the reconciliation failure visible. | A verified copy must not be treated as converged unless the matching manifest row can be updated safely and revalidated. | -| `bisync-residual-drift` | A post-apply verification plan still found drift entries. | Block bisync apply and inspect the residual plan. | Apply is not complete until a second plan reports zero drift and zero blockers. | +| Code | Meaning | Route | +| --- | --- | --- | +| `unknown-target` | The requested runtime target is unsupported. | Correct the target. | +| `unsupported-family` | The target does not support this resource family. | Select a supported target. | +| `docs-unverified` | Runtime support is not documented enough for writes. | Use read-only modes or add support evidence. | +| `needs-directory-create` | A required runtime directory is absent. | Rerun apply with explicit `--create-missing-dirs`. | +| `permission-denied` | The runtime path is not accessible enough. | Repair permissions. | +| `unsafe-home-path` | A path escaped its allowed runtime location. | Stop and repair the path. | +| `symlink-not-allowed` | The runtime root or an intermediate path crosses a link boundary. | Replace it with a real confined directory. | +| `symlink-unsupported` | The filesystem cannot create required skill links. | Use a supported filesystem; copied skills are not allowed. | +| `link-target-missing` | A managed home link is broken or its source disappeared. | Restore the source or remove the broken link after review. | +| `link-target-mismatch` | A home link points to another checkout. | Rerun after correcting the link; do not overwrite it automatically. | +| `manifest-missing` | A read-only manifest-backed mode has no prior state. | Treat it as first-run evidence. | +| `manifest-corrupt` | The manifest cannot be trusted. | Repair or remove the state before apply. | +| `target-exists-unmanaged` | A copied agent target is unmanaged. | Preserve it or resolve ownership before apply. | +| `target-modified-managed` | A copied agent changed after the recorded hash. | Review the local change before replacing it. | +| `source-missing` | A catalog source no longer exists. | Repair the catalog or source. | +| `source-invalid-skill` | A repository skill lacks `SKILL.md`. | Repair the source bundle. | +| `stale-managed` | A copied managed resource is no longer planned. | Review and use explicit `--prune-managed` if appropriate. | +| `prune-not-approved` | Copied-resource pruning needs explicit approval. | Rerun apply with `--prune-managed`. | +| `stale-content-drifted` | A stale copied resource changed locally. | Review it before deletion. | +| `stale-path-unresolvable` | A stale path cannot be confined safely. | Repair manifest state before deletion. | +| `reverse-sync-blocked` | The requested source is inside home sync state. | Select the repository source; reverse writes are forbidden. | +| `retire-target-overlap` | A runtime was selected and retired simultaneously. | Make target selection unambiguous. | diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md b/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md index 0fab99a9..11717e6a 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md +++ b/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md @@ -1,355 +1,104 @@ # Home Sync Contract -Use this reference when the paired agent or skill needs the exact sync rules rather than the compact summary in `SKILL.md`. +Use this reference for the exact repository-to-home contract. -## Scope +## Scope And Ownership -- Source root: this repository. -- Target roots: runtime home resource directories for supported AI runtimes. -- Managed families in v1: allowlisted `skills` and `agents`. -- Excluded in v1: non-`skills` and non-`agents` runtime resources and undocumented families. -- Skill resources are normally auto-discovered from `.github/skills/` according to `home-sync-catalog.yaml` defaults. The catalog should list policy defaults and explicit non-skill resources, not serialize every skill bundle. Defaults may also exclude specific home-kept skill IDs from install and bisync and may declare whether unmanaged home skill bundles stay blocked or are adopted with repo-wins behavior. +- `.github/skills/` is the sole source of truth for managed skill bundles. +- `~/.agents/skills/` remains a real directory. It is a runtime projection, + not a second source. +- Eligible skills are materialized as one absolute canonical symbolic link per + skill. A write through that link changes the repository object directly. +- Allowlisted agents retain their existing translation and copy behavior. +- Never copy, merge, or reconcile home skill content into the repository. +- Preserve all home-only skills, including `graphify`, every `local-*` bundle, + invalid repository bundles, and every catalog-excluded ID. -## State Root +## State And Manifest -Keep local state under `~/.sync/cloud-strategy-governance/home-ai-resources/`. - -Expected state files: +State belongs under `~/.sync/cloud-strategy-governance/home-ai-resources/`: - `manifest.json` - `last-plan.json` - `last-audit.json` -- `last-bisync-plan.json` - `locks/home-ai-resources.lock` -Optional debug logs may live under `logs/` when the implementation needs durable operator evidence. - -## Managed Resource Rules - -- Copy files and directories. Do not create symlinks in v1. -- Preserve unmanaged target-local files unless the active catalog policy explicitly adopts unmanaged skill bundles with repo-wins behavior. -- Record one manifest row per managed target resource. -- Prune only resources that were previously manifest-managed and are now absent from the new plan, including resources whose source bundle disappeared from the repo after an earlier sync. -- Require explicit prune approval before deleting stale managed resources. -- Treat `--retire-targets` as the explicit declaration that a previously active runtime should leave the managed target set on the next plan or apply. -- Exclude runtime-generated bundle artifacts from hashes and copies: `.venv`, `__pycache__`, `.pytest_cache`, `.pyc`, and `.pyo`. - -## Manifest Fields - -Minimum fields: - -- `schema_version` -- `generated_at` -- `source_root` -- `source_revision` -- `state_root` -- `targets` -- `managed_resources[]` - -Each `managed_resources[]` item should capture: - -- `target` -- `resource_family` -- `resource_id` -- `source_path` -- `target_path` -- `source_hash` -- `content_hash` -- `last_action` - -## Target Path Rules - -- Resolve every target path under the selected home directory. -- Block paths that escape the expected home root. -- Block unsupported symlink hops. -- Treat missing runtime roots as `needs-directory-create` in `plan` and `doctor`. -- Allow directory creation in `apply` only with explicit approval. - -## Reporting Contract - -`--format compact` is the default and the preferred format for AI/tool -iteration. It emits a single-line JSON object with status, counts, blockers, -next action, and a small bounded evidence sample. Use `--format report` only -when a human-readable command report is explicitly needed. In chat, agents -should translate compact output into concise Markdown instead of asking the CLI -to spend tokens on tables. - -Deterministic report output (`--format report`) and JSON reporting should expose at least: - -- `selected_targets` -- `mode` -- `source_resources_considered` -- `copied` -- `skipped` -- `blocked` -- `conflicts` -- `unsupported_families_by_target` -- `missing_dirs` -- `validation` -- `residual_drift` -- `next_step` -- `next_action` (structured object with `action`, `allowed`, `requires_explicit_approval`, `command`, `reason`) - -Text reports must use a summary-first layout rather than a raw field dump. Use tables where columns clarify changes, blockers, or completed actions; use short bullets for counts and state summaries. - -Human-readable report tables should stay bounded for routine change and completed-action rows. Keep all blocker, attention, and readiness-failure rows visible. When rows are omitted, add an explicit omitted-count row and point to `--format json` for full detail. - -## Canonical Chat Template - -Use this template when you need to answer the user directly in chat. Keep the wording plain and convert the labels into the conversation language when appropriate. - -### Problem Report - -Use this structure when the sync is not yet finished or when the user needs to choose a direction. Skip any section that would be empty instead of rendering a heading with no content: - -1. `🚦 Status` - - One short line with the overall result. -2. `πŸ“Œ What is happening` - - One short paragraph that explains the current state in plain language. -3. `πŸ” Differences` - - List only the actionable changes. - - Omit the section entirely when there is nothing actionable to show. - - Number the items when the user may need to choose between them. - - Summarize unchanged or skipped resources by count unless the user asked for the full list. -4. `β›” Why it stops` - - Explain the smallest real blocker, not the internal code name. - - Include only the drift or policy item that prevents the next step. -5. `🎯 Choices` - - Present only the actions the user can actually take now. - - Use numbered options. - - Put the recommended choice first. - - Keep each option to one line when possible. - -### Completion Report - -Use this structure when the work is finished. Skip any section that would be empty instead of rendering a heading with no content: - -1. `βœ… Result` - - Say whether the run completed, applied changes, or ended as no-op. -2. `πŸ› οΈ What changed` - - One short paragraph with the concrete outcome. - - Omit the section entirely when nothing changed. -3. `πŸ”Ž Final verification` - - State the strongest evidence available, such as a clean plan, hash match, or zero residual drift. -4. `🧾 Residuals` - - Include only if something still needs attention. - - If nothing remains, say `none`. -5. `➑️ Next step` - - If the run is done, say there is nothing else to do. - - If the run is blocked, name the single next action. - -### Shared Header - -Always start with a short `🚦 Status` line that includes: - -- mode -- human-friendly lane label, such as `repo-to-home install` for install or `repo-home drift` for bisync -- selected targets -- overall result or status -- blocker count -- `next_action.action` - -### Summary-First Layout - -Use these stable sections when rendering model-facing reports: - -- `🧭 Summary`: target, resource, drift, blocked, and already-aligned counts. -- `🩺 Readiness`: doctor-only non-ok checks with why they matter, what blocks next, and the recommended action. -- `πŸ› οΈ Changes`: proposed or completed writes, with one row per changed resource. -- `⚠️ Attention`: blockers, ambiguous drift, or decisions that need a user. -- `πŸ”Ž Validation`: strongest available evidence such as hash match, manifest path, state path, or post-apply clean plan. -- `🧾 Remaining Work`: include only when something remains. -- `➑️ Next`: structured next action and reason. - -Do not list every unchanged managed resource in model-facing reports. Summarize skipped or already-aligned resources by count unless the user asks for full audit detail or selects JSON output. - -### Doctor And Readiness Report - -After the shared header, include a short readiness summary and a single table with this shape: - -| Check or path | Status | Why it matters | What blocks next | Recommended action | -| --- | --- | --- | --- | --- | - -Use this table for missing roots, documentation gaps, manifest problems, permission failures, and unsafe paths. - -### Sync, Plan, Audit, And Bisync Plan Report - -For top-level `sync`, use this compact chat order: `🚦 Status`, `🧭 Summary`, `πŸš€ Auto-applied` or `πŸ“‹ Planned repo-to-home copies`, `β›” Stopped on`, `πŸ”Ž Validation`, and `➑️ Next`. - -After the shared header and summary, show one change-oriented table and one attention table when they are useful. - -Planned changes table: - -| Resource or path | Lane | Planned action | Why this will change | Evidence or winner | -| --- | --- | --- | --- | --- | - -Typical reasons include repo bundle newer than home, home bundle newer than repo, first-run install into a missing directory, stale managed resource marked for optional prune, or unmanaged content preserved by policy. - -Attention table: - -| Code or status | Resource or path | Why it needs attention | Required user action | -| --- | --- | --- | --- | - -Populate `Why it needs attention` from the error-code meaning plus rationale, not from the code alone. Include skips only when they are exceptional; routine unchanged resources belong in the summary count. - -### Apply And Bisync Apply Completion Report - -After the shared header, show one completed-actions table and one residual-issues table when needed. - -Completed actions table: - -| Resource or path | Action performed | Why it was done | Result | Verification | -| --- | --- | --- | --- | --- | - -Allowed action labels include `copied`, `updated`, `pruned`, `preserved`, `skipped`, and `no-op`. - -Residual issues table: - -| Resource or path | Residual issue | Why it remains | Required follow-up | -| --- | --- | --- | --- | - -`Verification` should state the strongest evidence available, for example hash match, manifest updated, post-apply plan clean, or explicit validation gap. - -### Top-Level Sync Mode - -The `sync` command is the only auto-execute mode. - -Behavior: - -1. Build an install-lane `apply` plan for the selected targets, defaulting to `skills`. -2. Stop before writing when install blockers, residual drift, stale managed resources, missing directory creation without `--create-missing-dirs`, or destructive cleanup gates are present. -3. If the install lane is clean, apply repo-to-home materialization and verify hashes plus manifest state. -4. For the default `skills` target, run `bisync plan` after install. -5. Stop and report `needs_review` when bisync reports `home-to-repo`, `only-home`, `equal-mtime`, or another non-safe blocker. Do not run `bisync apply` automatically. -6. Treat `repo-to-home` and `only-repo` bisync entries as safe informational leftovers for default `sync`; report them without stopping the sync run. -7. Report `done` when install succeeded or had no work and bisync has no home-owned or ambiguous drift. - -Exit behavior: - -- Return `0` when `sync` reaches `done`. -- Return non-zero when `sync` stops for blocker, missing approval, or bisync review. - -### No-Op Reporting - -When no changes are proposed or applied, report `no-op` explicitly with the reason and still surface validation and `next_action`. - -### Next Action Schema - -```json -{ - "action": "apply|resolve_blockers|review|done|unknown", - "allowed": true, - "requires_explicit_approval": true, - "command": "apply --targets codex --home-root ~", - "reason": "Plan is ready with zero blockers. Run apply when ready." -} -``` - -## Automation Entry Points - -- Bundled dependency bootstrap and canonical CLI: `scripts/run.sh` -- Bundled Python CLI: `scripts/sync_home_ai_resources.py` -- Bundled implementation: `scripts/home_syncing.py` -- Bundled bisync engine: `scripts/bisync_skills.py` -- Bundled reference loader: `scripts/home_sync_contract.py` -- Bundled dependency lock: `scripts/requirements.txt` -- Repository dispatcher compatibility path: `.github/scripts/run.sh sync_home_ai_resources ...` - -Prefer the bundled runner in this repository and after direct-copy into a home -runtime. The repository dispatcher exists only as a compatibility path and must -delegate to the bundled runner instead of carrying duplicate sync logic. - -## Install Sync Contract - -The install lane provides unidirectional `repo -> home` materialization of allowlisted resources. - -### Install Modes - -- `sync`: safe top-level automation that may apply only clean repo-to-home install work before running the bisync review gate. -- `plan`: dry run that produces a readable diff and machine-readable state. Read-only. -- `audit`: compare source, manifest, and managed target paths. Read-only. -- `--fast`: manifest-focused shortcut for `plan` and `audit` only. It must not change `apply` or `sync` source discovery. -- `doctor`: verify runtime roots, permissions, symlink posture, and manifest health. Read-only. -- `apply`: materialize approved operations. Writes to home only. -- `dry-run`: alias of `plan`. - -### Safety - -- Block `apply` when `blocked_codes` are present. -- Block `apply` when runtime targets are undocumented and `--experimental-targets` is not set. -- Block `apply` on unmanaged overwrite, modified managed files, and stale-content drift. -- Block planning and apply when a target appears in both `--targets` and `--retire-targets`. -- Block `apply` when source root falls under home sync state directory. -- Block `apply` when manifest is corrupt. - -### Post-apply +`manifest.json` uses schema version 2. Each row has these common fields: -- Verify every copied resource by hash comparison. -- Write updated manifest with content hashes. -- When `bisync apply` copies a repo-wins bundle into home and the install manifest tracks that target, refresh the matching manifest entry so the next install plan does not report a stale `target-modified-managed` blocker for the verified copy. -- If reconciliation cannot be proven safe after a bisync copy, return `bisync-manifest-reconcile-failed` and keep the blocker visible instead of claiming convergence. -- Rewrite the manifest target set to the requested active targets only; retired targets are removed from manifest state after a successful apply. -- Report residual drift entries. +- `target`, `resource_family`, `resource_id`, `source_path`, `target_path`, + `source_hash`, and `last_action`. -## Bisync Contract +Skill rows have `materialization: symlink`, an absolute canonical +`link_target`, and `content_hash: null`. Agent rows have +`materialization: copy`, `link_target: null`, and a content hash. Schema-v1 +rows are normalized in memory as copied resources and are rewritten only after +a successful apply. -The `bisync` lane provides explicit bidirectional reconciliation between `.github/skills/` and `~/.agents/skills/`. It is a separate lane from install sync. +## Planning -### Bisync Modes +For each eligible skill, calculate the absolute canonical source path and +inspect the direct home child: -- `bisync plan`: detect drift. Read-only. Produces a drift list with entries for `repo-to-home`, `home-to-repo`, `only-repo`, `only-home`, and `equal-mtime`. -- `bisync apply`: resolve drift by copying winner to loser. Writes to both repo and home as needed, but only after a reviewed matching `bisync plan` snapshot exists for the same repo and home roots. +| Current target | Plan action | +| --- | --- | +| Missing | `link` | +| Link to the canonical source | `skip`; adopt it into the manifest | +| Existing non-link directory with the same eligible ID | `link`; replace it without backup | +| Broken link | `blocked` with `link-target-missing` | +| Link to another checkout | `blocked` with `link-target-mismatch` | -### Logic +Skills never use mtime or hashes to choose a winner. Agents retain the +existing hash-based copy and explicit-prune behavior. -1. Scan both directories and collect all skill names (union). -2. Exclude bundles whose name starts with `local-`. -3. For each skill present in both sides: - - Compute content hash (excluding `.venv`, `__pycache__`, `.pytest_cache`, `.pyc`, `.pyo`). - - If hashes match, the skill is `in-sync` (not reported). - - If hashes differ, compare max mtime across all files in the bundle: - - `repo_mtime > home_mtime` -> `repo-to-home` - - `home_mtime > repo_mtime` -> `home-to-repo` - - `repo_mtime == home_mtime` -> `equal-mtime` blocker -4. For skills only on one side: +A stale schema-v2 skill link is planned as `unlink` without +`--prune-managed`, but only when it is a direct child of the real runtime skill +root. A stale copied agent remains subject to its explicit prune safety. -- report `only-repo` as an actionable repo-to-home creation candidate; -- report `only-home` as a blocker that requires manual review. +## Path Safety And Apply -### Preflight +Before changing a skill target, require all of the following: -Before any write in `bisync apply`: +1. the source is a valid repository skill bundle containing `SKILL.md`; +2. the target is a direct child of the real runtime skill root; +3. the runtime skill root and every intermediate parent are real directories, + not links; +4. the source and target stay inside their respective allowed roots; and +5. symlink creation is supported by the active filesystem. -1. Require a reviewed `last-bisync-plan.json` snapshot that matches the current drift plan for the same repo and home roots. -2. Verify `git status --porcelain --untracked-files=all` on the source repository. -3. Block `apply` if the repository is not clean. -4. Block `apply` if any `only-home` or `equal-mtime` entry exists in the current plan. +After those checks, `create_skill_link` may remove a colliding real directory +without backup and create the absolute link. `unlink_managed_skill` unlinks +the link itself, including a broken link, and never follows its target. -### Apply +Apply verifies every skill by exact link identity and every copied agent by +its expected hash. It then writes manifest v2. Unsupported link capability is +`symlink-unsupported`; do not fall back to copied skills. -- Process `repo-to-home`, `home-to-repo`, and actionable `only-repo` drift entries. -- Copy the winner bundle (with runtime artifact exclusions) to the loser path. -- Remove the loser directory before copying to ensure a clean replacement. -- Verify the target hash matches the winner hash after copy. +Moving the repository checkout invalidates canonical link targets. Rerun sync +from the new checkout to recreate managed links. -### Post-apply Verification +## Modes -- Re-run `bisync plan` after all copies complete. -- Accept success only when zero drift entries and zero blocked codes remain. -- Report residual drift with blocker codes on failure. +- `sync`: build an apply plan and auto-apply only clean repository-to-home + work. It may create links and copy agents. +- `plan` or `dry-run`: read-only proposed operations. +- `audit`: read-only comparison of source, manifest, and managed targets. +- `doctor`: read-only runtime-root, support, catalog, and state checks. +- `apply`: explicit materialization; directory creation and copied-agent + pruning retain their explicit flags. -### Exclusions +Do not run against the real home during tests. Use a temporary home root. -- Runtime artifacts: `.venv`, `__pycache__`, `.pytest_cache`, `.pyc`, `.pyo`. -- Bundle prefix: `local-*` bundles are excluded from bisync scanning and copying. +## Reporting -### Output +Compact and report payloads expose `linked`, `unlinked`, copied agents, +skipped resources, blockers, and a bounded path sample. Report counts for +unchanged skills rather than enumerating them. Translate every blocker using +`error-codes.md` and state the next action. -The `bisync` payload includes: +## Entry Points -- `drifts`: list of drift entries with `skill`, `type`, `direction`, `repo`, `home`. -- `blocked_codes`: list of active blocker codes. -- `next_step`: human-readable next instruction. -- `next_action`: structured object with `action`, `allowed`, `requires_explicit_approval`, `command`, `reason`. -- `verification`: post-apply status with `status` and optional `reason` or `residual_drifts`. -- `bisync-plan-required`: emitted when `bisync apply` is requested without a matching reviewed snapshot. +- Canonical runner: `scripts/run.sh` +- Python CLI: `scripts/sync_home_ai_resources.py` +- Planning and apply implementation: `scripts/home_syncing.py` +- Contract loader: `scripts/home_sync_contract.py` +- Repository dispatcher: `.github/scripts/run.sh sync_home_ai_resources ...` diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/bisync_skills.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/bisync_skills.py deleted file mode 100644 index ccd669e0..00000000 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/bisync_skills.py +++ /dev/null @@ -1,704 +0,0 @@ -#!/usr/bin/env python3 -"""Bisync skills between source .github/skills/ and home ~/.agents/skills/ using hash+mtime resolution. - -Standalone operation: - python3 bisync_skills.py plan --source-root /repo --home-root /home/user - python3 bisync_skills.py apply --source-root /repo --home-root /home/user - -Importable for CLI integration: - from bisync_skills import build_bisync_plan, apply_bisync_plan -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import shutil -import subprocess -import sys -from dataclasses import dataclass, field -from pathlib import Path - -_SCRIPTS_LIB = Path(__file__).resolve().parents[3] / "scripts" / "lib" -if _SCRIPTS_LIB.parent.as_posix() not in sys.path: - sys.path.insert(0, _SCRIPTS_LIB.parent.as_posix()) - -from home_sync_contract import load_home_sync_policy, state_root_for_home -from home_syncing import reconcile_manifest_entry_after_bisync_copy -from lib.sync_exclusions import should_ignore_sync_path, sync_copytree_ignore -from sync_output import ( - build_compact_bisync_output, - dump_compact_json, - render_bisync_report, -) - -EXCLUDED_BUNDLE_PREFIX: str = "local-" -BISYNC_PLAN_PATH = "last-bisync-plan.json" - - -def should_ignore(path: Path) -> bool: - return should_ignore_sync_path(path) - - -def should_ignore_copytree(directory: str, names: list[str]) -> set[str]: - return sync_copytree_ignore(directory, names) - - -def get_max_mtime(directory: Path) -> float: - max_time = 0.0 - for path in directory.rglob("*"): - if path.is_file() and not should_ignore(path): - max_time = max(max_time, path.stat().st_mtime) - return max_time - - -def hash_bundle(directory: Path) -> str: - files: list[dict[str, str]] = [] - for path in sorted(directory.rglob("*")): - if path.is_file() and not should_ignore(path): - rel = path.relative_to(directory).as_posix() - content = path.read_bytes() - files.append({"path": rel, "hash": hashlib.sha256(content).hexdigest()[:16]}) - return hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest() - - -def is_repo_clean(source_root: Path) -> tuple[bool, str, str]: - try: - result = subprocess.run( - ["git", "status", "--porcelain", "--untracked-files=all"], - cwd=source_root, - text=True, - capture_output=True, - check=False, - timeout=10, - ) - except subprocess.TimeoutExpired: - return False, "bisync-repo-git-failed", "git status timed out" - if result.returncode != 0: - return False, "bisync-repo-git-failed", "Unable to run git status before bisync apply." - if result.stdout.strip(): - return ( - False, - "bisync-repo-dirty", - "Repository has uncommitted or untracked changes.", - ) - return True, "", "" - - -def compute_next_action( - blocked_codes: list[str], - has_drift_items: bool, - mode: str, - source_root: Path, - home_root: Path, -) -> dict: - if mode == "plan": - if blocked_codes: - return { - "action": "resolve_blockers", - "allowed": False, - "requires_explicit_approval": True, - "command": "", - "reason": "Blocked codes prevent apply. Resolve each blocker manually.", - } - if has_drift_items: - return { - "action": "apply", - "allowed": True, - "requires_explicit_approval": True, - "command": f"bisync apply --source-root {source_root} --home-root {home_root}", - "reason": "Plan shows resolvable drift. Apply copies winner to loser for each drifted skill.", - } - return { - "action": "done", - "allowed": False, - "requires_explicit_approval": False, - "command": "", - "reason": "No drift detected. Source and home are in sync.", - } - if mode == "apply": - if blocked_codes: - return { - "action": "resolve_blockers", - "allowed": False, - "requires_explicit_approval": True, - "command": "", - "reason": "Blocked codes remain after apply. Manual resolution required.", - } - return { - "action": "done", - "allowed": False, - "requires_explicit_approval": False, - "command": "", - "reason": "Bisync completed. Source and home are converged.", - } - return { - "action": "unknown", - "allowed": False, - "requires_explicit_approval": True, - "command": "", - "reason": f"Unknown mode: {mode}", - } - - -@dataclass(frozen=True) -class BisyncDriftEntry: - skill_name: str - drift_type: str - direction: str | None = None - repo_path: str = "" - home_path: str = "" - repo_hash: str = "" - home_hash: str = "" - repo_mtime: float = 0.0 - home_mtime: float = 0.0 - blocked_codes: tuple[str, ...] = () - - def to_dict(self) -> dict: - result: dict = { - "skill": self.skill_name, - "type": self.drift_type, - "repo": self.repo_path, - "home": self.home_path, - } - if self.direction: - result["direction"] = self.direction - if self.blocked_codes: - result["blocked_codes"] = self.blocked_codes - return result - - -@dataclass -class BisyncPlan: - source_root: Path - home_root: Path - source_skills_root: Path - home_skills_root: Path - mode: str - drifts: list[BisyncDriftEntry] = field(default_factory=list) - blocked_codes: list[str] = field(default_factory=list) - next_step: str = "" - next_action: dict = field(default_factory=dict) - verification: dict = field(default_factory=dict) - - def to_dict(self) -> dict: - return { - "mode": self.mode, - "source_root": self.source_root.as_posix(), - "home_root": self.home_root.as_posix(), - "source_skills_root": self.source_skills_root.as_posix(), - "home_skills_root": self.home_skills_root.as_posix(), - "drifts": [d.to_dict() for d in self.drifts], - "blocked_codes": self.blocked_codes, - "next_step": self.next_step, - "next_action": self.next_action, - "verification": self.verification, - } - - -def build_bisync_plan( - source_root: Path, - home_root: Path, - *, - mode: str = "plan", -) -> BisyncPlan: - policy = load_home_sync_policy(source_root) - source_skills = source_root / ".github" / "skills" - home_skills = home_root / ".agents" / "skills" - - blocked_codes: list[str] = [] - if not source_skills.exists() or not source_skills.is_dir(): - blocked_codes.append("bisync-source-missing") - - root_check = ( - _root_check(source_skills, "bisync-source-missing") - if source_skills.exists() - else [] - ) - blocked_codes.extend(root_check) - - if not home_skills.exists() or not home_skills.is_dir(): - blocked_codes.append("bisync-home-missing") - - root_check_home = ( - _root_check(home_skills, "bisync-home-missing") - if home_skills.exists() - else [] - ) - blocked_codes.extend(root_check_home) - - drifts: list[BisyncDriftEntry] = [] - if blocked_codes: - plan = BisyncPlan( - source_root=source_root, - home_root=home_root, - source_skills_root=source_skills, - home_skills_root=home_skills, - mode=mode, - blocked_codes=sorted(set(blocked_codes)), - ) - plan.next_step = _next_step_for_bisync(plan) - plan.next_action = compute_next_action( - plan.blocked_codes, bool(plan.drifts), mode, source_root, home_root - ) - return plan - - repo_names = { - p.name - for p in source_skills.iterdir() - if p.is_dir() - and not p.name.startswith(EXCLUDED_BUNDLE_PREFIX) - and p.name not in policy.excluded_skills - } - home_names = { - p.name - for p in home_skills.iterdir() - if p.is_dir() - and not p.name.startswith(EXCLUDED_BUNDLE_PREFIX) - and p.name not in policy.excluded_skills - } - all_names = sorted(repo_names | home_names) - - for skill_name in all_names: - repo_path = source_skills / skill_name - home_path = home_skills / skill_name - in_repo = skill_name in repo_names - in_home = skill_name in home_names - - if in_repo and not in_home: - drifts.append( - BisyncDriftEntry( - skill_name=skill_name, - drift_type="only-repo", - direction="repo-to-home", - repo_path=repo_path.as_posix(), - home_path=home_path.as_posix(), - repo_hash=hash_bundle(repo_path), - ) - ) - continue - - if in_home and not in_repo: - drifts.append( - BisyncDriftEntry( - skill_name=skill_name, - drift_type="only-home", - repo_path=repo_path.as_posix(), - home_path=home_path.as_posix(), - blocked_codes=["bisync-only-home"], - ) - ) - continue - - repo_hash = hash_bundle(repo_path) - home_hash = hash_bundle(home_path) - - if repo_hash == home_hash: - continue - - repo_mtime = get_max_mtime(repo_path) - home_mtime = get_max_mtime(home_path) - - if repo_mtime > home_mtime: - drifts.append( - BisyncDriftEntry( - skill_name=skill_name, - drift_type="drift", - direction="repo-to-home", - repo_path=repo_path.as_posix(), - home_path=home_path.as_posix(), - repo_hash=repo_hash, - home_hash=home_hash, - repo_mtime=repo_mtime, - home_mtime=home_mtime, - ) - ) - elif home_mtime > repo_mtime: - drifts.append( - BisyncDriftEntry( - skill_name=skill_name, - drift_type="drift", - direction="home-to-repo", - repo_path=repo_path.as_posix(), - home_path=home_path.as_posix(), - repo_hash=repo_hash, - home_hash=home_hash, - repo_mtime=repo_mtime, - home_mtime=home_mtime, - ) - ) - else: - drifts.append( - BisyncDriftEntry( - skill_name=skill_name, - drift_type="equal-mtime", - repo_path=repo_path.as_posix(), - home_path=home_path.as_posix(), - repo_hash=repo_hash, - home_hash=home_hash, - repo_mtime=repo_mtime, - home_mtime=home_mtime, - blocked_codes=["bisync-equal-mtime"], - ) - ) - - all_blocked_set = { - code - for d in drifts - for code in d.blocked_codes - } - for drift in drifts: - if drift.drift_type == "only-repo": - all_blocked_set.add("bisync-only-repo") - all_blocked = sorted(all_blocked_set) - - plan = BisyncPlan( - source_root=source_root, - home_root=home_root, - source_skills_root=source_skills, - home_skills_root=home_skills, - mode=mode, - drifts=drifts, - blocked_codes=all_blocked, - ) - plan.next_step = _next_step_for_bisync(plan) - plan.next_action = compute_next_action( - plan.blocked_codes, bool(plan.drifts), mode, source_root, home_root - ) - plan.verification = { - "status": "ok" if not all_blocked else "blocked", - "total_drifts": len(drifts), - } - return plan - - -def apply_bisync_plan( - source_root: Path, - home_root: Path, - plan: BisyncPlan, -) -> BisyncPlan: - if plan.blocked_codes: - resolvable_during_apply = {"bisync-only-repo"} - remaining_blockers = [ - code for code in plan.blocked_codes if code not in resolvable_during_apply - ] - if remaining_blockers: - return plan - plan.blocked_codes = [] - - clean, blocked_code, reason = is_repo_clean(source_root) - if not clean: - plan.blocked_codes = [blocked_code] - plan.mode = "apply" - plan.next_step = "Repository is not clean. Bisync apply is blocked. Commit or stash changes." - plan.next_action = { - "action": "resolve_blockers", - "allowed": False, - "requires_explicit_approval": True, - "command": "", - "reason": reason, - } - plan.verification = { - "status": "blocked", - "code": blocked_code, - "reason": reason, - } - return plan - - drifts_to_resolve = [ - d - for d in plan.drifts - if d.drift_type == "drift" or (d.drift_type == "only-repo" and d.direction == "repo-to-home") - ] - for drift in drifts_to_resolve: - if drift.direction == "repo-to-home": - src = Path(drift.repo_path) - dst = Path(drift.home_path) - elif drift.direction == "home-to-repo": - src = Path(drift.home_path) - dst = Path(drift.repo_path) - else: - continue - - for check_path in (src, dst): - resolved = check_path.resolve() - if check_path.is_symlink() or any(p.is_symlink() for p in check_path.parents): - plan.blocked_codes.append("symlink-not-allowed") - plan.blocked_codes = sorted(set(plan.blocked_codes)) - plan.verification = {"status": "blocked", "code": "symlink-not-allowed", "path": str(check_path)} - plan.next_step = _next_step_for_bisync(plan) - plan.next_action = compute_next_action( - plan.blocked_codes, bool(plan.drifts), "apply", source_root, home_root - ) - return plan - - if dst.exists(): - shutil.rmtree(dst) - shutil.copytree(src, dst, ignore=should_ignore_copytree) - - actual_hash = hash_bundle(dst) - expected_hash = ( - drift.repo_hash - if drift.direction == "repo-to-home" - else drift.home_hash - ) - if actual_hash != expected_hash: - plan.blocked_codes.append("bisync-verify-failed") - plan.blocked_codes = sorted(set(plan.blocked_codes)) - plan.verification = { - "status": "blocked", - "code": "bisync-verify-failed", - "skill": drift.skill_name, - "reason": f"Post-copy hash mismatch for {drift.skill_name}", - } - plan.next_step = _next_step_for_bisync(plan) - plan.next_action = compute_next_action( - plan.blocked_codes, bool(plan.drifts), "apply", source_root, home_root - ) - return plan - - if drift.direction == "repo-to-home": - reconcile_code = reconcile_manifest_entry_after_bisync_copy( - source_root=source_root, - home_root=home_root, - source_path=src, - target_path=dst, - ) - if reconcile_code is not None: - plan.blocked_codes.append(reconcile_code) - plan.blocked_codes = sorted(set(plan.blocked_codes)) - plan.verification = { - "status": "blocked", - "code": reconcile_code, - "skill": drift.skill_name, - "reason": f"Manifest reconciliation failed after verified copy for {drift.skill_name}", - } - plan.next_step = _next_step_for_bisync(plan) - plan.next_action = compute_next_action( - plan.blocked_codes, bool(plan.drifts), "apply", source_root, home_root - ) - return plan - - post_plan = build_bisync_plan(source_root, home_root, mode="verify") - if post_plan.drifts or post_plan.blocked_codes: - plan.blocked_codes.extend(post_plan.blocked_codes) - if post_plan.drifts: - plan.blocked_codes.append("bisync-residual-drift") - plan.blocked_codes = sorted(set(plan.blocked_codes)) - plan.verification = { - "status": "blocked", - "code": "bisync-residual-drift" if post_plan.drifts else "bisync-post-apply-blocked", - "reason": f"Post-apply drift still detected: {len(post_plan.drifts)} drift(s)", - "residual_drifts": [d.to_dict() for d in post_plan.drifts], - } - plan.next_step = _next_step_for_bisync(plan) - plan.next_action = compute_next_action( - plan.blocked_codes, - bool(post_plan.drifts), - "apply", - source_root, - home_root, - ) - return plan - - plan.blocked_codes = [] - plan.verification = { - "status": "converged", - "reason": "Post-apply plan shows zero drift and zero blockers.", - } - plan.mode = "apply" - plan.next_step = "Bisync apply completed. 0 drift detected." - plan.next_action = compute_next_action( - [], False, "apply", source_root, home_root - ) - return plan - - -def write_bisync_plan_snapshot(plan: BisyncPlan) -> Path: - state_root = state_root_for_home(plan.home_root) - state_root.mkdir(parents=True, exist_ok=True) - snapshot_path = state_root / BISYNC_PLAN_PATH - snapshot = { - "source_root": plan.source_root.as_posix(), - "home_root": plan.home_root.as_posix(), - "drifts": [drift.to_dict() for drift in plan.drifts], - "blocked_codes": plan.blocked_codes, - } - snapshot_path.write_text(json.dumps(snapshot, indent=2, sort_keys=True), encoding="utf-8") - return snapshot_path - - -def load_bisync_plan_snapshot(source_root: Path, home_root: Path) -> dict[str, object] | None: - snapshot_path = state_root_for_home(home_root) / BISYNC_PLAN_PATH - if not snapshot_path.exists(): - return None - try: - snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - return None - if not isinstance(snapshot, dict): - return None - if snapshot.get("source_root") != source_root.as_posix(): - return None - if snapshot.get("home_root") != home_root.as_posix(): - return None - return snapshot - - -def reviewed_bisync_plan_matches(plan: BisyncPlan, snapshot: dict[str, object]) -> bool: - return ( - snapshot.get("drifts") == [drift.to_dict() for drift in plan.drifts] - and snapshot.get("blocked_codes") == plan.blocked_codes - ) - - -def _next_step_for_bisync(plan: BisyncPlan) -> str: - if plan.mode == "plan": - if plan.blocked_codes: - return "Resolve blocked codes before bisync apply." - if plan.drifts: - return "Review the drift plan and run bisync apply when ready." - return "Source and home are already in sync." - if plan.mode == "apply": - if plan.blocked_codes: - return "Bisync apply blocked. Review blocker codes." - return "Bisync apply completed. Verify with bisync plan." - if plan.mode == "verify": - if plan.drifts or plan.blocked_codes: - return "Post-apply verification shows residual drift. Manual review required." - return "Verification passed. Source and home are converged." - return "Review the generated output." - - -def _root_check(root: Path, code: str) -> list[str]: - blocked: list[str] = [] - resolved = root.resolve() - try: - if not resolved.is_dir(): - blocked.append(code) - except (OSError, PermissionError): - blocked.append(code) - return blocked - - -def run_bisync_plan(args: argparse.Namespace) -> int: - source_root = _resolve_source_root(args) - home_root = Path(args.home_root).expanduser().resolve() - plan = build_bisync_plan(source_root, home_root, mode="plan") - write_bisync_plan_snapshot(plan) - _emit_bisync_output(plan, args.format) - return 1 if plan.blocked_codes else 0 - - -def run_bisync_apply(args: argparse.Namespace) -> int: - source_root = _resolve_source_root(args) - home_root = Path(args.home_root).expanduser().resolve() - plan = build_bisync_plan(source_root, home_root, mode="plan") - snapshot = load_bisync_plan_snapshot(source_root, home_root) - if snapshot is None or not reviewed_bisync_plan_matches(plan, snapshot): - plan.mode = "apply" - plan.blocked_codes = sorted(set([*plan.blocked_codes, "bisync-plan-required"])) - plan.next_step = "Run bisync plan first, review that exact drift snapshot, then rerun bisync apply." - plan.next_action = { - "action": "review", - "allowed": False, - "requires_explicit_approval": True, - "command": f"bisync plan --source-root {source_root} --home-root {home_root}", - "reason": "Bisync apply requires a reviewed matching plan snapshot before any write.", - } - plan.verification = { - "status": "blocked", - "code": "bisync-plan-required", - "reason": "Bisync apply requires a reviewed matching plan snapshot.", - } - _emit_bisync_output(plan, args.format) - return 1 - resolvable_during_apply = {"bisync-only-repo"} - remaining_blockers = [ - code for code in plan.blocked_codes if code not in resolvable_during_apply - ] - if remaining_blockers: - _emit_bisync_output(plan, args.format) - return 1 - - result = apply_bisync_plan(source_root, home_root, plan) - _emit_bisync_output(result, args.format) - return 1 if result.blocked_codes else 0 - - -def _resolve_source_root(args: argparse.Namespace) -> Path: - root = Path(args.source_root).resolve() - if root.name == ".github" and (root / "skills").is_dir(): - return root.parent - return root - - -def _emit_bisync_output(plan: BisyncPlan, format_name: str) -> None: - payload = plan.to_dict() - if format_name == "compact": - print(dump_compact_json(build_compact_bisync_output(payload))) - return - if format_name == "json": - print(json.dumps(payload, indent=2, sort_keys=True)) - return - print(render_bisync_report(payload), end="") - - -def build_bisync_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Bisync skills between source repo and home directory." - ) - subparsers = parser.add_subparsers(dest="bisync_command", required=True) - plan_parser = subparsers.add_parser("plan", help="Detect drift without writing") - plan_parser.add_argument("--source-root", default=".", help="Source repository root.") - plan_parser.add_argument( - "--home-root", - default=str(Path.home()), - help="Home directory root.", - ) - plan_parser.add_argument( - "--format", - choices=["text", "json", "compact", "report"], - default="compact", - help="Output format.", - ) - plan_parser.add_argument( - "--compact", - action="store_true", - help="Alias for --format compact; optimized for AI/tool iteration.", - ) - apply_parser = subparsers.add_parser("apply", help="Apply bisync resolution") - apply_parser.add_argument("--source-root", default=".", help="Source repository root.") - apply_parser.add_argument( - "--home-root", - default=str(Path.home()), - help="Home directory root.", - ) - apply_parser.add_argument( - "--format", - choices=["text", "json", "compact", "report"], - default="compact", - help="Output format.", - ) - apply_parser.add_argument( - "--compact", - action="store_true", - help="Alias for --format compact; optimized for AI/tool iteration.", - ) - return parser - - -def main(argv: list[str] | None = None) -> None: - parser = build_bisync_parser() - args = parser.parse_args(argv) - if getattr(args, "compact", False): - args.format = "compact" - if args.bisync_command == "plan": - raise SystemExit(run_bisync_plan(args)) - elif args.bisync_command == "apply": - raise SystemExit(run_bisync_apply(args)) - - -if __name__ == "__main__": - main() diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py index 3be09d77..cef091be 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py @@ -52,10 +52,12 @@ class ManagedResource: source_path: str target_path: str source_hash: str - content_hash: str - last_action: str + materialization: str = "copy" + link_target: str | None = None + content_hash: str | None = None + last_action: str = "copy" - def to_dict(self) -> dict[str, str]: + def to_dict(self) -> dict[str, str | None]: return { "target": self.target, "resource_family": self.resource_family, @@ -63,6 +65,8 @@ def to_dict(self) -> dict[str, str]: "source_path": self.source_path, "target_path": self.target_path, "source_hash": self.source_hash, + "materialization": self.materialization, + "link_target": self.link_target, "content_hash": self.content_hash, "last_action": self.last_action, } @@ -127,6 +131,8 @@ def to_dict(self) -> dict[str, object]: "state_root": self.state_root.as_posix(), "source_revision": self.source_revision, "source_resources_considered": self.source_resources_considered, + "linked": operation_paths(self.operations, "link"), + "unlinked": operation_paths(self.operations, "unlink"), "copied": operation_paths(self.operations, "copy"), "skipped": operation_paths(self.operations, "skip"), "blocked": operation_paths(self.operations, "blocked"), @@ -269,8 +275,10 @@ def build_home_sync_plan( source_path=resource.source_path, target_path=target_path.as_posix(), source_hash=source_hash, - content_hash=content_hash, - last_action="copy", + materialization="symlink" if resource.source_family == "skills" else "copy", + link_target=(source_path.resolve().as_posix() if resource.source_family == "skills" else None), + content_hash=(None if resource.source_family == "skills" else content_hash), + last_action="link" if resource.source_family == "skills" else "copy", ) if managed_resource.target_path in seen_target_paths: continue @@ -340,6 +348,102 @@ def _verify_apply_confinement(target_path: Path, home_root: Path) -> str | None: return None +def canonical_skill_link_target(source_root: Path, source_path: str) -> Path: + return (source_root.resolve() / source_path).resolve() + + +def assess_skill_link(target_path: Path, expected_target: Path) -> tuple[str, str | None]: + if not os.path.lexists(target_path): + return "missing", None + if not target_path.is_symlink(): + return "replace-directory", None + + actual_target = target_path.resolve(strict=False) + if actual_target == expected_target: + if expected_target.exists(): + return "matching", None + return "blocked", "link-target-missing" + if not target_path.exists(): + return "blocked", "link-target-missing" + return "blocked", "link-target-mismatch" + + +def _verify_skill_target_path( + *, + home_root: Path, + target: str, + target_path: Path, +) -> str | None: + target_root = runtime_skill_root(home_root, target) + resolved_home = home_root.resolve() + if target_path.parent != target_root: + return "unsafe-home-path" + if not target_root.is_dir(): + return "unsafe-home-path" + if target_root.is_symlink(): + return "symlink-not-allowed" + try: + target_root.resolve().relative_to(resolved_home) + except ValueError: + return "unsafe-home-path" + + current_path = target_root + while current_path != resolved_home: + if current_path.is_symlink(): + return "symlink-not-allowed" + if current_path.parent == current_path: + return "unsafe-home-path" + current_path = current_path.parent + return None + + +def create_skill_link(source_path: Path, target_path: Path) -> None: + source_path = source_path.resolve() + if not is_valid_skill_bundle(source_path): + raise RuntimeError(f"source-invalid-skill: {source_path}") + if os.path.lexists(target_path): + if target_path.is_symlink(): + target_path.unlink() + elif target_path.is_dir(): + shutil.rmtree(target_path) + else: + target_path.unlink() + os.symlink(source_path, target_path, target_is_directory=True) + + +def unlink_managed_skill(target_path: Path) -> None: + if target_path.is_symlink(): + target_path.unlink() + + +def verify_skill_link(source_path: Path, target_path: Path) -> str | None: + expected_target = source_path.resolve() + if not is_valid_skill_bundle(expected_target) or not target_path.is_symlink(): + return "link-target-missing" + if not target_path.exists(): + return "link-target-missing" + if target_path.resolve() != expected_target: + return "link-target-mismatch" + return None + + +def probe_symlink_support(state_root: Path) -> str | None: + state_root.mkdir(parents=True, exist_ok=True) + probe_path = state_root / ".symlink-probe" + try: + if os.path.lexists(probe_path): + if probe_path.is_dir() and not probe_path.is_symlink(): + return "symlink-unsupported" + probe_path.unlink() + os.symlink(state_root.resolve(), probe_path, target_is_directory=True) + except OSError: + return "symlink-unsupported" + finally: + if probe_path.is_symlink(): + probe_path.unlink() + return None + + def apply_home_sync_plan( plan: HomeSyncPlan, *, @@ -360,12 +464,43 @@ def apply_home_sync_plan( Path(operation.path).mkdir(parents=True, exist_ok=True) desired_by_path = {resource.target_path: resource for resource in plan.desired_resources} + if any(operation.action == "link" for operation in plan.operations): + symlink_code = probe_symlink_support(plan.state_root) + if symlink_code is not None: + raise RuntimeError(symlink_code) + copied_paths: set[str] = set() actual_content_hashes: dict[str, str] = {} for operation in plan.operations: - if operation.action not in {"copy", "delete"}: + if operation.action not in {"link", "unlink", "copy", "delete"}: continue target_path = Path(operation.path) + if operation.action in {"link", "unlink"}: + skill_path_code = _verify_skill_target_path( + home_root=plan.home_root, + target=operation.target, + target_path=target_path, + ) + if skill_path_code is not None: + raise RuntimeError(f"{skill_path_code}: {target_path}") + if operation.action == "unlink": + unlink_managed_skill(target_path) + continue + + managed_resource = desired_by_path.get(operation.path) + if managed_resource is None or managed_resource.resource_family != "skills": + raise RuntimeError(f"source-invalid-skill: {target_path}") + source_path = canonical_skill_link_target(plan.source_root, managed_resource.source_path) + link_state, link_code = assess_skill_link(target_path, source_path) + if link_state == "blocked": + raise RuntimeError(f"{link_code}: {target_path}") + if link_state != "matching": + create_skill_link(source_path, target_path) + verification_code = verify_skill_link(source_path, target_path) + if verification_code is not None: + raise RuntimeError(f"{verification_code}: {target_path}") + continue + confinement_code = _verify_apply_confinement(target_path, plan.home_root) if confinement_code: raise RuntimeError(f"{confinement_code}: {target_path}") @@ -378,6 +513,8 @@ def apply_home_sync_plan( continue managed_resource = desired_by_path[operation.path] + if managed_resource.resource_family == "skills": + raise RuntimeError(f"source-invalid-skill: copied skill fallback is not supported: {target_path}") source_path = plan.source_root / managed_resource.source_path if managed_resource.resource_family == "agents" and managed_resource.target != "copilot": _apply_translated_agent(source_path, target_path, managed_resource.target) @@ -388,6 +525,13 @@ def apply_home_sync_plan( for resource in plan.desired_resources: target_path = Path(resource.target_path) + if resource.resource_family == "skills": + verification_code = verify_skill_link( + canonical_skill_link_target(plan.source_root, resource.source_path), target_path + ) + if verification_code is not None: + raise RuntimeError(f"{verification_code}: {target_path}") + continue if not target_path.exists(): raise RuntimeError(f"post-apply-verify-failed: {target_path} missing after copy") actual_hash = actual_content_hashes.get(target_path.as_posix()) @@ -616,7 +760,7 @@ def add_resource_blockers( ) -> None: reason = { "source-missing": "Catalog entry points to a source path that does not exist. Blocked to avoid materializing a stale or incomplete resource and to surface catalog drift.", - "source-invalid-skill": "Source skill bundle is missing SKILL.md. Blocked because a valid direct-copy skill bundle must contain SKILL.md.", + "source-invalid-skill": "Source skill bundle is missing SKILL.md. Blocked because a valid repository skill bundle must contain SKILL.md.", "source-invalid-agent": "Source agent file is missing or not a .md file. Blocked because only allowlisted .agent.md files are eligible for translation.", }[code] for target in intersection_targets(resource, targets): @@ -719,6 +863,47 @@ def add_materialization_operation( policy: HomeSyncPolicy, ) -> None: manifest_entry = manifest_index.get(target_path.as_posix()) + if resource.source_family == "skills": + expected_target = source_path.resolve() + link_state, link_code = assess_skill_link(target_path, expected_target) + if link_state == "blocked": + add_blocked_operation( + operations, + target=target, + target_path=target_path, + code=link_code or "link-target-mismatch", + reason="Existing home link does not identify the canonical repository skill bundle.", + resource=resource, + ) + return + if link_state == "matching": + operations.append( + HomeSyncOperation( + target=target, + action="skip", + path=target_path.as_posix(), + reason="Home skill already links to the canonical repository bundle.", + source_path=resource.source_path, + resource_id=resource.resource_id, + ) + ) + return + operations.append( + HomeSyncOperation( + target=target, + action="link", + path=target_path.as_posix(), + reason=( + "Create the canonical repository skill link." + if link_state == "missing" + else "Replace the colliding home directory with the canonical repository skill link." + ), + source_path=resource.source_path, + resource_id=resource.resource_id, + ) + ) + return + if target_path.exists(): current_hash = hash_resource(target_path) if manifest_entry is None: @@ -753,7 +938,7 @@ def add_materialization_operation( target=target, action="warning", path=target_path.as_posix(), - reason="Managed target diverged from the last recorded manifest hash and the home copy is newer than the repo source. Install skips this resource so an explicit home-to-repo review can decide whether to keep or copy back the newer home state.", + reason="Managed target diverged from the last recorded manifest hash and is newer than the repository source. Install skips it to prevent losing local copied-agent edits.", code="target-modified-managed", source_path=resource.source_path, resource_id=resource.resource_id, @@ -844,6 +1029,40 @@ def add_stale_managed_operations( if not isinstance(target_path, str) or target_path in desired_paths: continue + if ( + item.get("resource_family") == "skills" + and item.get("materialization") == "symlink" + ): + stale_path = Path(target_path) + skill_path_code = _verify_skill_target_path( + home_root=home_root, + target=str(item.get("target", "")), + target_path=stale_path, + ) + if skill_path_code is not None: + operations.append( + HomeSyncOperation( + target=str(item.get("target", "")), + action="blocked", + path=target_path, + reason="Stale managed skill link fails the direct runtime-root confinement check.", + code=skill_path_code, + resource_id=str(item.get("resource_id", "")), + ) + ) + continue + if stale_path.is_symlink(): + operations.append( + HomeSyncOperation( + target=str(item.get("target", "")), + action="unlink", + path=target_path, + reason="Stale manifest-managed skill link is removed automatically without following its target.", + resource_id=str(item.get("resource_id", "")), + ) + ) + continue + confinement_code = _stale_confinement_check( item=item, target_path=target_path, @@ -1019,7 +1238,7 @@ def add_doctor_support_check( def load_manifest(path: Path) -> tuple[dict[str, object], str | None]: if not path.exists(): - return {"managed_resources": []}, None + return {"schema_version": 2, "managed_resources": []}, None try: payload = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: @@ -1028,58 +1247,49 @@ def load_manifest(path: Path) -> tuple[dict[str, object], str | None]: return {"managed_resources": []}, "manifest-corrupt" if not isinstance(payload.get("managed_resources"), list): return {"managed_resources": []}, "manifest-corrupt" - for i, item in enumerate(payload.get("managed_resources", [])): + schema_version = payload.get("schema_version", 1) + if schema_version not in {1, 2}: + return {"managed_resources": []}, "manifest-corrupt" + if schema_version == 1: + payload["schema_version"] = 1 + for item in payload.get("managed_resources", []): if not isinstance(item, dict): return {"managed_resources": []}, "manifest-corrupt" if not isinstance(item.get("target"), str): return {"managed_resources": []}, "manifest-corrupt" if not isinstance(item.get("target_path"), str): return {"managed_resources": []}, "manifest-corrupt" + if schema_version == 1: + item["materialization"] = "copy" + item["link_target"] = None + item.setdefault("content_hash", None) + continue + if not _is_valid_v2_manifest_row(item): + return {"managed_resources": []}, "manifest-corrupt" return payload, None -def reconcile_manifest_entry_after_bisync_copy( - *, - source_root: Path, - home_root: Path, - source_path: Path, - target_path: Path, -) -> str | None: - manifest_path = state_root_for_home(home_root) / MANIFEST_PATH - manifest_payload, manifest_error = load_manifest(manifest_path) - if manifest_error is not None: - return "bisync-manifest-reconcile-failed" - - managed_resources = manifest_payload.get("managed_resources") - if not isinstance(managed_resources, list): - return "bisync-manifest-reconcile-failed" - - manifest_index = index_manifest(manifest_payload) - manifest_entry = manifest_index.get(target_path.as_posix()) - if manifest_entry is None: - # The copied bundle is not install-managed, so there is nothing to reconcile. - return None - try: - source_path_rel = source_path.relative_to(source_root).as_posix() - except ValueError: - return "bisync-manifest-reconcile-failed" - if manifest_entry.get("source_path") != source_path_rel: - return "bisync-manifest-reconcile-failed" - - try: - source_hash = hash_resource(source_path) - target_hash = hash_resource(target_path) - except (OSError, PermissionError, ValueError): - return "bisync-manifest-reconcile-failed" - - if source_hash != target_hash: - return "bisync-manifest-reconcile-failed" - - manifest_entry["source_hash"] = source_hash - manifest_entry["content_hash"] = target_hash - manifest_entry["last_action"] = "bisync-copy" - manifest_path.write_text(render_json(manifest_payload), encoding="utf-8") - return None +def _is_valid_v2_manifest_row(item: dict[str, object]) -> bool: + materialization = item.get("materialization") + family = item.get("resource_family") + link_target = item.get("link_target") + content_hash = item.get("content_hash") + target_path = item.get("target_path") + if materialization not in {"symlink", "copy"} or not isinstance(family, str): + return False + if not isinstance(target_path, str) or not Path(target_path).is_absolute(): + return False + if family == "skills": + return ( + materialization == "symlink" + and isinstance(link_target, str) + and Path(link_target).is_absolute() + and Path(link_target).resolve().as_posix() == link_target + and content_hash is None + ) + if family == "agents": + return materialization == "copy" and link_target is None and isinstance(content_hash, str) + return False def index_manifest(payload: dict[str, object]) -> dict[str, dict[str, object]]: @@ -1244,11 +1454,15 @@ def build_manifest_payload( continue seen_target_paths.add(resource.target_path) entry = resource.to_dict() - if actual_content_hashes and resource.target_path in actual_content_hashes: + if ( + resource.materialization == "copy" + and actual_content_hashes + and resource.target_path in actual_content_hashes + ): entry["content_hash"] = actual_content_hashes[resource.target_path] managed_resources.append(entry) return { - "schema_version": 1, + "schema_version": 2, "generated_at": now_isoformat(), "source_root": plan.source_root.as_posix(), "source_revision": plan.source_revision, @@ -1311,6 +1525,9 @@ def copy_resource(source_path: Path, target_path: Path) -> None: def remove_resource(target_path: Path) -> None: + if target_path.is_symlink(): + target_path.unlink() + return if target_path.is_dir(): shutil.rmtree(target_path) return diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py index 2c349c31..9f8370fd 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py @@ -13,11 +13,6 @@ import json from pathlib import Path -from bisync_skills import ( - build_bisync_plan, - run_bisync_apply, - run_bisync_plan, -) from home_syncing import ( apply_home_sync_plan, build_home_sync_plan, @@ -28,7 +23,6 @@ write_plan_snapshot, ) from sync_output import ( - build_compact_bisync_output, build_compact_install_output, dump_compact_json, render_doctor_report, @@ -99,45 +93,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="Skip unchanged manifest-managed resources when possible.", ) - bisync_parser = subparsers.add_parser("bisync", help="Bidirectional sync between repo skills and home skills.") - bisync_sub = bisync_parser.add_subparsers(dest="bisync_command", required=True) - bisync_plan = bisync_sub.add_parser("plan", help="Detect drift without writing.") - bisync_plan.add_argument("--source-root", default=".", help="Source repository root.") - bisync_plan.add_argument( - "--home-root", - default=str(Path.home()), - help="Home directory root.", - ) - bisync_plan.add_argument( - "--format", - choices=["text", "json", "compact", "report"], - default="compact", - help="Output format.", - ) - bisync_plan.add_argument( - "--compact", - action="store_true", - help="Alias for --format compact; optimized for AI/tool iteration.", - ) - bisync_apply = bisync_sub.add_parser("apply", help="Apply bisync resolution.") - bisync_apply.add_argument("--source-root", default=".", help="Source repository root.") - bisync_apply.add_argument( - "--home-root", - default=str(Path.home()), - help="Home directory root.", - ) - bisync_apply.add_argument( - "--format", - choices=["text", "json", "compact", "report"], - default="compact", - help="Output format.", - ) - bisync_apply.add_argument( - "--compact", - action="store_true", - help="Alias for --format compact; optimized for AI/tool iteration.", - ) - args = parser.parse_args(argv) if getattr(args, "compact", False): args.format = "compact" @@ -149,11 +104,6 @@ def main(argv: list[str] | None = None) -> int: def run(args: argparse.Namespace) -> int: - if args.command == "bisync": - if args.bisync_command == "plan": - return run_bisync_plan(args) - return run_bisync_apply(args) - source_root = find_repo_root(Path(args.source_root)) home_root = Path(args.home_root).expanduser().resolve() try: @@ -283,7 +233,6 @@ def run_sync( "blocked_codes": [code], "reason": str(exc), "install": {"error": str(exc)}, - "bisync": None, } emit_sync_output(payload, format_name=args.format) return 1 @@ -298,14 +247,13 @@ def run_sync( "status": "needs_review", "reason": "Install lane needs review before writing home resources.", "install": install_payload, - "bisync": None, }, format_name=args.format, ) return 1 install_changed = any( - operation.action in {"copy", "delete", "mkdir"} + operation.action in {"link", "unlink", "copy", "delete", "mkdir"} for operation in install_plan.operations ) if install_changed: @@ -324,7 +272,6 @@ def run_sync( "status": "blocked", "reason": str(error), "install": install_payload, - "bisync": None, }, format_name=args.format, ) @@ -332,51 +279,18 @@ def run_sync( install_payload["manifest_path"] = manifest_path.as_posix() install_payload["state_path"] = write_plan_snapshot(install_plan).as_posix() - bisync_payload: dict[str, object] | None = None - if targets == ("skills",) and not retire_targets: - bisync_plan = build_bisync_plan(source_root, home_root, mode="plan") - bisync_payload = bisync_plan.to_dict() - if bisync_requires_review(bisync_plan): - emit_sync_output( - { - "mode": "sync", - "status": "needs_review", - "reason": "Bisync detected home-owned or ambiguous drift that needs human review before sync can finish.", - "install": install_payload, - "bisync": bisync_payload, - }, - format_name=args.format, - ) - return 1 - emit_sync_output( { "mode": "sync", "status": "done", - "reason": "Repo-to-home install completed and no home-owned bisync drift needs review.", + "reason": "Home resource install completed.", "install": install_payload, - "bisync": bisync_payload, }, format_name=args.format, ) return 0 -def bisync_requires_review(plan) -> bool: - safe_blocked_codes = {"bisync-only-repo"} - if any(code not in safe_blocked_codes for code in plan.blocked_codes): - return True - - for drift in plan.drifts: - if drift.drift_type == "only-repo": - continue - if drift.drift_type == "drift" and drift.direction == "repo-to-home": - continue - return True - - return False - - def install_auto_apply_blockers( plan, args: argparse.Namespace, @@ -400,7 +314,6 @@ def install_auto_apply_blockers( def emit_sync_output(payload: dict[str, object], *, format_name: str) -> None: install_payload = payload.get("install") - bisync_payload = payload.get("bisync") if format_name == "json": print(json.dumps(payload, indent=2, sort_keys=True)) return @@ -412,8 +325,6 @@ def emit_sync_output(payload: dict[str, object], *, format_name: str) -> None: } if isinstance(install_payload, dict): compact["install"] = build_compact_install_output(install_payload) - if isinstance(bisync_payload, dict): - compact["bisync"] = build_compact_bisync_output(bisync_payload) print(dump_compact_json(compact)) return diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py index f3b83254..f1f87376 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py @@ -22,32 +22,23 @@ "prune-not-approved": "Prune was not approved for stale managed resources.", "stale-content-drifted": "A stale managed resource drifted and cannot be removed safely.", "stale-path-unresolvable": "A stale managed path cannot be resolved safely.", - "reverse-sync-blocked": "Reverse sync is blocked by policy.", + "symlink-unsupported": "The runtime cannot create symbolic links.", + "link-target-missing": "A managed link target is missing or broken.", + "link-target-mismatch": "A managed link points to a different checkout.", "retire-target-overlap": "A target was selected as both active and retired.", - "bisync-source-missing": "The source skills root is missing or unreadable.", - "bisync-home-missing": "The home skills root is missing or unreadable.", - "bisync-repo-dirty": "Repository has uncommitted or untracked changes.", - "bisync-repo-git-failed": "The dirty repository preflight check failed.", - "bisync-only-home": "A skill exists only in home and needs manual resolution.", - "bisync-equal-mtime": "Hashes differ but timestamps are equal, so winner is ambiguous.", - "bisync-verify-failed": "Post-copy hash verification failed.", - "bisync-manifest-reconcile-failed": "Manifest reconciliation failed after a verified bisync copy.", - "bisync-residual-drift": "Post-apply bisync still detected residual drift.", - "bisync-plan-required": "Bisync apply requires a reviewed plan snapshot that matches the current drift set.", } REPORT_TABLE_ROW_LIMIT = 8 REPORT_SECTION_EMOJIS: dict[str, str] = { "Summary": "🧭", "Auto-applied": "πŸš€", - "Planned repo-to-home copies": "πŸ“‹", + "Planned changes": "πŸ“‹", "Stopped on": "β›”", "Changes": "πŸ› οΈ", "Completed": "βœ…", "Attention": "⚠️", "Validation": "πŸ”Ž", "Readiness": "🩺", - "Remaining Work": "🧾", "Next": "➑️", } @@ -56,14 +47,6 @@ def dump_compact_json(payload: dict[str, object]) -> str: return json.dumps(payload, sort_keys=True, separators=(",", ":")) -def _install_lane_label() -> str: - return "repo-to-home install" - - -def _bisync_lane_label() -> str: - return "repo-home drift" - - def build_compact_install_output(payload: dict[str, object]) -> dict[str, object]: next_action = payload.get("next_action") compact: dict[str, object] = { @@ -73,14 +56,18 @@ def build_compact_install_output(payload: dict[str, object]) -> dict[str, object "next": _next_action_name(next_action), "approval": _next_action_requires_approval(next_action), "counts": { - "source": payload.get("source_resources_considered") or 0, + "source": _count_value(payload.get("source_resources_considered")), + "linked": _count_value(payload.get("linked")), + "unlinked": _count_value(payload.get("unlinked")), "copy": _count_value(payload.get("copied")), "skip": _count_value(payload.get("skipped")), "blocked": _count_value(payload.get("blocked")), "conflict": _count_value(payload.get("conflicts")), "missing_dir": _count_value(payload.get("missing_dirs")), "residual": _count_value(payload.get("residual_drift")), - "unsupported": _count_unsupported_families(payload.get("unsupported_families_by_target")), + "unsupported": _count_unsupported_families( + payload.get("unsupported_families_by_target") + ), }, } blockers = _as_string_list(payload.get("blocked_codes")) @@ -89,953 +76,217 @@ def build_compact_install_output(payload: dict[str, object]) -> dict[str, object changes = _install_change_evidence(payload, limit=4) if changes: compact["changes"] = changes - _copy_if_present(compact, payload, "error") - return compact - - -def build_compact_bisync_output(payload: dict[str, object]) -> dict[str, object]: - verification = payload.get("verification") - status = None - if isinstance(verification, dict): - status = verification.get("status") - next_action = payload.get("next_action") - compact: dict[str, object] = { - "mode": payload.get("mode"), - "status": status if status is not None else payload.get("status"), - "next": _next_action_name(next_action), - "approval": _next_action_requires_approval(next_action), - "counts": { - "drift": _count_value(payload.get("drifts")), - **_direction_counts(payload.get("drifts")), - **_bucket_counts(payload.get("drifts")), - }, - } - blockers = _as_string_list(payload.get("blocked_codes")) - if blockers: - compact["blockers"] = blockers - changes = _bisync_change_evidence(payload.get("drifts"), limit=4) - if changes: - compact["changes"] = changes - _copy_if_present(compact, payload, "error") - if isinstance(verification, dict): - _copy_if_present(compact, verification, "code") + if isinstance(payload.get("error"), str): + compact["error"] = payload["error"] return compact def render_sync_report(payload: dict[str, object]) -> str: - status = str(payload.get("status") or "unknown") + install_payload = payload.get("install") + install = install_payload if isinstance(install_payload, dict) else payload + status = str(payload.get("status") or install.get("validation") or "unknown") reason = str(payload.get("reason") or "") - install_payload = payload.get("install") if isinstance(payload.get("install"), dict) else {} - bisync_payload = payload.get("bisync") if isinstance(payload.get("bisync"), dict) else {} - targets = _join_or_none(_as_string_list(install_payload.get("selected_targets"))) - next_action = _sync_next_action(payload) - - install_operations = install_payload.get("operations") if isinstance(install_payload, dict) else None - direction_counts = _direction_counts(bisync_payload.get("drifts")) if isinstance(bisync_payload, dict) else _direction_counts(()) - bucket_counts = _bucket_counts(bisync_payload.get("drifts")) if isinstance(bisync_payload, dict) else _bucket_counts(()) + lines = [ + f"🚦 Status: sync | status={status} | targets={_join_or_none(_as_string_list(install.get('selected_targets')))} | reason={reason}", + "", + *_report_section("Summary"), + *_bullet_lines( + [ + f"Linked skills: {_count_value(install.get('linked'))}", + f"Unlinked skills: {_count_value(install.get('unlinked'))}", + f"Copied agents: {_count_value(install.get('copied'))}", + f"Unchanged resources: {_count_value(install.get('skipped'))}", + f"Blockers: {_count_value(install.get('blocked'))}", + ] + ), + "", + *_report_section("Validation"), + *_table_lines( + ["Check", "Result"], + [["Install", status], ["Blockers", _join_or_none(_as_string_list(install.get("blocked_codes"))) or "none"]], + ), + "", + *_report_section("Next"), + *_table_lines([["Field", "Value"]][0], [["Action", _next_action_name(payload.get("next_action"))]]), + ] + return "\n".join(lines).strip() + "\n" - lines: list[str] = [] - lines.append( - f"🚦 Status: sync | status={status} | targets={targets} | next_action={next_action} | reason={reason}" - ) - lines.append("") - lines.extend(_report_section("Summary")) - lines.extend( - _bullet_lines( +def render_install_report(payload: dict[str, object]) -> str: + status = str(payload.get("validation") or payload.get("status") or "unknown") + lines = [ + f"🚦 Status: install | status={status} | targets={_join_or_none(_as_string_list(payload.get('selected_targets')))}", + "", + *_report_section("Summary"), + *_bullet_lines( [ - f"Install copied: {_count_operations(install_operations, {'copy'})}", - f"Install skipped: {_count_operations(install_operations, {'skip'})}", - f"Install warnings: {_count_operations(install_operations, {'warning'})}", - f"Install blockers: {_count_operations(install_operations, {'blocked', 'stale-managed'})}", - f"Bisync repo-to-home: {direction_counts['repo_to_home']}", - f"Bisync home-to-repo: {direction_counts['home_to_repo']}", - f"Bisync only-repo: {bucket_counts['only_repo']}", - f"Bisync only-home: {bucket_counts['only_home']}", - f"Bisync equal-mtime: {bucket_counts['equal_mtime']}", + f"Linked skills: {_count_value(payload.get('linked'))}", + f"Unlinked skills: {_count_value(payload.get('unlinked'))}", + f"Copied agents: {_count_value(payload.get('copied'))}", + f"Unchanged resources: {_count_value(payload.get('skipped'))}", + f"Blockers: {_count_value(payload.get('blocked'))}", ] - ) - ) - lines.append("") - - ( - auto_applied_section, - auto_applied_reason_column, - auto_applied_omitted_label, - _auto_applied_none_reason, - ) = _sync_apply_section_labels( - install_payload if isinstance(install_payload, dict) else {} - ) - auto_applied_rows = _bounded_rows( - _sync_auto_applied_rows(install_payload if isinstance(install_payload, dict) else {}), - auto_applied_omitted_label, - ) - if auto_applied_rows: - lines.extend(_report_section(auto_applied_section)) - lines.extend( - _table_lines( - ["Skill or path", auto_applied_reason_column, "Verification"], - auto_applied_rows, - ) - ) + ), + "", + ] + operations = payload.get("operations") + rows = _operation_rows(operations) + if rows: + lines.extend(_report_section("Changes")) + lines.extend(_table_lines(["Action", "Resource", "Reason"], _bounded_rows(rows))) lines.append("") - - stopped_rows = _sync_stopped_rows( - install_payload if isinstance(install_payload, dict) else {}, - bisync_payload if isinstance(bisync_payload, dict) else {}, - ) - if stopped_rows: - lines.extend(_report_section("Stopped on")) - lines.extend( - _table_lines( - ["Skill or path", "Direction", "Differences or reason"], - stopped_rows, - ) - ) + blockers = _blocker_rows(payload) + if blockers: + lines.extend(_report_section("Attention")) + lines.extend(_table_lines(["Code", "Meaning", "Next action"], blockers)) lines.append("") - lines.extend(_report_section("Validation")) - lines.extend( - _table_lines( - ["Check", "Result"], - _sync_validation_rows( - status, - install_payload if isinstance(install_payload, dict) else {}, - bisync_payload if isinstance(bisync_payload, dict) else {}, - ), - ) - ) + lines.extend(_table_lines(["Check", "Result"], [["Install", status]])) lines.append("") - lines.extend(_report_section("Next")) - lines.extend(_table_lines(["Field", "Value"], _sync_next_rows(payload))) + lines.extend(_table_lines(["Field", "Value"], [["Action", _next_action_name(payload.get("next_action"))]])) return "\n".join(lines).strip() + "\n" -def _copy_if_present( - target: dict[str, object], - source: dict[str, object], - key: str, -) -> None: - if key in source: - target[key] = source[key] - - -def _count_value(value: object) -> int: - if isinstance(value, dict): - return len(value) - if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): - return len(list(value)) - return 0 - - -def _as_iterable(value: object) -> Iterable[object]: - if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): - return value - return () - - -def _count_unsupported_families(value: object) -> int: - if not isinstance(value, dict): - return 0 - count = 0 - for families in value.values(): - if isinstance(families, Iterable) and not isinstance(families, (str, bytes)): - count += len(list(families)) - return count - - -def _direction_counts(drifts: object) -> dict[str, int]: - counts = {"repo_to_home": 0, "home_to_repo": 0} - if not isinstance(drifts, Iterable) or isinstance(drifts, (str, bytes, dict)): - return counts - for drift in drifts: - if not isinstance(drift, dict): - continue - direction = drift.get("direction") - if direction == "repo-to-home": - counts["repo_to_home"] += 1 - elif direction == "home-to-repo": - counts["home_to_repo"] += 1 - elif direction in counts: - counts[str(direction)] += 1 - return counts - - -def _bucket_counts(drifts: object) -> dict[str, int]: - counts = {"only_repo": 0, "only_home": 0, "equal_mtime": 0} - if not isinstance(drifts, Iterable) or isinstance(drifts, (str, bytes, dict)): - return counts - for drift in drifts: - if not isinstance(drift, dict): - continue - drift_type = drift.get("type") - if drift_type == "only-repo": - counts["only_repo"] += 1 - elif drift_type == "only-home": - counts["only_home"] += 1 - elif drift_type == "equal-mtime": - counts["equal_mtime"] += 1 - return counts +def render_doctor_report(payload: dict[str, object]) -> str: + status = str(payload.get("validation") or payload.get("status") or "unknown") + lines = [ + f"🚦 Status: doctor | status={status} | targets={_join_or_none(_as_string_list(payload.get('selected_targets')))}", + "", + *_report_section("Summary"), + ] + checks = payload.get("checks") + rows = [] + if isinstance(checks, Iterable) and not isinstance(checks, (str, bytes, dict)): + for check in checks: + if isinstance(check, dict): + rows.append([str(check.get("name", "check")), str(check.get("status", "unknown"))]) + if rows: + lines.extend(_table_lines(["Check", "Result"], rows)) + blocked = _as_string_list(payload.get("blocked_codes")) + if blocked: + lines.extend(("", *_report_section("Readiness"))) + lines.extend(_table_lines(["Code", "Meaning", "Next action"], [[code, _reason(code), "Resolve it, then rerun doctor."] for code in blocked])) + lines.extend(("", *_report_section("Validation"))) + lines.extend(_table_lines(["Check", "Result"], [["Doctor", status]])) + lines.extend(("", *_report_section("Next"))) + lines.extend(_table_lines(["Field", "Value"], [["Action", _next_action_name(payload.get("next_action"))]])) + return "\n".join(lines).strip() + "\n" def _install_change_evidence(payload: dict[str, object], limit: int = 8) -> list[dict[str, object]]: operations = payload.get("operations") if not isinstance(operations, list): return [] - evidence: list[dict[str, object]] = [] - seen: set[tuple[str, str]] = set() - interesting_actions = {"copy", "blocked", "stale-managed", "mkdir", "delete"} for operation in operations: - if not isinstance(operation, dict): - continue - action = operation.get("action") - if action not in interesting_actions: + if not isinstance(operation, dict) or operation.get("action") not in { + "link", "unlink", "copy", "delete", "blocked", "stale-managed", "mkdir" + }: continue path = operation.get("path") - resource_id = operation.get("resource_id") if not isinstance(path, str) or not path: continue - resource = resource_id if isinstance(resource_id, str) and resource_id else _compact_path(path) - key = (str(action), str(resource)) - if key in seen: - continue - seen.add(key) - item: dict[str, object] = {"action": action, "resource": resource} - for field in ("code",): - value = operation.get(field) - if isinstance(value, str) and value: - item[field] = value + item: dict[str, object] = { + "action": operation["action"], + "resource": operation.get("resource_id") or _compact_path(path), + } + if isinstance(operation.get("code"), str) and operation["code"]: + item["code"] = operation["code"] evidence.append(item) if len(evidence) >= limit: break return evidence -def _bisync_change_evidence(drifts: object, limit: int = 8) -> list[dict[str, object]]: - if not isinstance(drifts, Iterable) or isinstance(drifts, (str, bytes, dict)): +def _operation_rows(value: object) -> list[list[str]]: + if not isinstance(value, list): return [] - - evidence: list[dict[str, object]] = [] - for drift in drifts: - if not isinstance(drift, dict): - continue - item: dict[str, object] = {} - for field in ("skill", "direction", "type"): - value = drift.get(field) - if isinstance(value, str) and value: - item[field] = value - blocked_codes = drift.get("blocked_codes") - if isinstance(blocked_codes, Iterable) and not isinstance(blocked_codes, (str, bytes, dict)): - item["blocked_codes"] = list(blocked_codes) - if item: - evidence.append(item) - if len(evidence) >= limit: - break - return evidence - - -def _sync_auto_applied_rows(install_payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - grouped: dict[tuple[str, str, str], int] = {} - operations = install_payload.get("operations") - if not isinstance(operations, list): - return rows - manifest_path = install_payload.get("manifest_path") - verification = "manifest hash recorded" if isinstance(manifest_path, str) and manifest_path else "planned only" - for operation in operations: - if not isinstance(operation, dict): - continue - if str(operation.get("action") or "") != "copy": + rows = [] + for operation in value: + if not isinstance(operation, dict) or operation.get("action") == "skip": continue - item = str(operation.get("resource_id") or operation.get("path") or "unknown") - reason = str(operation.get("reason") or "Repo-to-home copy applied.") - grouped[(item, reason, verification)] = grouped.get((item, reason, verification), 0) + 1 - for (item, reason, verification), count in grouped.items(): - label = f"{item} ({count} targets)" if count > 1 else item - rows.append([label, reason, verification]) - return rows - - -def _sync_apply_section_labels(install_payload: dict[str, object]) -> tuple[str, str, str, str]: - operations = install_payload.get("operations") - manifest_path = install_payload.get("manifest_path") - if isinstance(manifest_path, str) and manifest_path: - return ("Auto-applied", "Why copied", "auto-applied", "No safe repo-to-home copy was applied.") - if isinstance(operations, list) and any( - isinstance(operation, dict) and str(operation.get("action") or "") == "copy" - for operation in operations - ): - return ( - "Planned repo-to-home copies", - "Why this is planned", - "planned copy", - "No safe repo-to-home copy is planned.", - ) - return ("Auto-applied", "Why copied", "auto-applied", "No safe repo-to-home copy was applied.") - - -def _sync_stopped_rows( - install_payload: dict[str, object], - bisync_payload: dict[str, object], -) -> list[list[str]]: - rows: list[list[str]] = [] - - operations = install_payload.get("operations") - if isinstance(operations, list): - for operation in operations: - if not isinstance(operation, dict): - continue - action = str(operation.get("action") or "") - code = str(operation.get("code") or "") - if action not in {"blocked", "stale-managed"} and not ( - action == "warning" and code == "target-modified-managed" - ): - continue - path = str(operation.get("resource_id") or operation.get("path") or "unknown") - direction = "install review" - if code == "target-modified-managed": - direction = "home-to-repo review" - reason = str(operation.get("reason") or _reason_for_code(code or action)) - rows.append([path, direction, reason]) - - drifts = bisync_payload.get("drifts") - if isinstance(drifts, list): - for drift in drifts: - if not isinstance(drift, dict): - continue - drift_type = str(drift.get("type") or "drift") - direction = str(drift.get("direction") or drift_type) - if drift_type == "only-repo": - continue - if drift_type == "drift" and direction == "repo-to-home": - continue - skill = str(drift.get("skill") or "unknown") - rows.append([skill, direction, _sync_drift_summary(drift)]) - return rows - - -def _sync_validation_rows( - status: str, - install_payload: dict[str, object], - bisync_payload: dict[str, object], -) -> list[list[str]]: - rows = [["Overall sync", status]] - install_status = str(install_payload.get("validation") or install_payload.get("status") or "not-run") - rows.append(["Install lane", install_status]) - - bisync_status = "not-run" - verification = bisync_payload.get("verification") - if isinstance(verification, dict): - bisync_status = str(verification.get("status") or bisync_status) - elif bisync_payload: - bisync_status = "reviewed" - rows.append(["Bisync lane", bisync_status]) - - state_path = install_payload.get("state_path") - if isinstance(state_path, str) and state_path: - rows.append(["State path", state_path]) - manifest_path = install_payload.get("manifest_path") - if isinstance(manifest_path, str) and manifest_path: - rows.append(["Manifest path", manifest_path]) + rows.append([ + str(operation.get("action", "unknown")), + str(operation.get("resource_id") or _compact_path(str(operation.get("path", "")))), + str(operation.get("reason", "")), + ]) return rows -def _sync_next_rows(payload: dict[str, object]) -> list[list[str]]: - action = _sync_next_action(payload) - status = str(payload.get("status") or "unknown") - reason = str(payload.get("reason") or "Review the latest sync report.") - bisync_payload = payload.get("bisync") if isinstance(payload.get("bisync"), dict) else {} - - if status == "done": - return [["Action", action], ["Reason", reason]] - - if isinstance(bisync_payload, dict) and _sync_stopped_rows({}, bisync_payload): - return [ - ["Action", action], - ["Reason", "Review home-owned or ambiguous drift, then run explicit `bisync plan` or `bisync apply` if needed."], - ] - +def _blocker_rows(payload: dict[str, object]) -> list[list[str]]: return [ - ["Action", action], - ["Reason", "Resolve install blockers or warnings, then rerun `sync`."], + [code, _reason(code), "Resolve the blocker, then rerun the same command."] + for code in _as_string_list(payload.get("blocked_codes")) ] -def _sync_next_action(payload: dict[str, object]) -> str: - status = str(payload.get("status") or "unknown") - bisync_payload = payload.get("bisync") if isinstance(payload.get("bisync"), dict) else {} - if status == "done": - return "done" - if isinstance(bisync_payload, dict) and _sync_stopped_rows({}, bisync_payload): - return "review_bisync" - return "review_install" - - -def _sync_drift_summary(drift: dict[str, object]) -> str: - drift_type = str(drift.get("type") or "drift") - direction = str(drift.get("direction") or drift_type) - if drift_type == "only-home": - return "Only present in home. Manual review decides whether to keep, remove, or add it to the repo." - if drift_type == "equal-mtime": - return "Repo and home hashes differ, but timestamps are equal, so the winner is ambiguous." - if direction == "home-to-repo": - return "Home copy is newer than repo copy and needs an explicit home-to-repo decision." - return _bisync_reason(drift_type, direction) - - -def render_install_report(payload: dict[str, object]) -> str: - mode = str(payload.get("mode") or "plan") - targets = _as_string_list(payload.get("selected_targets")) - blocked_codes = _as_string_list(payload.get("blocked_codes")) - status = str(payload.get("validation") or payload.get("status") or "unknown") - next_action = _next_action_name(payload.get("next_action")) - lane_label = _install_lane_label() - operations = payload.get("operations") - skipped_count = _count_operations(operations, {"skip"}) - blocked_count = _count_operations(operations, {"blocked"}) - planned_count = _count_operations(operations, {"copy", "mkdir", "delete", "stale-managed"}) - - lines: list[str] = [] - lines.append( - f"🚦 Status: {lane_label} | mode={mode} | targets={_join_or_none(targets)} | status={status} | blockers={len(blocked_codes)} | next_action={next_action}" - ) - lines.append("") - - lines.extend(_report_section("Summary")) - lines.extend( - _bullet_lines( - [ - f"Targets: {_join_or_none(targets)}", - f"Retired targets: {_join_or_none(_as_string_list(payload.get('retired_targets')))}", - f"Source resources considered: {payload.get('source_resources_considered') or 0}", - f"Planned or applied changes: {planned_count}", - f"Already aligned resources: {skipped_count}", - f"Blocked resources: {blocked_count}", - ] - ) - ) - lines.append("") - - planned_rows = _bounded_rows(_install_planned_rows(payload), "change") - if planned_rows: - lines.extend(_report_section("Changes")) - lines.extend( - _table_lines( - ["Resource or path", "Lane", "Planned action", "Why this will change", "Evidence or winner"], - planned_rows, - ) - ) - lines.append("") - - completed_rows = _bounded_rows(_install_completed_rows(payload), "completed action") - if completed_rows: - lines.extend(_report_section("Completed")) - lines.extend( - _table_lines( - ["Resource or path", "Action performed", "Why it was done", "Result", "Verification"], - completed_rows, - ) - ) - lines.append("") - - blocker_rows = _install_blocker_rows(payload) - if blocker_rows: - lines.extend(_report_section("Attention")) - lines.extend( - _table_lines( - ["Code or status", "Resource or path", "Why it needs attention", "Required user action"], - blocker_rows, - ) - ) - lines.append("") - - lines.extend(_report_section("Validation")) - validation_rows = [["Validation status", status]] - state_path = payload.get("state_path") - if isinstance(state_path, str) and state_path: - validation_rows.append(["State path", state_path]) - manifest_path = payload.get("manifest_path") - if isinstance(manifest_path, str) and manifest_path: - validation_rows.append(["Manifest path", manifest_path]) - lines.extend(_table_lines(["Check", "Result"], validation_rows)) - lines.append("") - - remaining_rows = _remaining_work_rows(payload) - if remaining_rows: - lines.extend(_report_section("Remaining Work")) - lines.extend( - _table_lines( - ["Item", "Why it remains", "Required follow-up"], - remaining_rows, - ) - ) - lines.append("") - - lines.extend(_report_section("Next")) - lines.extend(_next_action_table(payload.get("next_action"), payload.get("next_step"))) - return "\n".join(lines).strip() + "\n" - - -def render_doctor_report(payload: dict[str, object]) -> str: - targets = _as_string_list(payload.get("selected_targets")) - blocked_codes = _as_string_list(payload.get("blocked_codes")) - status = str(payload.get("validation") or payload.get("status") or "unknown") - next_action = _next_action_name(payload.get("next_action")) - checks = payload.get("checks") - lane_label = _install_lane_label() - - lines: list[str] = [] - lines.append( - f"🚦 Status: {lane_label} | mode=doctor | targets={_join_or_none(targets)} | status={status} | blockers={len(blocked_codes)} | next_action={next_action}" - ) - lines.append("") - - lines.extend(_report_section("Summary")) - lines.extend( - _bullet_lines( - [ - f"Targets: {_join_or_none(targets)}", - f"Readiness checks: {_count_value(checks)}", - f"Ready checks: {_doctor_check_count(checks, 'ok')}", - f"Warnings: {_doctor_check_count(checks, 'warning')}", - f"Blocked checks: {_doctor_check_count(checks, 'blocked')}", - ] - ) - ) - lines.append("") - - readiness_rows = _doctor_readiness_rows(payload) - if readiness_rows: - lines.extend(_report_section("Readiness")) - lines.extend( - _table_lines( - ["Check or path", "Status", "Why it matters", "What blocks next", "Recommended action"], - readiness_rows, - ) - ) - lines.append("") - - lines.extend(_report_section("Validation")) - validation_rows = [["Validation status", status]] - state_path = payload.get("state_path") - if isinstance(state_path, str) and state_path: - validation_rows.append(["State path", state_path]) - lines.extend(_table_lines(["Check", "Result"], validation_rows)) - lines.append("") - - lines.extend(_report_section("Next")) - lines.extend(_next_action_table(payload.get("next_action"), payload.get("next_step"))) - return "\n".join(lines).strip() + "\n" - - -def render_bisync_report(payload: dict[str, object]) -> str: - mode = str(payload.get("mode") or "plan") - blocked_codes = _as_string_list(payload.get("blocked_codes")) - drifts = payload.get("drifts") - drift_total = _count_value(drifts) - verification = payload.get("verification") - status = "unknown" - if isinstance(verification, dict): - status = str(verification.get("status") or status) - next_action = _next_action_name(payload.get("next_action")) - lane_label = _bisync_lane_label() - - lines: list[str] = [] - lines.append( - f"🚦 Status: {lane_label} | mode={mode} | target=skills | status={status} | drift_total={drift_total} | blockers={len(blocked_codes)} | next_action={next_action}" - ) - lines.append("") - - lines.extend(_report_section("Summary")) - direction_counts = _direction_counts(drifts) - bucket_counts = _bucket_counts(drifts) - lines.extend( - _bullet_lines( - [ - f"Repo-to-home drift: {direction_counts['repo_to_home']}", - f"Home-to-repo drift: {direction_counts['home_to_repo']}", - f"Repo-only bundles: {bucket_counts['only_repo']}", - f"Home-only bundles: {bucket_counts['only_home']}", - f"Equal-mtime conflicts: {bucket_counts['equal_mtime']}", - ] - ) - ) - lines.append("") - - planned_rows = _bounded_rows(_bisync_planned_rows(payload), "change") - if planned_rows: - lines.extend(_report_section("Changes")) - lines.extend( - _table_lines( - ["Resource or path", "Lane", "Planned action", "Why this will change", "Evidence or winner"], - planned_rows, - ) - ) - - completed_rows = _bounded_rows(_bisync_completed_rows(payload), "completed action") - if completed_rows: - lines.extend(_report_section("Completed")) - lines.extend( - _table_lines( - ["Resource or path", "Action performed", "Why it was done", "Result", "Verification"], - completed_rows, - ) - ) - lines.append("") - - blocker_rows = _bisync_blocker_rows(payload) - if blocker_rows: - lines.extend(_report_section("Attention")) - lines.extend( - _table_lines( - ["Code or status", "Resource or path", "Why it needs attention", "Required user action"], - blocker_rows, - ) - ) - lines.append("") - - lines.extend(_report_section("Validation")) - verification_rows = [["Verification status", status]] - if isinstance(verification, dict): - reason = verification.get("reason") - if isinstance(reason, str) and reason: - verification_rows.append(["Reason", reason]) - code = verification.get("code") - if isinstance(code, str) and code: - verification_rows.append(["Code", code]) - lines.extend(_table_lines(["Check", "Result"], verification_rows)) - lines.append("") - - remaining_rows = _remaining_work_rows(payload) - if remaining_rows: - lines.extend(_report_section("Remaining Work")) - lines.extend( - _table_lines( - ["Item", "Why it remains", "Required follow-up"], - remaining_rows, - ) - ) - lines.append("") - - lines.extend(_report_section("Next")) - lines.extend(_next_action_table(payload.get("next_action"), payload.get("next_step"))) - return "\n".join(lines).strip() + "\n" +def _reason(code: str) -> str: + return BLOCKER_REASON_MAP.get(code, "Manual review required.") -def _report_section(name: str) -> list[str]: - emoji = REPORT_SECTION_EMOJIS.get(name) - label = f"{emoji} {name}" if emoji else name - return [f"## {label}"] +def _count_value(value: object) -> int: + if isinstance(value, dict): + return len(value) + if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): + return len(list(value)) + if isinstance(value, int): + return value + return 0 -def _bullet_lines(items: list[str]) -> list[str]: - return [f"- {item}" for item in items] +def _as_string_list(value: object) -> list[str]: + if isinstance(value, dict): + return [str(item) for item in value] + if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): + return [str(item) for item in value] + return [] -def _count_operations(operations: object, actions: set[str]) -> int: - if not isinstance(operations, list): +def _count_unsupported_families(value: object) -> int: + if not isinstance(value, dict): return 0 - return sum( - 1 - for operation in operations - if isinstance(operation, dict) and str(operation.get("action") or "") in actions - ) + return sum(_count_value(families) for families in value.values()) -def _table_lines( - headers: list[str], - rows: list[list[str]], - *, - none_row: list[str] | None = None, -) -> list[str]: - output = [ - "| " + " | ".join(_table_cell(header) for header in headers) + " |", - "| " + " | ".join(["---"] * len(headers)) + " |", - ] - if not rows and none_row is not None: - rows = [none_row] - for row in rows: - padded = row + [""] * (len(headers) - len(row)) - output.append("| " + " | ".join(_table_cell(cell) for cell in padded[: len(headers)]) + " |") - return output +def _next_action_name(value: object) -> str: + return str(value.get("action") or "unknown") if isinstance(value, dict) else "unknown" -def _table_cell(value: object) -> str: - return " ".join(str(value).splitlines()).replace("|", r"\|") +def _next_action_requires_approval(value: object) -> bool: + return bool(value.get("requires_explicit_approval")) if isinstance(value, dict) else False -def _bounded_rows(rows: list[list[str]], label: str) -> list[list[str]]: - if len(rows) <= REPORT_TABLE_ROW_LIMIT: - return rows - omitted_count = len(rows) - REPORT_TABLE_ROW_LIMIT - return rows[:REPORT_TABLE_ROW_LIMIT] + [ - [f"{omitted_count} additional {label} rows omitted; use --format json for full detail."] - ] +def _install_lane_label() -> str: + return "repo-to-home install" def _join_or_none(values: list[str]) -> str: - if not values: - return "none" - return ", ".join(values) - + return ",".join(values) if values else "none" -def _as_string_list(value: object) -> list[str]: - if not isinstance(value, Iterable) or isinstance(value, (str, bytes, dict)): - return [] - return [str(item) for item in value] - -def _install_planned_rows(payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - grouped: dict[tuple[str, str, str, str], int] = {} - operations = payload.get("operations") - if not isinstance(operations, list): - return rows - for operation in operations: - if not isinstance(operation, dict): - continue - action = str(operation.get("action") or "") - if action not in {"copy", "mkdir", "delete", "stale-managed"}: - continue - path = str(operation.get("resource_id") or operation.get("path") or "unknown") - reason = str(operation.get("reason") or "policy decision") - winner = str(operation.get("code") or operation.get("target") or "n/a") - planned = "copy" if action == "copy" else action - grouped[(path, planned, reason, winner)] = grouped.get((path, planned, reason, winner), 0) + 1 - for (path, planned, reason, winner), count in grouped.items(): - label = f"{path} ({count} targets)" if count > 1 else path - rows.append([label, "install", planned, reason, winner]) - return rows - - -def _install_completed_rows(payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - grouped: dict[tuple[str, str, str, str, str], int] = {} - if str(payload.get("mode") or "") != "apply": - return rows - operations = payload.get("operations") - if not isinstance(operations, list): - return rows - for operation in operations: - if not isinstance(operation, dict): - continue - action = str(operation.get("action") or "") - if action not in {"copy", "delete", "mkdir"}: - continue - path = str(operation.get("resource_id") or operation.get("path") or "unknown") - reason = str(operation.get("reason") or "applied by plan") - result = "ok" if action != "skip" else "skipped" - verification = "hash-match" if action in {"copy", "delete"} else "n/a" - grouped[(path, action, reason, result, verification)] = grouped.get((path, action, reason, result, verification), 0) + 1 - for (path, action, reason, result, verification), count in grouped.items(): - label = f"{path} ({count} targets)" if count > 1 else path - rows.append([label, action, reason, result, verification]) - return rows - - -def _install_blocker_rows(payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - seen: set[tuple[str, str, str]] = set() - operations = payload.get("operations") - if isinstance(operations, list): - for operation in operations: - if not isinstance(operation, dict): - continue - action = str(operation.get("action") or "") - if action != "blocked": - continue - code = str(operation.get("code") or "unknown") - path = str(operation.get("resource_id") or operation.get("path") or "unknown") - reason = str(operation.get("reason") or _reason_for_code(code)) - key = (code, path, reason) - if key in seen: - continue - seen.add(key) - rows.append([code, path, reason, "Resolve blocker and rerun plan."]) - for code in _as_string_list(payload.get("blocked_codes")): - if any(existing[0] == code for existing in rows): - continue - rows.append([code, "n/a", _reason_for_code(code), "Resolve blocker and rerun plan."]) - return rows - - -def _bisync_planned_rows(payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - drifts = payload.get("drifts") - if not isinstance(drifts, list): - return rows - for drift in drifts: - if not isinstance(drift, dict): - continue - dtype = str(drift.get("type") or "drift") - direction = str(drift.get("direction") or "manual") - skill = str(drift.get("skill") or "unknown") - planned_action = "copy" if direction in {"repo-to-home", "home-to-repo"} else "manual-review" - reason = _bisync_reason(dtype, direction) - evidence = direction if direction != "manual" else dtype - rows.append([skill, "bisync", planned_action, reason, evidence]) - return rows +def _compact_path(path: str) -> str: + return path.rsplit("/", 1)[-1] or path -def _bisync_completed_rows(payload: dict[str, object]) -> list[list[str]]: - if str(payload.get("mode") or "") != "apply": - return [] - verification = payload.get("verification") - status = "unknown" - if isinstance(verification, dict): - status = str(verification.get("status") or status) - if status != "converged": - return [] - drifts = payload.get("drifts") - if not isinstance(drifts, list): - return [] - rows: list[list[str]] = [] - for drift in drifts: - if not isinstance(drift, dict): - continue - direction = str(drift.get("direction") or "") - if direction not in {"repo-to-home", "home-to-repo"}: - continue - skill = str(drift.get("skill") or "unknown") - rows.append([skill, "copied", f"Applied {direction} winner.", "ok", "post-apply plan clean"]) - return rows +def _report_section(name: str) -> list[str]: + return [f"## {REPORT_SECTION_EMOJIS.get(name, '')} {name}".rstrip()] -def _doctor_check_count(checks: object, status: str) -> int: - if not isinstance(checks, list): - return 0 - return sum(1 for check in checks if isinstance(check, dict) and check.get("status") == status) +def _bullet_lines(values: list[str]) -> list[str]: + return [f"- {value}" for value in values] -def _doctor_readiness_rows(payload: dict[str, object]) -> list[list[str]]: - checks = payload.get("checks") - if not isinstance(checks, list): +def _table_lines(headers: list[str], rows: list[list[str]]) -> list[str]: + if not rows: return [] + lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"] + lines.extend("| " + " | ".join(row) + " |" for row in rows) + return lines - rows: list[list[str]] = [] - for check in checks: - if not isinstance(check, dict): - continue - status = str(check.get("status") or "unknown") - if status == "ok": - continue - code = str(check.get("code") or "none") - name = str(check.get("name") or check.get("path") or "unknown") - path = str(check.get("path") or "") - check_or_path = f"{name} ({path})" if path else name - reason = str(check.get("reason") or _reason_for_code(code)) - blocking_reason = _reason_for_code(code) if code != "none" else "Manual review required." - rows.append([ - check_or_path, - status, - reason, - f"{code}: {blocking_reason}", - _doctor_recommended_action(code), - ]) - return rows - - -def _doctor_recommended_action(code: str) -> str: - if code == "needs-directory-create": - return "Create the runtime root intentionally or rerun apply with --create-missing-dirs after review." - if code == "docs-unverified": - return "Verify runtime support, update the support matrix, or rerun with --experimental-targets only after review." - if code == "source-missing": - return "Fix the catalog entry or restore the missing source path, then rerun doctor." - if code == "unsupported-family": - return "Remove the target or add documented runtime support before apply." - return "Resolve the readiness issue, then rerun doctor." - - -def _bisync_blocker_rows(payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - drifts = payload.get("drifts") - if isinstance(drifts, list): - for drift in drifts: - if not isinstance(drift, dict): - continue - codes = drift.get("blocked_codes") - if not isinstance(codes, list): - continue - skill = str(drift.get("skill") or "unknown") - for code in codes: - code_str = str(code) - rows.append([code_str, skill, _reason_for_code(code_str), "Resolve manually before apply."]) - for code in _as_string_list(payload.get("blocked_codes")): - if any(existing[0] == code for existing in rows): - continue - rows.append([code, "n/a", _reason_for_code(code), "Resolve manually before apply."]) - return rows - - -def _remaining_work_rows(payload: dict[str, object]) -> list[list[str]]: - rows: list[list[str]] = [] - for code in _as_string_list(payload.get("blocked_codes")): - rows.append([code, _reason_for_code(code), "Resolve blocker and rerun."]) - residual = payload.get("residual_drift") - if isinstance(residual, list) and residual: - rows.append(["residual_drift", "Residual drift remains after apply.", "Run plan and resolve residual entries."]) - verification = payload.get("verification") - if isinstance(verification, dict): - residual_drifts = verification.get("residual_drifts") - if isinstance(residual_drifts, list) and residual_drifts: - rows.append(["bisync_residual", "Bisync verification reported residual drift.", "Resolve residual drifts and rerun bisync plan."]) - return rows - -def _next_action_table(next_action: object, next_step: object) -> list[str]: - if not isinstance(next_action, dict): - rows = [["Action", "unknown"], ["Reason", str(next_step or "none")]] - return _table_lines(["Field", "Value"], rows) - rows = [ - ["Action", str(next_action.get("action") or "unknown")], - ["Allowed", str(bool(next_action.get("allowed", False))).lower()], - [ - "Requires explicit approval", - str(bool(next_action.get("requires_explicit_approval", False))).lower(), - ], - ["Command", str(next_action.get("command") or "") or "none"], - ["Reason", str(next_action.get("reason") or next_step or "none")], - ] - return _table_lines(["Field", "Value"], rows) - - -def _next_action_name(next_action: object) -> str: - if isinstance(next_action, dict): - action = next_action.get("action") - if isinstance(action, str) and action: - return action - return "unknown" - - -def _next_action_requires_approval(next_action: object) -> bool: - if isinstance(next_action, dict): - return bool(next_action.get("requires_explicit_approval", False)) - return False - - -def _compact_path(path: str) -> str: - parts = [part for part in path.split("/") if part] - if len(parts) >= 2: - return "/".join(parts[-2:]) - return path - - -def _reason_for_code(code: str) -> str: - return BLOCKER_REASON_MAP.get(code, "Manual review required.") - - -def _bisync_reason(drift_type: str, direction: str) -> str: - if drift_type == "only-repo": - return "Skill exists only in repository and can be created in home during explicit apply." - if drift_type == "only-home": - return "Skill exists only in home and requires manual decision." - if drift_type == "equal-mtime": - return "Hashes differ with equal timestamps, so winner is ambiguous." - if direction == "repo-to-home": - return "Repository bundle timestamp is newer than home bundle." - if direction == "home-to-repo": - return "Home bundle timestamp is newer than repository bundle." - return "Drift detected and requires review." +def _bounded_rows(rows: list[list[str]], label: str = "row") -> list[list[str]]: + if len(rows) <= REPORT_TABLE_ROW_LIMIT: + return rows + omitted = len(rows) - REPORT_TABLE_ROW_LIMIT + return rows[:REPORT_TABLE_ROW_LIMIT] + [["...", f"{omitted} more {label}(s)", ""]] diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py index 2ad21d5e..6766b26f 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py @@ -1,3 +1,4 @@ +import json import sys from pathlib import Path @@ -14,9 +15,247 @@ HomeSyncPlan, ManagedResource, _stale_confinement_check, + add_materialization_operation, + add_stale_managed_operations, + assess_skill_link, apply_home_sync_plan, + canonical_skill_link_target, hash_resource, ) +from home_sync_contract import CatalogResource, HomeSyncPolicy # noqa: E402 +from sync_home_ai_resources import parse_args, run # noqa: E402 + + +def _demo_resource() -> CatalogResource: + return CatalogResource( + resource_id="demo", + source_family="skills", + source_path=".github/skills/demo", + include_targets=("skills",), + target_support="documented", + notes="", + ) + + +def _repo_wins_policy() -> HomeSyncPolicy: + return HomeSyncPolicy( + include_local_skills=False, + include_internal_skills=True, + include_unlisted_skills=True, + skill_targets=("skills",), + excluded_skills=(), + unmanaged_existing_skills_policy="repo-wins", + ) + + +def test_skill_link_assessment_is_deterministic(tmp_path: Path) -> None: + source = tmp_path / ".github/skills/demo" + source.mkdir(parents=True) + (source / "SKILL.md").write_text("# demo\n", encoding="utf-8") + expected = canonical_skill_link_target(tmp_path, ".github/skills/demo") + missing = tmp_path / "home/.agents/skills/demo" + missing.parent.mkdir(parents=True) + + assert assess_skill_link(missing, expected) == ("missing", None) + + missing.symlink_to(expected) + assert assess_skill_link(missing, expected) == ("matching", None) + + missing.unlink() + missing.mkdir() + assert assess_skill_link(missing, expected) == ("replace-directory", None) + + missing.rmdir() + other_checkout = tmp_path / "other-checkout" + other_checkout.mkdir() + missing.symlink_to(other_checkout, target_is_directory=True) + assert assess_skill_link(missing, expected) == ("blocked", "link-target-mismatch") + + missing.unlink() + missing.symlink_to(tmp_path / "removed-checkout", target_is_directory=True) + assert assess_skill_link(missing, expected) == ("blocked", "link-target-missing") + + +def test_skill_planning_uses_link_and_adopts_matching_link(tmp_path: Path) -> None: + source, source_hash = _setup_skill_source(tmp_path) + target = tmp_path / "home/.agents/skills/demo" + target.parent.mkdir(parents=True) + operations: list[HomeSyncOperation] = [] + + add_materialization_operation( + operations, + target="skills", + target_path=target, + source_path=source, + resource=_demo_resource(), + source_hash=source_hash, + manifest_index={}, + changed_only=False, + policy=_repo_wins_policy(), + ) + assert [operation.action for operation in operations] == ["link"] + + target.symlink_to(source.resolve(), target_is_directory=True) + operations.clear() + add_materialization_operation( + operations, + target="skills", + target_path=target, + source_path=source, + resource=_demo_resource(), + source_hash=source_hash, + manifest_index={}, + changed_only=False, + policy=_repo_wins_policy(), + ) + assert [operation.action for operation in operations] == ["skip"] + + +def test_stale_manifest_skill_link_is_unlinked_without_prune_flag(tmp_path: Path) -> None: + target = tmp_path / "home/.agents/skills/old" + target.parent.mkdir(parents=True) + source = tmp_path / "repo/.github/skills/old" + source.mkdir(parents=True) + (source / "SKILL.md").write_text("# old\n", encoding="utf-8") + target.symlink_to(source.resolve(), target_is_directory=True) + operations: list[HomeSyncOperation] = [] + + add_stale_managed_operations( + operations, + { + "schema_version": 2, + "managed_resources": [ + { + "target": "skills", + "resource_family": "skills", + "resource_id": "old", + "source_path": ".github/skills/old", + "target_path": target.as_posix(), + "source_hash": "source", + "materialization": "symlink", + "link_target": source.resolve().as_posix(), + "content_hash": None, + "last_action": "link", + } + ], + }, + [], + ("skills",), + (), + "plan", + False, + tmp_path / "home", + _repo_wins_policy(), + ) + + assert [(operation.action, operation.code) for operation in operations] == [("unlink", None)] + + +def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( + tmp_path: Path, capsys +) -> None: + refs = tmp_path / ".github/skills/local-agent-sync-install-ai-resources/references" + refs.mkdir(parents=True) + (refs / "home-sync-catalog.yaml").write_text( + """version: 1 +defaults: + include_internal_skills: true + include_local_skills: false + include_unlisted_skills: true + unmanaged_existing_skills_policy: repo-wins + excluded_skills: [graphify] + skill_targets: [codex] +resources: + - resource_id: demo-agent + source_family: agents + source_path: .github/agents/demo-agent.agent.md + include_targets: [codex] + target_support: documented + notes: test agent +""", + encoding="utf-8", + ) + (refs / "runtime-support-matrix.yaml").write_text( + """version: 1 +rows: + - target: skills + resource_family: skills + support_level: Documented + home_path: ~/.agents/skills// + direct_copy_possible: true + translation_required: false + include_in_v1: true + evidence: [] + notes: test + - target: codex + resource_family: skills + support_level: Documented + home_path: ~/.agents/skills// + direct_copy_possible: true + translation_required: false + include_in_v1: true + evidence: [] + notes: test + - target: codex + resource_family: agents + support_level: Documented + home_path: ~/.codex/agents/ + direct_copy_possible: false + translation_required: true + include_in_v1: true + evidence: [] + notes: test +""", + encoding="utf-8", + ) + source_skill = tmp_path / ".github/skills/demo" + source_skill.mkdir(parents=True) + (source_skill / "SKILL.md").write_text("# demo\n", encoding="utf-8") + for skill_id in ("graphify", "local-private"): + skill = tmp_path / ".github/skills" / skill_id + skill.mkdir() + (skill / "SKILL.md").write_text(f"# {skill_id}\n", encoding="utf-8") + agent = tmp_path / ".github/agents/demo-agent.agent.md" + agent.parent.mkdir(parents=True) + agent.write_text("---\nname: demo-agent\ndescription: test\n---\nTest agent.\n", encoding="utf-8") + + home = tmp_path / "home" + divergent = home / ".agents/skills/demo" + divergent.mkdir(parents=True) + (divergent / "SKILL.md").write_text("# divergent\n", encoding="utf-8") + for skill_id in ("graphify", "home-only"): + skill = home / ".agents/skills" / skill_id + skill.mkdir(parents=True, exist_ok=True) + (skill / "SKILL.md").write_text(f"# {skill_id}\n", encoding="utf-8") + + assert run(parse_args(["sync", "--source-root", str(tmp_path), "--home-root", str(home), "--targets", "skills,codex", "--create-missing-dirs"])) == 0 + + target_skill = home / ".agents/skills/demo" + assert target_skill.is_symlink() + assert target_skill.resolve() == source_skill.resolve() + assert (home / ".agents/skills/graphify/SKILL.md").is_file() + assert (home / ".agents/skills/home-only/SKILL.md").is_file() + assert (home / ".codex/agents/demo-agent.toml").is_file() + (source_skill / "SKILL.md").write_text("# repo edit\n", encoding="utf-8") + assert (target_skill / "SKILL.md").read_text(encoding="utf-8") == "# repo edit\n" + (target_skill / "SKILL.md").write_text("# home write\n", encoding="utf-8") + assert (source_skill / "SKILL.md").read_text(encoding="utf-8") == "# home write\n" + + import shutil + + shutil.rmtree(source_skill) + assert run(parse_args(["sync", "--source-root", str(tmp_path), "--home-root", str(home), "--targets", "skills,codex", "--create-missing-dirs"])) == 0 + capsys.readouterr() + assert not target_skill.exists() + assert not target_skill.is_symlink() + for mode in ("plan", "audit", "doctor"): + assert run(parse_args([mode, "--source-root", str(tmp_path), "--home-root", str(home), "--targets", "skills,codex"])) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload.get("blocked_codes", []) == [] + assert payload["counts"]["linked"] == 0 + assert payload["counts"]["unlinked"] == 0 + assert payload["counts"]["blocked"] == 0 + assert payload["counts"]["residual"] == 0 def _setup_skill_source(tmp_path: Path) -> tuple[Path, str]: @@ -27,8 +266,8 @@ def _setup_skill_source(tmp_path: Path) -> tuple[Path, str]: return skill_dir, content_hash -def test_apply_copies_skill_to_home(tmp_path: Path) -> None: - _, content_hash = _setup_skill_source(tmp_path) +def test_apply_links_skill_to_home_with_write_through(tmp_path: Path) -> None: + source, _ = _setup_skill_source(tmp_path) home_root = tmp_path / "home" target_path = home_root / ".agents" / "skills" / "demo" target_path.parent.mkdir(parents=True) @@ -42,12 +281,14 @@ def test_apply_copies_skill_to_home(tmp_path: Path) -> None: source_path=".github/skills/demo", target_path=str(target_path), source_hash="abc", - content_hash=content_hash, - last_action="copy", + materialization="symlink", + link_target=source.resolve().as_posix(), + content_hash=None, + last_action="link", ) operation = HomeSyncOperation( target="skills", - action="copy", + action="link", path=str(target_path), reason="first install", source_path=".github/skills/demo", @@ -71,8 +312,13 @@ def test_apply_copies_skill_to_home(tmp_path: Path) -> None: manifest_path = apply_home_sync_plan(plan) assert manifest_path.is_file() + assert target_path.is_symlink() + assert target_path.resolve() == source.resolve() assert (target_path / "SKILL.md").is_file() - assert hash_resource(target_path) == content_hash + (source / "SKILL.md").write_text("# changed in repo\n", encoding="utf-8") + assert (target_path / "SKILL.md").read_text(encoding="utf-8") == "# changed in repo\n" + (target_path / "SKILL.md").write_text("# changed through home\n", encoding="utf-8") + assert (source / "SKILL.md").read_text(encoding="utf-8") == "# changed through home\n" def test_apply_delete_with_prune(tmp_path: Path) -> None: diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_safety.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_safety.py index d302f55a..4d16b498 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_safety.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_safety.py @@ -16,6 +16,7 @@ HomeSyncPlan, ManagedResource, apply_home_sync_plan, + probe_symlink_support, ) @@ -31,12 +32,14 @@ def _make_plan(tmp_path: Path, target_path: Path) -> HomeSyncPlan: source_path=".github/skills/demo", target_path=str(target_path), source_hash="abc", - content_hash="abc", - last_action="copy", + materialization="symlink", + link_target=source_skill.resolve().as_posix(), + content_hash=None, + last_action="link", ) operation = HomeSyncOperation( target="skills", - action="copy", + action="link", path=str(target_path), reason="first install", code=None, @@ -81,5 +84,16 @@ def test_apply_blocks_symlink_escape(tmp_path: Path) -> None: plan = _make_plan(tmp_path, symlink_path) (tmp_path / "state").mkdir() - with pytest.raises(RuntimeError, match="symlink-not-allowed|unsafe-home-path"): + with pytest.raises(RuntimeError, match="link-target-mismatch"): apply_home_sync_plan(plan) + + +def test_probe_symlink_support_returns_blocker_when_os_rejects_link_creation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def reject_symlink(*args: object, **kwargs: object) -> None: + raise OSError("not supported") + + monkeypatch.setattr("home_syncing.os.symlink", reject_symlink) + + assert probe_symlink_support(tmp_path) == "symlink-unsupported" diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py index 2a09e96c..35eb4767 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py @@ -5,7 +5,6 @@ import textwrap import tomllib from pathlib import Path -from types import SimpleNamespace import pytest @@ -18,33 +17,222 @@ sys.path.insert(0, SCRIPT_DIR.as_posix()) from agent_translation import target_extension, translate_agent_for_target # noqa: E402 -from bisync_skills import ( # noqa: E402 - build_bisync_plan, - run_bisync_apply, - run_bisync_plan, -) from home_sync_contract import ( # noqa: E402 load_home_sync_catalog, load_home_sync_policy, ) from home_syncing import ( # noqa: E402 HomeSyncOperation, + HomeSyncPlan, + ManagedResource, build_home_sync_plan, build_manifest_payload, + load_manifest, parse_targets, state_root_for_home, ) from sync_home_ai_resources import ( # noqa: E402 - bisync_requires_review, install_auto_apply_blockers, + parse_args, ) from sync_output import ( # noqa: E402 + build_compact_install_output, render_doctor_report, render_install_report, render_sync_report, ) +def test_parse_args_rejects_removed_bisync_and_preserves_supported_modes() -> None: + for command in ("sync", "plan", "apply", "audit", "doctor", "dry-run"): + assert parse_args([command]).command == command + + with pytest.raises(SystemExit): + parse_args(["bisync", "plan"]) + + +def test_install_payload_reports_linked_and_unlinked_without_bisync() -> None: + compact = build_compact_install_output( + { + "mode": "plan", + "validation": "ok", + "linked": ["/home/.agents/skills/alpha"], + "unlinked": ["/home/.agents/skills/removed"], + "operations": [], + } + ) + + assert compact["counts"]["linked"] == 1 + assert compact["counts"]["unlinked"] == 1 + assert "bisync" not in compact + + +def test_empty_manifest_defaults_to_schema_v2(tmp_path: Path) -> None: + payload, error = load_manifest(tmp_path / "manifest.json") + + assert error is None + assert payload == {"schema_version": 2, "managed_resources": []} + + +def test_v1_manifest_rows_are_normalized_as_copy_without_rewrite(tmp_path: Path) -> None: + path = tmp_path / "manifest.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "managed_resources": [ + { + "target": "skills", + "resource_family": "skills", + "resource_id": "demo", + "source_path": ".github/skills/demo", + "target_path": str(tmp_path / "home/demo"), + "source_hash": "source", + "content_hash": "content", + "last_action": "copy", + } + ], + } + ), + encoding="utf-8", + ) + + payload, error = load_manifest(path) + + assert error is None + assert payload["managed_resources"][0]["materialization"] == "copy" + assert payload["managed_resources"][0]["link_target"] is None + assert path.read_text(encoding="utf-8").startswith('{"schema_version": 1') + + +@pytest.mark.parametrize( + ("row", "expected_error"), + [ + ( + { + "target": "skills", + "resource_family": "skills", + "materialization": "copy", + "link_target": None, + "content_hash": None, + "target_path": "/home/.agents/skills/demo", + }, + "manifest-corrupt", + ), + ( + { + "target": "skills", + "resource_family": "skills", + "materialization": "symlink", + "link_target": None, + "content_hash": None, + "target_path": "/home/.agents/skills/demo", + }, + "manifest-corrupt", + ), + ( + { + "target": "codex", + "resource_family": "agents", + "materialization": "copy", + "link_target": "/repo/.github/agents/demo.agent.md", + "content_hash": "hash", + "target_path": "/home/.codex/agents/demo.toml", + }, + "manifest-corrupt", + ), + ], +) +def test_manifest_rejects_inconsistent_v2_rows( + tmp_path: Path, row: dict[str, object], expected_error: str +) -> None: + path = tmp_path / "manifest.json" + path.write_text( + json.dumps({"schema_version": 2, "managed_resources": [row]}), + encoding="utf-8", + ) + + _, error = load_manifest(path) + + assert error == expected_error + + +def test_valid_v2_manifest_rows_load_unchanged(tmp_path: Path) -> None: + rows = [ + { + "target": "skills", + "resource_family": "skills", + "materialization": "symlink", + "link_target": (tmp_path / "repo/.github/skills/demo").as_posix(), + "content_hash": None, + "target_path": (tmp_path / "home/.agents/skills/demo").as_posix(), + }, + { + "target": "codex", + "resource_family": "agents", + "materialization": "copy", + "link_target": None, + "content_hash": "agent-content", + "target_path": (tmp_path / "home/.codex/agents/review.toml").as_posix(), + }, + ] + path = tmp_path / "manifest.json" + path.write_text(json.dumps({"schema_version": 2, "managed_resources": rows}), encoding="utf-8") + + payload, error = load_manifest(path) + + assert error is None + assert payload["schema_version"] == 2 + assert payload["managed_resources"] == rows + + +def test_manifest_serialization_emits_v2_link_and_copy_rows(tmp_path: Path) -> None: + skill = ManagedResource( + target="skills", + resource_id="demo", + resource_family="skills", + source_path=".github/skills/demo", + target_path=str(tmp_path / "home/.agents/skills/demo"), + source_hash="source", + materialization="symlink", + link_target=str(tmp_path / ".github/skills/demo"), + content_hash=None, + last_action="link", + ) + agent = ManagedResource( + target="codex", + resource_id="review", + resource_family="agents", + source_path=".github/agents/review.agent.md", + target_path=str(tmp_path / "home/.codex/agents/review.toml"), + source_hash="source-agent", + materialization="copy", + link_target=None, + content_hash="content-agent", + last_action="copy", + ) + plan = HomeSyncPlan( + source_root=tmp_path, + home_root=tmp_path / "home", + state_root=tmp_path / "state", + mode="apply", + selected_targets=("skills", "codex"), + retired_targets=(), + source_revision=None, + source_resources_considered=2, + operations=(), + desired_resources=(skill, agent), + missing_dirs=(), + unsupported_families_by_target={}, + residual_drift=(), + ) + + payload = build_manifest_payload(plan) + + assert payload["schema_version"] == 2 + assert payload["managed_resources"] == [skill.to_dict(), agent.to_dict()] + + def test_translate_agent_for_codex_preserves_body_and_handoffs(tmp_path: Path) -> None: source_path = tmp_path / "review.agent.md" source_path.write_text( @@ -204,11 +392,6 @@ def test_render_sync_report_omits_empty_action_sections() -> None: "state_path": "/tmp/state", "manifest_path": "/tmp/manifest", }, - "bisync": { - "mode": "plan", - "drifts": [], - "verification": {"status": "ok"}, - }, "next_action": { "action": "done", "allowed": True, @@ -222,66 +405,11 @@ def test_render_sync_report_omits_empty_action_sections() -> None: assert "🚦 Status:" in report assert "## 🧭 Summary" in report assert "## πŸš€ Auto-applied" not in report - assert "## πŸ“‹ Planned repo-to-home copies" not in report + assert "## πŸ“‹ Planned changes" not in report assert "## β›” Stopped on" not in report assert "## πŸ”Ž Validation" in report assert "## ➑️ Next" in report - - -def test_build_bisync_plan_filters_local_and_excluded_bundles(tmp_path: Path) -> None: - refs_dir = ( - tmp_path - / ".github" - / "skills" - / "local-agent-sync-install-ai-resources" - / "references" - ) - refs_dir.mkdir(parents=True) - (refs_dir / "home-sync-catalog.yaml").write_text( - textwrap.dedent( - """\ - version: 1 - defaults: - include_internal_skills: true - include_local_skills: false - include_unlisted_skills: false - unmanaged_existing_skills_policy: block - excluded_skills: - - graphify - skill_targets: - - codex - resources: [] - """ - ), - encoding="utf-8", - ) - - repo_skills = tmp_path / ".github" / "skills" - home_root = tmp_path / "home" - home_skills = home_root / ".agents" / "skills" - home_skills.mkdir(parents=True) - - for skill_name, root in ( - ("shared-skill", repo_skills), - ("local-helper", repo_skills), - ("graphify", repo_skills), - ("home-only-skill", home_skills), - ): - skill_dir = root / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"# {skill_name}\n", encoding="utf-8") - - plan = build_bisync_plan(tmp_path, home_root, mode="plan") - - assert {(drift.skill_name, drift.drift_type) for drift in plan.drifts} == { - ("shared-skill", "only-repo"), - ("home-only-skill", "only-home"), - } - assert "bisync-only-home" in plan.blocked_codes - assert "bisync-only-repo" in plan.blocked_codes - assert all( - drift.skill_name not in {"local-helper", "graphify"} for drift in plan.drifts - ) + assert "bisync" not in report def test_install_auto_apply_blockers_require_explicit_review() -> None: @@ -314,20 +442,6 @@ def blocked_codes(self) -> list[str]: assert blockers == ["install-residual-drift", "needs-directory-create"] -def test_bisync_requires_review_only_for_non_safe_drift() -> None: - safe_plan = SimpleNamespace( - blocked_codes=["bisync-only-repo"], - drifts=[SimpleNamespace(drift_type="only-repo", direction="repo-to-home")], - ) - review_plan = SimpleNamespace( - blocked_codes=[], - drifts=[SimpleNamespace(drift_type="drift", direction="home-to-repo")], - ) - - assert bisync_requires_review(safe_plan) is False - assert bisync_requires_review(review_plan) is True - - def test_skill_run_sh_should_quiet_only_for_compact_modes() -> None: run_sh = ( ( @@ -439,124 +553,6 @@ def test_fast_mode_does_not_filter_apply_catalog(tmp_path: Path) -> None: assert fast.source_resources_considered == normal.source_resources_considered == 2 -def test_bisync_apply_requires_reviewed_plan_snapshot( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - repo_root = tmp_path / "repo" - home_root = tmp_path / "home" - refs_dir = ( - repo_root - / ".github" - / "skills" - / "local-agent-sync-install-ai-resources" - / "references" - ) - refs_dir.mkdir(parents=True) - (refs_dir / "home-sync-catalog.yaml").write_text( - textwrap.dedent( - """\ - version: 1 - defaults: - include_internal_skills: true - include_local_skills: false - include_unlisted_skills: false - unmanaged_existing_skills_policy: block - excluded_skills: [] - skill_targets: - - codex - resources: [] - """ - ), - encoding="utf-8", - ) - (repo_root / ".github" / "skills" / "demo-skill").mkdir(parents=True) - (repo_root / ".github" / "skills" / "demo-skill" / "SKILL.md").write_text( - "# demo\n", encoding="utf-8" - ) - (home_root / ".agents" / "skills").mkdir(parents=True) - - subprocess.run(["git", "init", "-q"], cwd=repo_root, check=True) - subprocess.run( - ["git", "config", "user.email", "review@example.com"], cwd=repo_root, check=True - ) - subprocess.run( - ["git", "config", "user.name", "reviewer"], cwd=repo_root, check=True - ) - subprocess.run(["git", "add", "."], cwd=repo_root, check=True) - subprocess.run(["git", "commit", "-qm", "init"], cwd=repo_root, check=True) - - args = argparse.Namespace( - source_root=repo_root.as_posix(), - home_root=home_root.as_posix(), - format="compact", - compact=True, - ) - - assert run_bisync_apply(args) == 1 - payload = json.loads(capsys.readouterr().out) - assert "bisync-plan-required" in payload["blockers"] - - -def test_bisync_apply_uses_matching_reviewed_plan_snapshot( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - repo_root = tmp_path / "repo" - home_root = tmp_path / "home" - refs_dir = ( - repo_root - / ".github" - / "skills" - / "local-agent-sync-install-ai-resources" - / "references" - ) - refs_dir.mkdir(parents=True) - (refs_dir / "home-sync-catalog.yaml").write_text( - textwrap.dedent( - """\ - version: 1 - defaults: - include_internal_skills: true - include_local_skills: false - include_unlisted_skills: false - unmanaged_existing_skills_policy: block - excluded_skills: [] - skill_targets: - - codex - resources: [] - """ - ), - encoding="utf-8", - ) - (repo_root / ".github" / "skills" / "demo-skill").mkdir(parents=True) - (repo_root / ".github" / "skills" / "demo-skill" / "SKILL.md").write_text( - "# demo\n", encoding="utf-8" - ) - (home_root / ".agents" / "skills").mkdir(parents=True) - - subprocess.run(["git", "init", "-q"], cwd=repo_root, check=True) - subprocess.run( - ["git", "config", "user.email", "review@example.com"], cwd=repo_root, check=True - ) - subprocess.run( - ["git", "config", "user.name", "reviewer"], cwd=repo_root, check=True - ) - subprocess.run(["git", "add", "."], cwd=repo_root, check=True) - subprocess.run(["git", "commit", "-qm", "init"], cwd=repo_root, check=True) - - args = argparse.Namespace( - source_root=repo_root.as_posix(), - home_root=home_root.as_posix(), - format="compact", - compact=True, - ) - - assert run_bisync_plan(args) == 1 - _ = capsys.readouterr() - - assert run_bisync_apply(args) == 0 - assert (home_root / ".agents" / "skills" / "demo-skill" / "SKILL.md").is_file() - - def test_cross_target_skill_plan_deduplicates_shared_paths( tmp_path: Path, ) -> None: @@ -643,11 +639,11 @@ def test_cross_target_skill_plan_deduplicates_shared_paths( mode="plan", ) - copy_paths = [ - operation.path for operation in plan.operations if operation.action == "copy" + link_paths = [ + operation.path for operation in plan.operations if operation.action == "link" ] assert ( - copy_paths.count( + link_paths.count( str((tmp_path / "home" / ".agents" / "skills" / "demo-skill").resolve()) ) == 1 diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_error_handling.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_error_handling.py index a23fe6ec..2e0b7a15 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_error_handling.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_error_handling.py @@ -13,7 +13,7 @@ sys.path.insert(0, SCRIPT_DIR.as_posix()) from home_sync_contract import load_home_sync_catalog # noqa: E402 -from home_syncing import build_home_sync_plan # noqa: E402 +from home_syncing import build_home_sync_plan, load_manifest # noqa: E402 def test_load_catalog_raises_valueerror_on_malformed_yaml(tmp_path: Path) -> None: @@ -89,3 +89,15 @@ def test_build_plan_raises_reverse_sync_blocked(tmp_path: Path) -> None: with pytest.raises(RuntimeError, match="reverse-sync-blocked"): build_home_sync_plan(source_under_state, home_root, ("skills",), mode="plan") + + +def test_load_manifest_rejects_unsupported_schema(tmp_path: Path) -> None: + path = tmp_path / "manifest.json" + path.write_text( + '{"schema_version": 99, "managed_resources": []}', + encoding="utf-8", + ) + + _, error = load_manifest(path) + + assert error == "manifest-corrupt" From 8504f2239094d8fc5e93eaa62518c85f72bca0b1 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Sun, 12 Jul 2026 17:20:14 +0200 Subject: [PATCH 05/10] feat: add external research checkpoint and related validation to workflow --- .github/skills/internal-gateway-idea/SKILL.md | 48 +++++++++++++--- .../references/workflow.md | 47 +++++++++------- .../scripts/audit_workflow.py | 16 ++++++ .../test_workflow_contract.py | 56 +++++++++++++++++++ 4 files changed, 139 insertions(+), 28 deletions(-) diff --git a/.github/skills/internal-gateway-idea/SKILL.md b/.github/skills/internal-gateway-idea/SKILL.md index f7f7124c..a8dc4f18 100644 --- a/.github/skills/internal-gateway-idea/SKILL.md +++ b/.github/skills/internal-gateway-idea/SKILL.md @@ -9,6 +9,7 @@ description: Use when a repository-owned idea needs brainstorming, assumption ch - `superpowers-brainstorming`: core idea-to-design workflow. - `internal-gateway-writing-plans`: retained spec or implementation-plan writing after the user approves the direction. +- `mattpocock-research`: on-demand owner for decision-relevant external research after local evidence is exhausted; this reference does not preload the skill. ## Local references @@ -33,14 +34,14 @@ Lightweight repository-owned wrapper for idea shaping. Use `superpowers-brainsto ## Core contract -- Follow the mandatory gate sequence: `Specialization Checkpoint: gated`, `Idea Gate 0`, `Assumption Challenge Gate`, `Alternative discovery`, `Critical Challenge Gate`, `Spec vs plan decision`, `Stop before implementation execution`. +- Follow the mandatory gate sequence: `Specialization Checkpoint: gated`, `Idea Gate 0`, `External Research Checkpoint`, `Assumption Challenge Gate`, `Alternative discovery`, `Critical Challenge Gate`, `Spec vs plan decision`, `Stop before implementation execution`. - Load `superpowers-brainstorming` as the core workflow. - Read `references/workflow.md` before presenting the final design direction. - Keep the `superpowers-brainstorming` hard gate: no implementation action before the user approves the design or direct-plan recommendation. - Treat approval as gate-local. `procedi`, `ok`, `go`, or similar approval advances only the active visible gate. - If approval wording is ambiguous, ask whether it means critical review, retained spec or plan writing, or implementation execution. - After the bounded evidence pass, run `Idea Gate 0` as a visible numbered question block with `Question`, `Recommendation`, `Why`, and `Default if accepted`; evidence cannot replace Idea Gate 0. -- Do not proceed to assumption challenge, alternative discovery, design direction, critical challenge, or spec-vs-plan decision until `Idea Gate 0` is accepted or the user explicitly overrides its defaults. +- Do not proceed to `External Research Checkpoint`, assumption challenge, alternative discovery, design direction, critical challenge, or spec-vs-plan decision until `Idea Gate 0` is accepted or the user explicitly overrides its defaults. - Run `Critical Challenge Gate` as its own visible gate after the user approves the design direction and before the spec-vs-plan decision; an embedded critique does not satisfy Critical Challenge Gate. - If any mandatory gate was skipped, stop, name the missed gate, mark any downstream artifact as draft-only, and resume at the first skipped mandatory gate. - Use this skill only to add repository-owned idea gates, not to fork the core brainstorming process. @@ -65,13 +66,14 @@ Follow `references/workflow.md` in this order: 1. `Bounded evidence pass` 2. `Specialization Checkpoint: gated` when the incoming ask is execution-shaped. 3. `Idea Gate 0` -4. `Assumption Challenge Gate` -5. `Alternative discovery` -6. `Present design direction` -7. `Critical Challenge Gate` -8. `Spec vs plan decision` -9. `Approved writing handoff` -10. `Stop before implementation execution` +4. `External Research Checkpoint` +5. `Assumption Challenge Gate` +6. `Alternative discovery` +7. `Present design direction` +8. `Critical Challenge Gate` +9. `Spec vs plan decision` +10. `Approved writing handoff` +11. `Stop before implementation execution` If a later step happened before an earlier mandatory gate, use `Skipped-gate recovery`: stop the current lane, identify the first skipped mandatory gate, @@ -99,6 +101,34 @@ answer: intent, accepted defaults, constraints, success criteria, validation path, and anti-scope. A bounded evidence pass may prepare recommended defaults, but evidence cannot replace Idea Gate 0. +## External Research Checkpoint + +Run this checkpoint after `Idea Gate 0` is accepted and before `Assumption +Challenge Gate`. Local evidence remains the default. + +Skip external research unless all of these are true: + +- the unresolved question is owned by an external primary source; +- local evidence is insufficient; +- the answer could change feasibility, approach, constraints, or risk. + +When all conditions hold: + +1. Define one bounded research question. +2. load `mattpocock-research` on-demand and write one Markdown report under + `tmp/research/YYYY-MM-DD-.md`. +3. Bring only the report path and decision-relevant conclusions back into the + brainstorming flow. +4. Continue to `Assumption Challenge Gate`, or return to `Idea Gate 0` when the + evidence changes an accepted constraint or default. + +`internal-gateway-idea` owns when research is warranted. +`mattpocock-research` owns how the research is performed. Do not copy its +research procedure here, and do not start a second research pass automatically. + +Validation must keep this checkpoint on-demand, bounded to one question and one +report, preceded by local evidence, and routed to `tmp/research/`. + ## Assumption Challenge Gate Run this gate before finalizing the design direction. diff --git a/.github/skills/internal-gateway-idea/references/workflow.md b/.github/skills/internal-gateway-idea/references/workflow.md index 3c111f95..a21566e1 100644 --- a/.github/skills/internal-gateway-idea/references/workflow.md +++ b/.github/skills/internal-gateway-idea/references/workflow.md @@ -16,25 +16,31 @@ flowchart TD C1 --> D D --> E{Intent and defaults accepted?} E -- no --> D - E -- yes --> F[Assumption Challenge Gate] - F --> G[Alternative discovery] - G --> H[Present design direction] - H --> I{User approves design direction?} - I -- no --> D - I -- yes --> J[Critical Challenge Gate] - J --> K{Critical result} - K -- reopen --> D - K -- narrow --> H - K -- continue --> L[Spec vs plan decision] - L --> M{Decision} - M -- spec first --> N[Ask approval for retained spec path] - M -- direct plan --> O[Ask approval for direct plan path] - N --> P{Approved?} - O --> P - P -- no --> L - P -- yes --> Q[Load internal-gateway-writing-plans] - Q --> R[Writing outcome only] - R --> S[Stop before implementation execution] + E -- yes --> F{External Research Checkpoint} + F -- skip --> H[Assumption Challenge Gate] + F -- research needed --> G[Load mattpocock-research on-demand] + G --> G1[Write one report under tmp/research/] + G1 --> G2{Accepted defaults changed?} + G2 -- yes --> D + G2 -- no --> H + H --> I[Alternative discovery] + I --> J[Present design direction] + J --> K{User approves design direction?} + K -- no --> D + K -- yes --> L[Critical Challenge Gate] + L --> M{Critical result} + M -- reopen --> D + M -- narrow --> J + M -- continue --> N[Spec vs plan decision] + N --> O{Decision} + O -- spec first --> P[Ask approval for retained spec path] + O -- direct plan --> Q[Ask approval for direct plan path] + P --> R{Approved?} + Q --> R + R -- no --> N + R -- yes --> S[Load internal-gateway-writing-plans] + S --> T[Writing outcome only] + T --> U[Stop before implementation execution] ``` ## Gate Contract @@ -44,6 +50,7 @@ flowchart TD | `Specialization Checkpoint: gated` | Use when the incoming ask is already a file edit, command run, validator run, implementation step, or other execution-shaped request. Name the later execution owner only as a future consequence. | Do not execute, hand off, or present the post-critical recommendation. | | `Skipped-gate recovery` | Stop the current lane, name the first skipped mandatory gate, mark downstream artifacts draft-only, and resume at that gate. | Do not continue from an invalid later state or ask the user to approve a handoff built on skipped gates. | | `Idea Gate 0` | Confirm the recovered intent, defaults, constraints, success criteria, validation path, and anti-scope with a visible numbered question block using `Question`, `Recommendation`, `Why`, and `Default if accepted`; evidence cannot replace Idea Gate 0. | Do not treat repository evidence alone as user approval, and do not proceed to challenge, alternatives, design, or planning until this gate is accepted. | +| `External Research Checkpoint` | Skip unless local evidence is insufficient and one external fact could change feasibility, approach, constraints, or risk. When needed, load `mattpocock-research` on-demand with one bounded question, write one Markdown report under `tmp/research/`, and return only decision-relevant conclusions. | Do not preload the research skill, copy its research procedure, run generic best-practice research, or start a second research pass automatically. | | `Assumption Challenge Gate` | Test whether the proposed target or solution is necessary before choosing an approach. | Do not only polish the user's proposed solution. | | `Alternative discovery` | Present 2-3 approaches and explain why the recommended one beats the strongest rejected option. | Do not present a single-path design as inevitable. | | `Critical Challenge Gate` | Challenge the chosen direction as its own visible gate after design-direction approval and before spec or plan writing. Reopen or narrow when the objection is material. | Do not use this gate after loading `internal-gateway-writing-plans`; an embedded critique does not satisfy Critical Challenge Gate. | @@ -68,3 +75,5 @@ Keep the agent filename, frontmatter name, and workflow aligned. ## Local validation lane Run `python3 scripts/audit_workflow.py` or `make internal-gateway-idea-fast-check` before widening to catalog-wide checks. This scoped lane must cover the bundle audit and marker consistency. + +The checkpoint must use one bounded research question; do not start a second research pass automatically. diff --git a/.github/skills/internal-gateway-idea/scripts/audit_workflow.py b/.github/skills/internal-gateway-idea/scripts/audit_workflow.py index 2087f6c4..a8f37208 100644 --- a/.github/skills/internal-gateway-idea/scripts/audit_workflow.py +++ b/.github/skills/internal-gateway-idea/scripts/audit_workflow.py @@ -60,6 +60,18 @@ def main() -> int: "Approval Rules", "Routing Stability Rule", ] + research_markers = [ + "mattpocock-research", + "External Research Checkpoint", + "tmp/research/", + "on-demand", + "local evidence is insufficient", + "feasibility, approach, constraints, or risk", + "one bounded research question", + "one Markdown report", + "decision-relevant conclusions", + "do not start a second research pass automatically", + ] runtime_only_markers = [ "$internal-gateway-idea", "$superpowers-brainstorming", @@ -87,6 +99,10 @@ def main() -> int: "local_fast_lane_documented": "scripts/audit_workflow.py" in skill_text and "scripts/audit_workflow.py" in workflow_text, "workflow_mermaid_and_rules": contains_all(workflow_text, workflow_only_markers), + "skill_research_escalation": contains_all(skill_text, research_markers), + "workflow_research_escalation": contains_all( + workflow_text, research_markers + ), "canonical_alignment": "agent filename, frontmatter name, and workflow aligned" in skill_text and "agent filename, frontmatter name, and workflow aligned" in workflow_text and "agent filename, frontmatter name, and workflow aligned" in runtime_text, diff --git a/tests/github/skills/internal-gateway-idea/test_workflow_contract.py b/tests/github/skills/internal-gateway-idea/test_workflow_contract.py index 821957f9..6ac1835f 100644 --- a/tests/github/skills/internal-gateway-idea/test_workflow_contract.py +++ b/tests/github/skills/internal-gateway-idea/test_workflow_contract.py @@ -60,3 +60,59 @@ def test_audit_workflow_reports_extended_contract_status() -> None: assert payload["markers"]["workflow_gate_sequence"] is True assert payload["markers"]["runtime_core_markers"] is True assert payload["markers"]["local_fast_lane_documented"] is True + + +def test_external_research_checkpoint_is_lazy_and_skill_owned() -> None: + skill_text = SKILL_PATH.read_text() + workflow_text = WORKFLOW_PATH.read_text() + + required = [ + "mattpocock-research", + "External Research Checkpoint", + "tmp/research/", + "on-demand", + "local evidence is insufficient", + "feasibility, approach, constraints, or risk", + ] + for marker in required: + assert marker in skill_text + assert marker in workflow_text + + assert "does not preload" in skill_text + assert "owns when research is warranted" in skill_text + assert "owns how the research is performed" in skill_text + + +def test_external_research_checkpoint_sits_between_idea_and_challenge() -> None: + skill_text = SKILL_PATH.read_text() + workflow_text = WORKFLOW_PATH.read_text() + + skill_sequence = [ + "3. `Idea Gate 0`", + "4. `External Research Checkpoint`", + "5. `Assumption Challenge Gate`", + ] + workflow_sequence = [ + "D[Idea Gate 0]", + "F{External Research Checkpoint}", + "H[Assumption Challenge Gate]", + ] + _assert_in_order(skill_text, skill_sequence) + _assert_in_order(workflow_text, workflow_sequence) + + +def test_external_research_checkpoint_has_bounded_outcomes() -> None: + skill_text = SKILL_PATH.read_text() + workflow_text = WORKFLOW_PATH.read_text() + + outcomes = [ + "skip", + "load `mattpocock-research`", + "one bounded research question", + "one Markdown report", + "decision-relevant conclusions", + "do not start a second research pass automatically", + ] + for marker in outcomes: + assert marker in skill_text + assert marker in workflow_text From 63f9120f22587d9163391904a2b61f456635cbb5 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Sun, 12 Jul 2026 17:33:10 +0200 Subject: [PATCH 06/10] feat: implement Git hook installation script and add delegating hooks for post-commit, post-checkout, and post-merge --- .github/hooks/post-checkout | 24 ++++++ .github/hooks/post-commit | 24 ++++++ .github/hooks/post-merge | 24 ++++++ .github/scripts/install-graphify-hooks.sh | 64 ++++++++++++++++ .../scripts/test_install_graphify_hooks.py | 75 +++++++++++++++++++ 5 files changed, 211 insertions(+) create mode 100755 .github/hooks/post-checkout create mode 100755 .github/hooks/post-commit create mode 100755 .github/hooks/post-merge create mode 100644 tests/github/scripts/test_install_graphify_hooks.py diff --git a/.github/hooks/post-checkout b/.github/hooks/post-checkout new file mode 100755 index 00000000..4495c2ab --- /dev/null +++ b/.github/hooks/post-checkout @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# graphify-hook: managed delegate +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ORIGINAL_HOOK="$SCRIPT_DIR/post-checkout.graphify-original" +original_status=0 +if [ -f "$ORIGINAL_HOOK" ]; then + set +e + if [ -x "$ORIGINAL_HOOK" ]; then + "$ORIGINAL_HOOK" "$@" + else + bash "$ORIGINAL_HOOK" "$@" + fi + original_status=$? + set -e +fi +set +e +"$SCRIPT_DIR/../scripts/graphify-file-change-hook.sh" post-checkout "$@" +delegate_status=$? +set -e +if [ "$original_status" -ne 0 ]; then + exit "$original_status" +fi +exit "$delegate_status" diff --git a/.github/hooks/post-commit b/.github/hooks/post-commit new file mode 100755 index 00000000..28abc3ae --- /dev/null +++ b/.github/hooks/post-commit @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# graphify-hook: managed delegate +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ORIGINAL_HOOK="$SCRIPT_DIR/post-commit.graphify-original" +original_status=0 +if [ -f "$ORIGINAL_HOOK" ]; then + set +e + if [ -x "$ORIGINAL_HOOK" ]; then + "$ORIGINAL_HOOK" "$@" + else + bash "$ORIGINAL_HOOK" "$@" + fi + original_status=$? + set -e +fi +set +e +"$SCRIPT_DIR/../scripts/graphify-file-change-hook.sh" post-commit "$@" +delegate_status=$? +set -e +if [ "$original_status" -ne 0 ]; then + exit "$original_status" +fi +exit "$delegate_status" diff --git a/.github/hooks/post-merge b/.github/hooks/post-merge new file mode 100755 index 00000000..9d7cc7e0 --- /dev/null +++ b/.github/hooks/post-merge @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# graphify-hook: managed delegate +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ORIGINAL_HOOK="$SCRIPT_DIR/post-merge.graphify-original" +original_status=0 +if [ -f "$ORIGINAL_HOOK" ]; then + set +e + if [ -x "$ORIGINAL_HOOK" ]; then + "$ORIGINAL_HOOK" "$@" + else + bash "$ORIGINAL_HOOK" "$@" + fi + original_status=$? + set -e +fi +set +e +"$SCRIPT_DIR/../scripts/graphify-file-change-hook.sh" post-merge "$@" +delegate_status=$? +set -e +if [ "$original_status" -ne 0 ]; then + exit "$original_status" +fi +exit "$delegate_status" diff --git a/.github/scripts/install-graphify-hooks.sh b/.github/scripts/install-graphify-hooks.sh index a3d8741c..53604208 100755 --- a/.github/scripts/install-graphify-hooks.sh +++ b/.github/scripts/install-graphify-hooks.sh @@ -12,4 +12,68 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$REPO_ROOT" git config core.hooksPath .github/hooks +HOOKS_DIR="$REPO_ROOT/.github/hooks" +HOOK_MARKER="# graphify-hook: managed delegate" +mkdir -p "$HOOKS_DIR" + +install_hook() { + local hook_name="$1" + local hook_path="$HOOKS_DIR/$hook_name" + local original_path="$hook_path.graphify-original" + local temporary_path + + if [ -L "$hook_path" ]; then + printf 'Preserved foreign symlink hook: %s\n' "$hook_path" + return 0 + fi + + if [ -f "$hook_path" ] && ! grep -Fq "$HOOK_MARKER" "$hook_path"; then + if [ -e "$original_path" ] || [ -L "$original_path" ]; then + printf 'Preserved foreign hook with existing backup: %s\n' "$hook_path" + return 0 + fi + mv "$hook_path" "$original_path" + fi + + if [ -f "$hook_path" ] && grep -Fq "$HOOK_MARKER" "$hook_path"; then + return 0 + fi + + temporary_path="$(mktemp "$HOOKS_DIR/.${hook_name}.XXXXXX")" + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' "$HOOK_MARKER" + printf '%s\n' 'set -Eeuo pipefail' + printf '%s\n' 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"' + printf 'ORIGINAL_HOOK="$SCRIPT_DIR/%s.graphify-original"\n' "$hook_name" + printf '%s\n' 'original_status=0' + printf '%s\n' 'if [ -f "$ORIGINAL_HOOK" ]; then' + printf '%s\n' ' set +e' + printf '%s\n' ' if [ -x "$ORIGINAL_HOOK" ]; then' + printf '%s\n' ' "$ORIGINAL_HOOK" "$@"' + printf '%s\n' ' else' + printf '%s\n' ' bash "$ORIGINAL_HOOK" "$@"' + printf '%s\n' ' fi' + printf '%s\n' ' original_status=$?' + printf '%s\n' ' set -e' + printf '%s\n' 'fi' + printf '%s\n' 'set +e' + printf '%s ' '"$SCRIPT_DIR/../scripts/graphify-file-change-hook.sh"' + printf '%s ' "$hook_name" + printf '%s\n' '"$@"' + printf '%s\n' 'delegate_status=$?' + printf '%s\n' 'set -e' + printf '%s\n' 'if [ "$original_status" -ne 0 ]; then' + printf '%s\n' ' exit "$original_status"' + printf '%s\n' 'fi' + printf '%s\n' 'exit "$delegate_status"' + } >"$temporary_path" + chmod 755 "$temporary_path" + mv "$temporary_path" "$hook_path" +} + +for hook_name in post-commit post-checkout post-merge; do + install_hook "$hook_name" +done + printf 'Installed Git hooks path: %s\n' '.github/hooks' diff --git a/tests/github/scripts/test_install_graphify_hooks.py b/tests/github/scripts/test_install_graphify_hooks.py new file mode 100644 index 00000000..91aa947b --- /dev/null +++ b/tests/github/scripts/test_install_graphify_hooks.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +INSTALLER = REPOSITORY_ROOT / ".github/scripts/install-graphify-hooks.sh" +DELEGATE = REPOSITORY_ROOT / ".github/scripts/graphify-file-change-hook.sh" +HOOK_NAMES = ("post-commit", "post-checkout", "post-merge") + + +def _copy_graphify_scripts(repository: Path) -> None: + scripts = repository / ".github/scripts" + scripts.mkdir(parents=True) + for source in (INSTALLER, DELEGATE): + target = scripts / source.name + shutil.copy2(source, target) + target.chmod(target.stat().st_mode | 0o111) + + +def _run_installer(repository: Path) -> None: + result = subprocess.run( + [str(repository / ".github/scripts/install-graphify-hooks.sh")], + cwd=repository, + check=False, + text=True, + capture_output=True, + ) + assert result.returncode == 0, result.stderr + + +def test_installer_creates_idempotent_delegating_hooks_and_preserves_foreign_hook( + tmp_path: Path, +) -> None: + repository = tmp_path / "repository" + repository.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repository, check=True) + _copy_graphify_scripts(repository) + + hooks = repository / ".github/hooks" + hooks.mkdir(parents=True) + foreign_hook = hooks / "post-merge" + foreign_hook.write_text("#!/usr/bin/env bash\nprintf 'foreign\n'\n", encoding="utf-8") + foreign_hook.chmod(0o755) + + _run_installer(repository) + first_contents = { + name: (hooks / name).read_text(encoding="utf-8") for name in HOOK_NAMES + } + _run_installer(repository) + + assert subprocess.run( + ["git", "config", "--get", "core.hooksPath"], + cwd=repository, + check=True, + text=True, + capture_output=True, + ).stdout.strip() == ".github/hooks" + + for name in HOOK_NAMES: + hook = hooks / name + assert hook.is_file() + assert os.access(hook, os.X_OK) + contents = hook.read_text(encoding="utf-8") + assert "graphify-hook: managed delegate" in contents + assert "graphify-file-change-hook.sh" in contents + assert contents.count("graphify-hook: managed delegate") == 1 + assert contents == first_contents[name] + + preserved = hooks / "post-merge.graphify-original" + assert preserved.read_text(encoding="utf-8") == "#!/usr/bin/env bash\nprintf 'foreign\n'\n" + From ea97a6adee5323f040b5dd7127f2d240596a6c16 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Sun, 12 Jul 2026 17:49:15 +0200 Subject: [PATCH 07/10] feat: enhance resource linking and management for Copilot agents, update documentation and tests --- .../SKILL.md | 14 +- .../references/error-codes.md | 2 +- .../references/runtime-support-matrix.yaml | 2 +- .../references/sync-contract.md | 27 +-- .../scripts/home_syncing.py | 182 ++++++++++++++---- .../scripts/sync_output.py | 12 +- .../scripts/test_apply_paths.py | 108 +++++++++++ .../scripts/test_contracts.py | 8 + 8 files changed, 291 insertions(+), 64 deletions(-) diff --git a/.github/skills/local-agent-sync-install-ai-resources/SKILL.md b/.github/skills/local-agent-sync-install-ai-resources/SKILL.md index 0c3b8771..e84776a7 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/SKILL.md +++ b/.github/skills/local-agent-sync-install-ai-resources/SKILL.md @@ -14,7 +14,8 @@ bundle directly. - Repository skill bundles are materialized only as absolute links under `~/.agents/skills/`. -- Translated agent resources retain the existing repository-to-home copy path. +- Copilot agents are absolute links back to `.github/agents/`; Codex and + OpenCode agents retain their translated copy paths. - Home-only skills are unmanaged and preserved. This includes catalog-excluded `graphify` and every `local-*` bundle. - Reverse synchronization, reconciliation, and copied-skill fallback are @@ -40,12 +41,17 @@ compatibility entrypoint. - Keep `~/.agents/skills/` a real directory. Never replace the root with a link. +- Keep `~/.copilot/agents/` a real directory. Never replace the root with a + link. - Create one canonical absolute link for every eligible repository skill. +- Create one canonical absolute link for every eligible Copilot agent. +- Migrate a manifest-managed unchanged Copilot copy to its canonical link; + block unmanaged or locally modified copies. - A colliding home directory with an eligible repository skill ID is removed without backup and replaced by that link. - A matching unmanaged link is adopted into the manifest without replacement. - A broken link or a link to another checkout blocks the operation. -- Manifest-v2 stale managed skill links are unlinked automatically; copied +- Manifest-v2 stale managed links are unlinked automatically; copied translated agents retain explicit `--prune-managed` safety. - Unsupported symlink capability blocks the operation. Never copy a skill as a fallback. @@ -65,8 +71,8 @@ compatibility entrypoint. ## Reporting -Use `--format compact` for automation. Reports must summarize linked skills, -unlinked skills, copied agents, unchanged resources, and blockers. Do not list +Use `--format compact` for automation. Reports must summarize linked resources, +unlinked resources, copied translated agents, unchanged resources, and blockers. Do not list all unchanged skills. Translate blocker codes into a plain-language next action; see `references/error-codes.md`. diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md b/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md index 9679e3d6..0febbf0e 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md +++ b/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md @@ -17,7 +17,7 @@ code in an operator report. | `link-target-mismatch` | A home link points to another checkout. | Rerun after correcting the link; do not overwrite it automatically. | | `manifest-missing` | A read-only manifest-backed mode has no prior state. | Treat it as first-run evidence. | | `manifest-corrupt` | The manifest cannot be trusted. | Repair or remove the state before apply. | -| `target-exists-unmanaged` | A copied agent target is unmanaged. | Preserve it or resolve ownership before apply. | +| `target-exists-unmanaged` | A managed resource target is unmanaged. | Preserve it or resolve ownership before apply. | | `target-modified-managed` | A copied agent changed after the recorded hash. | Review the local change before replacing it. | | `source-missing` | A catalog source no longer exists. | Repair the catalog or source. | | `source-invalid-skill` | A repository skill lacks `SKILL.md`. | Repair the source bundle. | diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml b/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml index b95af3dd..c794d5d3 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml +++ b/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml @@ -59,7 +59,7 @@ rows: include_in_v1: true evidence: - https://code.visualstudio.com/docs/copilot/customization/custom-agents - notes: Direct copy of .agent.md files. + notes: .agent.md files are supported as canonical links to repository sources. - target: opencode resource_family: agents support_level: Documented diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md b/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md index 11717e6a..447c7434 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md +++ b/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md @@ -9,7 +9,8 @@ Use this reference for the exact repository-to-home contract. not a second source. - Eligible skills are materialized as one absolute canonical symbolic link per skill. A write through that link changes the repository object directly. -- Allowlisted agents retain their existing translation and copy behavior. +- Copilot agents are canonical links to `.github/agents/`; Codex and OpenCode + agents retain their translated copy behavior. - Never copy, merge, or reconcile home skill content into the repository. - Preserve all home-only skills, including `graphify`, every `local-*` bundle, invalid repository bundles, and every catalog-excluded ID. @@ -28,11 +29,11 @@ State belongs under `~/.sync/cloud-strategy-governance/home-ai-resources/`: - `target`, `resource_family`, `resource_id`, `source_path`, `target_path`, `source_hash`, and `last_action`. -Skill rows have `materialization: symlink`, an absolute canonical -`link_target`, and `content_hash: null`. Agent rows have -`materialization: copy`, `link_target: null`, and a content hash. Schema-v1 -rows are normalized in memory as copied resources and are rewritten only after -a successful apply. +Skill rows and Copilot agent rows have `materialization: symlink`, an absolute +canonical `link_target`, and `content_hash: null`. Codex and OpenCode agent +rows have `materialization: copy`, `link_target: null`, and a content hash. +Schema-v1 rows are normalized in memory as copied resources and are rewritten +only after a successful apply. ## Planning @@ -47,8 +48,11 @@ inspect the direct home child: | Broken link | `blocked` with `link-target-missing` | | Link to another checkout | `blocked` with `link-target-mismatch` | -Skills never use mtime or hashes to choose a winner. Agents retain the -existing hash-based copy and explicit-prune behavior. +Skills never use mtime or hashes to choose a winner. Codex and OpenCode agents +retain the existing hash-based copy and explicit-prune behavior. A +manifest-managed copied Copilot agent is migrated to a link only when its +content still matches the recorded hash; unmanaged or modified copies block +migration. A stale schema-v2 skill link is planned as `unlink` without `--prune-managed`, but only when it is a direct child of the real runtime skill @@ -69,8 +73,9 @@ After those checks, `create_skill_link` may remove a colliding real directory without backup and create the absolute link. `unlink_managed_skill` unlinks the link itself, including a broken link, and never follows its target. -Apply verifies every skill by exact link identity and every copied agent by -its expected hash. It then writes manifest v2. Unsupported link capability is +Apply verifies every skill and Copilot agent by exact link identity, and every +translated copied agent by its expected hash. It then writes manifest v2. +Unsupported link capability is `symlink-unsupported`; do not fall back to copied skills. Moving the repository checkout invalidates canonical link targets. Rerun sync @@ -90,7 +95,7 @@ Do not run against the real home during tests. Use a temporary home root. ## Reporting -Compact and report payloads expose `linked`, `unlinked`, copied agents, +Compact and report payloads expose `linked`, `unlinked`, copied translated agents, skipped resources, blockers, and a bounded path sample. Report counts for unchanged skills rather than enumerating them. Translate every blocker using `error-codes.md` and state the next action. diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py index cef091be..339b8cd0 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py @@ -268,6 +268,9 @@ def build_home_sync_plan( source_path=resource.source_path, target=target, ) + linked_resource = resource.source_family == "skills" or ( + resource.source_family == "agents" and target == "copilot" + ) managed_resource = ManagedResource( target=target, resource_id=resource.resource_id, @@ -275,10 +278,10 @@ def build_home_sync_plan( source_path=resource.source_path, target_path=target_path.as_posix(), source_hash=source_hash, - materialization="symlink" if resource.source_family == "skills" else "copy", - link_target=(source_path.resolve().as_posix() if resource.source_family == "skills" else None), - content_hash=(None if resource.source_family == "skills" else content_hash), - last_action="link" if resource.source_family == "skills" else "copy", + materialization="symlink" if linked_resource else "copy", + link_target=(source_path.resolve().as_posix() if linked_resource else None), + content_hash=(None if linked_resource else content_hash), + last_action="link" if linked_resource else "copy", ) if managed_resource.target_path in seen_target_paths: continue @@ -352,7 +355,7 @@ def canonical_skill_link_target(source_root: Path, source_path: str) -> Path: return (source_root.resolve() / source_path).resolve() -def assess_skill_link(target_path: Path, expected_target: Path) -> tuple[str, str | None]: +def assess_resource_link(target_path: Path, expected_target: Path) -> tuple[str, str | None]: if not os.path.lexists(target_path): return "missing", None if not target_path.is_symlink(): @@ -368,13 +371,22 @@ def assess_skill_link(target_path: Path, expected_target: Path) -> tuple[str, st return "blocked", "link-target-mismatch" -def _verify_skill_target_path( +def assess_skill_link(target_path: Path, expected_target: Path) -> tuple[str, str | None]: + return assess_resource_link(target_path, expected_target) + + +def _verify_link_target_path( *, home_root: Path, target: str, target_path: Path, + resource_family: str = "skills", ) -> str | None: - target_root = runtime_skill_root(home_root, target) + target_root = ( + runtime_agent_root(home_root, target) + if resource_family == "agents" and target == "copilot" + else runtime_skill_root(home_root, target) + ) resolved_home = home_root.resolve() if target_path.parent != target_root: return "unsafe-home-path" @@ -397,10 +409,24 @@ def _verify_skill_target_path( return None -def create_skill_link(source_path: Path, target_path: Path) -> None: +def _verify_skill_target_path( + *, + home_root: Path, + target: str, + target_path: Path, +) -> str | None: + return _verify_link_target_path( + home_root=home_root, + target=target, + target_path=target_path, + resource_family="skills", + ) + + +def create_resource_link(source_path: Path, target_path: Path) -> None: source_path = source_path.resolve() - if not is_valid_skill_bundle(source_path): - raise RuntimeError(f"source-invalid-skill: {source_path}") + if not source_path.exists(): + raise RuntimeError(f"link-target-missing: {source_path}") if os.path.lexists(target_path): if target_path.is_symlink(): target_path.unlink() @@ -408,7 +434,13 @@ def create_skill_link(source_path: Path, target_path: Path) -> None: shutil.rmtree(target_path) else: target_path.unlink() - os.symlink(source_path, target_path, target_is_directory=True) + os.symlink(source_path, target_path, target_is_directory=source_path.is_dir()) + + +def create_skill_link(source_path: Path, target_path: Path) -> None: + if not is_valid_skill_bundle(source_path.resolve()): + raise RuntimeError(f"source-invalid-skill: {source_path}") + create_resource_link(source_path, target_path) def unlink_managed_skill(target_path: Path) -> None: @@ -416,9 +448,9 @@ def unlink_managed_skill(target_path: Path) -> None: target_path.unlink() -def verify_skill_link(source_path: Path, target_path: Path) -> str | None: +def verify_resource_link(source_path: Path, target_path: Path) -> str | None: expected_target = source_path.resolve() - if not is_valid_skill_bundle(expected_target) or not target_path.is_symlink(): + if not expected_target.exists() or not target_path.is_symlink(): return "link-target-missing" if not target_path.exists(): return "link-target-missing" @@ -427,6 +459,12 @@ def verify_skill_link(source_path: Path, target_path: Path) -> str | None: return None +def verify_skill_link(source_path: Path, target_path: Path) -> str | None: + if not is_valid_skill_bundle(source_path.resolve()): + return "link-target-missing" + return verify_resource_link(source_path, target_path) + + def probe_symlink_support(state_root: Path) -> str | None: state_root.mkdir(parents=True, exist_ok=True) probe_path = state_root / ".symlink-probe" @@ -476,27 +514,39 @@ def apply_home_sync_plan( continue target_path = Path(operation.path) if operation.action in {"link", "unlink"}: - skill_path_code = _verify_skill_target_path( + managed_resource = desired_by_path.get(operation.path) + link_family = ( + managed_resource.resource_family + if managed_resource is not None + else ( + "agents" + if operation.target == "copilot" + and target_path.parent == runtime_agent_root(plan.home_root, "copilot") + else "skills" + ) + ) + link_path_code = _verify_link_target_path( home_root=plan.home_root, target=operation.target, target_path=target_path, + resource_family=link_family, ) - if skill_path_code is not None: - raise RuntimeError(f"{skill_path_code}: {target_path}") + if link_path_code is not None: + raise RuntimeError(f"{link_path_code}: {target_path}") if operation.action == "unlink": unlink_managed_skill(target_path) continue - managed_resource = desired_by_path.get(operation.path) - if managed_resource is None or managed_resource.resource_family != "skills": - raise RuntimeError(f"source-invalid-skill: {target_path}") - source_path = canonical_skill_link_target(plan.source_root, managed_resource.source_path) - link_state, link_code = assess_skill_link(target_path, source_path) + if managed_resource is None or managed_resource.materialization != "symlink": + invalid_code = "source-invalid-agent" if operation.target == "copilot" else "source-invalid-skill" + raise RuntimeError(f"{invalid_code}: {target_path}") + source_path = (plan.source_root / managed_resource.source_path).resolve() + link_state, link_code = assess_resource_link(target_path, source_path) if link_state == "blocked": raise RuntimeError(f"{link_code}: {target_path}") if link_state != "matching": - create_skill_link(source_path, target_path) - verification_code = verify_skill_link(source_path, target_path) + create_resource_link(source_path, target_path) + verification_code = verify_resource_link(source_path, target_path) if verification_code is not None: raise RuntimeError(f"{verification_code}: {target_path}") continue @@ -525,9 +575,9 @@ def apply_home_sync_plan( for resource in plan.desired_resources: target_path = Path(resource.target_path) - if resource.resource_family == "skills": - verification_code = verify_skill_link( - canonical_skill_link_target(plan.source_root, resource.source_path), target_path + if resource.materialization == "symlink": + verification_code = verify_resource_link( + (plan.source_root / resource.source_path).resolve(), target_path ) if verification_code is not None: raise RuntimeError(f"{verification_code}: {target_path}") @@ -761,7 +811,7 @@ def add_resource_blockers( reason = { "source-missing": "Catalog entry points to a source path that does not exist. Blocked to avoid materializing a stale or incomplete resource and to surface catalog drift.", "source-invalid-skill": "Source skill bundle is missing SKILL.md. Blocked because a valid repository skill bundle must contain SKILL.md.", - "source-invalid-agent": "Source agent file is missing or not a .md file. Blocked because only allowlisted .agent.md files are eligible for translation.", + "source-invalid-agent": "Source agent file is missing or not a .md file. Blocked because only allowlisted .agent.md files are eligible for linking or translation.", }[code] for target in intersection_targets(resource, targets): add_blocked_operation( @@ -863,16 +913,18 @@ def add_materialization_operation( policy: HomeSyncPolicy, ) -> None: manifest_entry = manifest_index.get(target_path.as_posix()) - if resource.source_family == "skills": + copilot_agent_link = resource.source_family == "agents" and target == "copilot" + linked_resource = resource.source_family == "skills" or copilot_agent_link + if linked_resource: expected_target = source_path.resolve() - link_state, link_code = assess_skill_link(target_path, expected_target) + link_state, link_code = assess_resource_link(target_path, expected_target) if link_state == "blocked": add_blocked_operation( operations, target=target, target_path=target_path, code=link_code or "link-target-mismatch", - reason="Existing home link does not identify the canonical repository skill bundle.", + reason="Existing home link does not identify the canonical repository resource.", resource=resource, ) return @@ -882,21 +934,68 @@ def add_materialization_operation( target=target, action="skip", path=target_path.as_posix(), - reason="Home skill already links to the canonical repository bundle.", + reason="Home resource already links to the canonical repository source.", source_path=resource.source_path, resource_id=resource.resource_id, ) ) return + if copilot_agent_link and link_state == "replace-directory": + if manifest_entry is None: + add_blocked_operation( + operations, + target=target, + target_path=target_path, + code="target-exists-unmanaged", + reason="Existing Copilot agent content is not manifest-managed, so replacing it with a repository link is unsafe.", + resource=resource, + ) + return + if manifest_entry.get("materialization") != "copy": + add_blocked_operation( + operations, + target=target, + target_path=target_path, + code="manifest-corrupt", + reason="Existing linked-resource state does not describe a migratable copied agent.", + resource=resource, + ) + return + current_hash = hash_resource(target_path) + if current_hash != manifest_entry.get("content_hash"): + code = "target-modified-managed" + reason = "Managed copied Copilot agent diverged from the recorded hash; review local edits before converting it to a repository link." + if resource_mtime(target_path) > resource_mtime(source_path): + operations.append( + HomeSyncOperation( + target=target, + action="warning", + path=target_path.as_posix(), + reason=reason, + code=code, + source_path=resource.source_path, + resource_id=resource.resource_id, + ) + ) + else: + add_blocked_operation( + operations, + target=target, + target_path=target_path, + code=code, + reason=reason, + resource=resource, + ) + return operations.append( HomeSyncOperation( target=target, action="link", path=target_path.as_posix(), reason=( - "Create the canonical repository skill link." + "Create the canonical repository resource link." if link_state == "missing" - else "Replace the colliding home directory with the canonical repository skill link." + else "Replace the managed copied resource with the canonical repository link." ), source_path=resource.source_path, resource_id=resource.resource_id, @@ -1029,24 +1128,25 @@ def add_stale_managed_operations( if not isinstance(target_path, str) or target_path in desired_paths: continue - if ( + if item.get("materialization") == "symlink" and ( item.get("resource_family") == "skills" - and item.get("materialization") == "symlink" + or (item.get("resource_family") == "agents" and item.get("target") == "copilot") ): stale_path = Path(target_path) - skill_path_code = _verify_skill_target_path( + link_path_code = _verify_link_target_path( home_root=home_root, target=str(item.get("target", "")), target_path=stale_path, + resource_family=str(item.get("resource_family", "")), ) - if skill_path_code is not None: + if link_path_code is not None: operations.append( HomeSyncOperation( target=str(item.get("target", "")), action="blocked", path=target_path, - reason="Stale managed skill link fails the direct runtime-root confinement check.", - code=skill_path_code, + reason="Stale managed link fails the direct runtime-root confinement check.", + code=link_path_code, resource_id=str(item.get("resource_id", "")), ) ) @@ -1057,7 +1157,7 @@ def add_stale_managed_operations( target=str(item.get("target", "")), action="unlink", path=target_path, - reason="Stale manifest-managed skill link is removed automatically without following its target.", + reason="Stale manifest-managed link is removed automatically without following its target.", resource_id=str(item.get("resource_id", "")), ) ) @@ -1279,7 +1379,7 @@ def _is_valid_v2_manifest_row(item: dict[str, object]) -> bool: return False if not isinstance(target_path, str) or not Path(target_path).is_absolute(): return False - if family == "skills": + if family == "skills" or (family == "agents" and item.get("target") == "copilot"): return ( materialization == "symlink" and isinstance(link_target, str) diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py index f1f87376..4e09ee52 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py @@ -92,9 +92,9 @@ def render_sync_report(payload: dict[str, object]) -> str: *_report_section("Summary"), *_bullet_lines( [ - f"Linked skills: {_count_value(install.get('linked'))}", - f"Unlinked skills: {_count_value(install.get('unlinked'))}", - f"Copied agents: {_count_value(install.get('copied'))}", + f"Linked resources: {_count_value(install.get('linked'))}", + f"Unlinked resources: {_count_value(install.get('unlinked'))}", + f"Copied translated agents: {_count_value(install.get('copied'))}", f"Unchanged resources: {_count_value(install.get('skipped'))}", f"Blockers: {_count_value(install.get('blocked'))}", ] @@ -120,9 +120,9 @@ def render_install_report(payload: dict[str, object]) -> str: *_report_section("Summary"), *_bullet_lines( [ - f"Linked skills: {_count_value(payload.get('linked'))}", - f"Unlinked skills: {_count_value(payload.get('unlinked'))}", - f"Copied agents: {_count_value(payload.get('copied'))}", + f"Linked resources: {_count_value(payload.get('linked'))}", + f"Unlinked resources: {_count_value(payload.get('unlinked'))}", + f"Copied translated agents: {_count_value(payload.get('copied'))}", f"Unchanged resources: {_count_value(payload.get('skipped'))}", f"Blockers: {_count_value(payload.get('blocked'))}", ] diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py index 6766b26f..60a5b657 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py @@ -258,6 +258,114 @@ def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( assert payload["counts"]["residual"] == 0 +def test_copilot_agents_are_symlinked_with_write_through( + tmp_path: Path, capsys +) -> None: + refs = tmp_path / ".github/skills/local-agent-sync-install-ai-resources/references" + refs.mkdir(parents=True) + (refs / "home-sync-catalog.yaml").write_text( + """version: 1 +defaults: + include_internal_skills: true + include_local_skills: false + include_unlisted_skills: true + unmanaged_existing_skills_policy: repo-wins + excluded_skills: [] + skill_targets: [copilot] +resources: + - resource_id: demo-agent + source_family: agents + source_path: .github/agents/demo-agent.agent.md + include_targets: [copilot] + target_support: documented + notes: test agent +""", + encoding="utf-8", + ) + (refs / "runtime-support-matrix.yaml").write_text( + """version: 1 +rows: + - target: copilot + resource_family: agents + support_level: Documented + home_path: ~/.copilot/agents/ + direct_copy_possible: true + translation_required: false + include_in_v1: true + evidence: [] + notes: test +""", + encoding="utf-8", + ) + source = tmp_path / ".github/agents/demo-agent.agent.md" + source.parent.mkdir(parents=True) + source.write_text("---\nname: demo-agent\n---\nRepository agent.\n", encoding="utf-8") + + home = tmp_path / "home" + assert run( + parse_args( + [ + "sync", + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "copilot", + "--create-missing-dirs", + ] + ) + ) == 0 + capsys.readouterr() + + target = home / ".copilot/agents/demo-agent.agent.md" + assert target.is_symlink() + assert target.resolve() == source.resolve() + source.write_text("---\nname: demo-agent\n---\nUpdated in repository.\n", encoding="utf-8") + assert target.read_text(encoding="utf-8") == source.read_text(encoding="utf-8") + target.write_text("---\nname: demo-agent\n---\nUpdated through home.\n", encoding="utf-8") + assert source.read_text(encoding="utf-8") == "---\nname: demo-agent\n---\nUpdated through home.\n" + + +def test_manifest_managed_copilot_copy_migrates_to_link_when_unchanged( + tmp_path: Path, +) -> None: + source = tmp_path / ".github/agents/demo-agent.agent.md" + source.parent.mkdir(parents=True) + source.write_text("Repository agent.\n", encoding="utf-8") + target = tmp_path / "home/.copilot/agents/demo-agent.agent.md" + target.parent.mkdir(parents=True) + target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + resource = CatalogResource( + resource_id="demo-agent", + source_family="agents", + source_path=".github/agents/demo-agent.agent.md", + include_targets=("copilot",), + target_support="documented", + notes="test agent", + ) + operations: list[HomeSyncOperation] = [] + + add_materialization_operation( + operations, + target="copilot", + target_path=target, + source_path=source, + resource=resource, + source_hash="source-hash", + manifest_index={ + target.as_posix(): { + "materialization": "copy", + "content_hash": hash_resource(target), + } + }, + changed_only=False, + policy=_repo_wins_policy(), + ) + + assert [(operation.action, operation.code) for operation in operations] == [("link", None)] + + def _setup_skill_source(tmp_path: Path) -> tuple[Path, str]: skill_dir = tmp_path / ".github" / "skills" / "demo" skill_dir.mkdir(parents=True) diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py index 35eb4767..5b3b1c28 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py @@ -175,6 +175,14 @@ def test_valid_v2_manifest_rows_load_unchanged(tmp_path: Path) -> None: "content_hash": "agent-content", "target_path": (tmp_path / "home/.codex/agents/review.toml").as_posix(), }, + { + "target": "copilot", + "resource_family": "agents", + "materialization": "symlink", + "link_target": (tmp_path / "repo/.github/agents/review.agent.md").as_posix(), + "content_hash": None, + "target_path": (tmp_path / "home/.copilot/agents/review.agent.md").as_posix(), + }, ] path = tmp_path / "manifest.json" path.write_text(json.dumps({"schema_version": 2, "managed_resources": rows}), encoding="utf-8") From 855132fb56b664864da701c89c885392b8c63f0f Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Sun, 12 Jul 2026 18:20:37 +0200 Subject: [PATCH 08/10] feat: update local sync agent and skill descriptions, enhance AGENTS.md handling, and improve error reporting --- .../local-sync-install-ai-resources.agent.md | 6 +- .../SKILL.md | 18 +++- .../agents/openai.yaml | 4 +- .../references/error-codes.md | 1 + .../references/home-sync-catalog.yaml | 7 ++ .../references/runtime-support-matrix.yaml | 10 ++ .../references/sync-contract.md | 8 ++ .../scripts/home_sync_contract.py | 3 +- .../scripts/home_syncing.py | 82 ++++++++++++++-- .../scripts/sync_home_ai_resources.py | 2 +- .../scripts/sync_output.py | 1 + .../scripts/test_apply_paths.py | 94 +++++++++++++++++++ .../scripts/test_contracts.py | 22 ++++- .../scripts/test_sync_output.py | 21 ++++- 14 files changed, 262 insertions(+), 17 deletions(-) diff --git a/.github/agents/local-sync-install-ai-resources.agent.md b/.github/agents/local-sync-install-ai-resources.agent.md index e6c45958..e9501289 100644 --- a/.github/agents/local-sync-install-ai-resources.agent.md +++ b/.github/agents/local-sync-install-ai-resources.agent.md @@ -1,6 +1,6 @@ --- name: local-sync-install-ai-resources -description: Use this agent when planning, auditing, or applying allowlisted home-directory sync of repository-owned AI runtime resources to local Codex, Copilot, or OpenCode targets. +description: Use this agent when planning, auditing, or applying repository-owned AI resources or the portable AGENTS.md baseline to local home runtimes. tools: ["read", "edit", "search", "execute"] disable-model-invocation: true agents: [] @@ -11,3 +11,7 @@ agents: [] ## Core Skill - `local-agent-sync-install-ai-resources` + +When the user names `agents.md`, `agents.md` means `sync --targets agents.md`. +This updates `~/.agents/AGENTS.md` from root `AGENTS.md` without the +`` block. diff --git a/.github/skills/local-agent-sync-install-ai-resources/SKILL.md b/.github/skills/local-agent-sync-install-ai-resources/SKILL.md index e84776a7..1a4d7573 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/SKILL.md +++ b/.github/skills/local-agent-sync-install-ai-resources/SKILL.md @@ -1,10 +1,14 @@ --- name: local-agent-sync-install-ai-resources -description: Use when planning, auditing, or applying allowlisted repository-owned AI resources to local Codex, Copilot, or OpenCode runtimes. +description: Use when planning, auditing, or applying repository-owned AI resources or the portable AGENTS.md baseline to local home runtimes. --- # Local Agent Sync Home AI Resources +## Referenced skills + +- None. + Use this skill as the operating engine for `.github/agents/local-sync-install-ai-resources.agent.md`. The repository is the only source of truth for managed resources. Home is a runtime projection: a write through a managed skill link writes the repository @@ -14,6 +18,8 @@ bundle directly. - Repository skill bundles are materialized only as absolute links under `~/.agents/skills/`. +- Root `AGENTS.md` is projected to `~/.agents/AGENTS.md` as a managed copy with + the complete `` block removed. - Copilot agents are absolute links back to `.github/agents/`; Codex and OpenCode agents retain their translated copy paths. - Home-only skills are unmanaged and preserved. This includes catalog-excluded @@ -27,6 +33,7 @@ Use `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh`. | Request | Command | | --- | --- | +| Update the global `AGENTS.md` baseline | `sync --targets agents.md` | | Default repository-to-home sync | `sync --targets skills` | | Dry review | `plan --targets skills` | | Explicit materialization | `apply --targets skills` | @@ -37,10 +44,16 @@ Use `.github/skills/local-agent-sync-install-ai-resources/scripts/run.sh`. `./.github/scripts/run.sh sync_home_ai_resources ...` remains a delegating compatibility entrypoint. +When the user calls this skill with an `agents.md` request, `agents.md` means `sync --targets agents.md`. +Accept `agents-md` as a CLI alias for the same target. + ## Operating Contract - Keep `~/.agents/skills/` a real directory. Never replace the root with a link. +- Treat repository root `AGENTS.md` as the only source of truth for the managed + `~/.agents/AGENTS.md` projection. Adopt and overwrite an unmanaged target + file, but never include `` in the result. - Keep `~/.copilot/agents/` a real directory. Never replace the root with a link. - Create one canonical absolute link for every eligible repository skill. @@ -62,7 +75,8 @@ compatibility entrypoint. ## Mode Selection -- `sync` may auto-apply clean repository-to-home work. It stops for blockers, +- `sync` may auto-apply clean repository-to-home work, including `agents.md`. + It stops for blockers, missing-directory approval, or copied-agent prune gates. - `plan` and `audit` are read-only. - `apply` needs an explicit request; `--create-missing-dirs` and diff --git a/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml b/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml index cddf9557..52c39581 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml +++ b/.github/skills/local-agent-sync-install-ai-resources/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Home AI Resource Sync" - short_description: "Plan, apply, audit, or doctor repository-owned AI home sync" - default_prompt: "Use $local-agent-sync-install-ai-resources for repository-to-home sync. Repository skills use managed absolute links and agents remain copied translations; never sync home content into the repository. Default to compact output, require explicit approval for apply, and explain each blocker in plain language." + short_description: "Sync AI resources and the portable AGENTS.md baseline to home" + default_prompt: "Use $local-agent-sync-install-ai-resources for repository-to-home sync. Treat an agents.md request as sync --targets agents.md, generating ~/.agents/AGENTS.md from root AGENTS.md without standards-repository-local-rules. Repository skills use managed absolute links; never sync home content into the repository. Default to compact output, require explicit approval for apply, and explain each blocker in plain language." diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md b/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md index 0febbf0e..5ae2e8de 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md +++ b/.github/skills/local-agent-sync-install-ai-resources/references/error-codes.md @@ -21,6 +21,7 @@ code in an operator report. | `target-modified-managed` | A copied agent changed after the recorded hash. | Review the local change before replacing it. | | `source-missing` | A catalog source no longer exists. | Repair the catalog or source. | | `source-invalid-skill` | A repository skill lacks `SKILL.md`. | Repair the source bundle. | +| `source-invalid-agents-md` | Root `AGENTS.md` lacks the ordered shared and repository-local policy blocks required for safe projection. | Restore both blocks before updating `~/.agents/AGENTS.md`. | | `stale-managed` | A copied managed resource is no longer planned. | Review and use explicit `--prune-managed` if appropriate. | | `prune-not-approved` | Copied-resource pruning needs explicit approval. | Rerun apply with `--prune-managed`. | | `stale-content-drifted` | A stale copied resource changed locally. | Review it before deletion. | diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/home-sync-catalog.yaml b/.github/skills/local-agent-sync-install-ai-resources/references/home-sync-catalog.yaml index 87745e78..d6f1ee28 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/home-sync-catalog.yaml +++ b/.github/skills/local-agent-sync-install-ai-resources/references/home-sync-catalog.yaml @@ -11,6 +11,13 @@ defaults: - copilot - opencode resources: + - resource_id: global-agents + source_family: agents-md + source_path: AGENTS.md + include_targets: + - agents.md + target_support: See runtime support matrix + notes: Portable global baseline generated without standards-repository-local-rules. - resource_id: internal-gateway-idea source_family: agents source_path: .github/agents/internal-gateway-idea.agent.md diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml b/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml index c794d5d3..1caeddfd 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml +++ b/.github/skills/local-agent-sync-install-ai-resources/references/runtime-support-matrix.yaml @@ -1,5 +1,15 @@ version: 1 rows: + - target: agents.md + resource_family: agents-md + support_level: Documented + home_path: ~/.agents/AGENTS.md + direct_copy_possible: true + translation_required: false + include_in_v1: true + evidence: + - Root AGENTS.md declares the shared baseline as the source for ~/.agents/AGENTS.md. + notes: Copy the portable root policy projection after removing standards-repository-local-rules. - target: skills resource_family: skills support_level: Documented diff --git a/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md b/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md index 447c7434..80b16436 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md +++ b/.github/skills/local-agent-sync-install-ai-resources/references/sync-contract.md @@ -5,6 +5,8 @@ Use this reference for the exact repository-to-home contract. ## Scope And Ownership - `.github/skills/` is the sole source of truth for managed skill bundles. +- Root `AGENTS.md` is the sole source of truth for the managed global + `~/.agents/AGENTS.md` baseline. - `~/.agents/skills/` remains a real directory. It is a runtime projection, not a second source. - Eligible skills are materialized as one absolute canonical symbolic link per @@ -14,6 +16,9 @@ Use this reference for the exact repository-to-home contract. - Never copy, merge, or reconcile home skill content into the repository. - Preserve all home-only skills, including `graphify`, every `local-*` bundle, invalid repository bundles, and every catalog-excluded ID. +- For the `agents.md` target, remove the complete + `` block and preserve the rest of root + `AGENTS.md`. Adopt and overwrite an unmanaged home copy. ## State And Manifest @@ -32,6 +37,8 @@ State belongs under `~/.sync/cloud-strategy-governance/home-ai-resources/`: Skill rows and Copilot agent rows have `materialization: symlink`, an absolute canonical `link_target`, and `content_hash: null`. Codex and OpenCode agent rows have `materialization: copy`, `link_target: null`, and a content hash. +The `agents-md` row is also a managed copy. Its source and content hashes +describe the rendered portable projection, excluding repository-local policy. Schema-v1 rows are normalized in memory as copied resources and are rewritten only after a successful apply. @@ -75,6 +82,7 @@ the link itself, including a broken link, and never follows its target. Apply verifies every skill and Copilot agent by exact link identity, and every translated copied agent by its expected hash. It then writes manifest v2. +The global `AGENTS.md` copy is verified against its rendered portable hash. Unsupported link capability is `symlink-unsupported`; do not fall back to copied skills. diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_sync_contract.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_sync_contract.py index 2e2c8b86..183c5865 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_sync_contract.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_sync_contract.py @@ -10,8 +10,9 @@ RUNTIME_SUPPORT_MATRIX_PATH = Path("references/runtime-support-matrix.yaml") HOME_SYNC_CATALOG_PATH = Path("references/home-sync-catalog.yaml") STATE_ROOT_RELATIVE = Path(".sync/cloud-strategy-governance/home-ai-resources") -TARGET_ORDER = ("skills", "codex", "copilot", "opencode") +TARGET_ORDER = ("agents.md", "skills", "codex", "copilot", "opencode") TARGET_SKILL_ROOTS = { + "agents.md": Path(".agents"), "skills": Path(".agents/skills"), "codex": Path(".agents/skills"), "copilot": Path(".agents/skills"), diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py index 339b8cd0..c540dad0 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/home_syncing.py @@ -42,6 +42,12 @@ LOCK_PATH = "locks/home-ai-resources.lock" NORMALIZATION_VERSION = "v1" TEXT_EXTENSIONS = (".md", ".txt", ".yml", ".yaml", ".json", ".sh", ".py") +AGENTS_MD_FAMILY = "agents-md" +AGENTS_MD_TARGET = "agents.md" +SHARED_BASELINE_START = "``" +SHARED_BASELINE_END = "``" +LOCAL_RULES_START = "``" +LOCAL_RULES_END = "``" @dataclass(frozen=True) @@ -162,6 +168,7 @@ def to_dict(self) -> dict[str, object]: CROSS_ALIASES = {"cross", "all", "tutto"} +TARGET_ALIASES = {"agents-md": AGENTS_MD_TARGET} def parse_targets(raw_targets: str) -> tuple[str, ...]: normalized = [part.strip().lower() for part in raw_targets.split(",") if part.strip()] @@ -170,7 +177,7 @@ def parse_targets(raw_targets: str) -> tuple[str, ...]: if len(normalized) == 1 and normalized[0] in CROSS_ALIASES: return TARGET_ORDER - requested = set(normalized) + requested = {TARGET_ALIASES.get(target, target) for target in normalized} unknown = requested.difference(TARGET_ORDER) if unknown: invalid = ", ".join(sorted(unknown)) @@ -234,7 +241,11 @@ def build_home_sync_plan( add_resource_blockers(operations, home_root, resource, targets, resource_block_code(resource.source_family)) continue - source_hash = hash_resource(source_path) + source_hash = ( + hash_portable_agents_md(source_path) + if resource.source_family == AGENTS_MD_FAMILY + else hash_resource(source_path) + ) for target in intersection_targets(resource, targets): target_path = resource_target_path(home_root, resource, target) support_row = resolve_support_row(runtime_rows, target, resource.source_family) @@ -566,7 +577,9 @@ def apply_home_sync_plan( if managed_resource.resource_family == "skills": raise RuntimeError(f"source-invalid-skill: copied skill fallback is not supported: {target_path}") source_path = plan.source_root / managed_resource.source_path - if managed_resource.resource_family == "agents" and managed_resource.target != "copilot": + if managed_resource.resource_family == AGENTS_MD_FAMILY: + _apply_portable_agents_md(source_path, target_path) + elif managed_resource.resource_family == "agents" and managed_resource.target != "copilot": _apply_translated_agent(source_path, target_path, managed_resource.target) else: copy_resource(source_path, target_path) @@ -651,7 +664,8 @@ def run_doctor( for target in targets: target_root = runtime_skill_root(home_root, target) add_doctor_target_check(checks, blocked_codes, target, target_root) - support_row = resolve_support_row(runtime_rows, target, "skills") + primary_family = AGENTS_MD_FAMILY if target == AGENTS_MD_TARGET else "skills" + support_row = resolve_support_row(runtime_rows, target, primary_family) add_doctor_support_check(checks, blocked_codes, target, target_root, support_row, experimental_targets) if has_agent_root(target): agent_root = runtime_agent_root(home_root, target) @@ -812,6 +826,7 @@ def add_resource_blockers( "source-missing": "Catalog entry points to a source path that does not exist. Blocked to avoid materializing a stale or incomplete resource and to surface catalog drift.", "source-invalid-skill": "Source skill bundle is missing SKILL.md. Blocked because a valid repository skill bundle must contain SKILL.md.", "source-invalid-agent": "Source agent file is missing or not a .md file. Blocked because only allowlisted .agent.md files are eligible for linking or translation.", + "source-invalid-agents-md": "Root AGENTS.md is missing the portable shared baseline or the repository-local rules block. Blocked because the global projection must remove repository-only policy deterministically.", }[code] for target in intersection_targets(resource, targets): add_blocked_operation( @@ -1006,7 +1021,7 @@ def add_materialization_operation( if target_path.exists(): current_hash = hash_resource(target_path) if manifest_entry is None: - if ( + if resource.source_family == AGENTS_MD_FAMILY or ( resource.source_family == "skills" and policy.unmanaged_existing_skills_policy == "repo-wins" ): @@ -1015,7 +1030,11 @@ def add_materialization_operation( target=target, action="copy", path=target_path.as_posix(), - reason="Target already exists in home but is not manifest-managed. Repo wins for unmanaged skills under the active catalog policy, so sync adopts the home path and overwrites it with the repository bundle.", + reason=( + "Repository AGENTS.md is the source of truth for the global baseline, so sync adopts and overwrites the unmanaged home projection." + if resource.source_family == AGENTS_MD_FAMILY + else "Target already exists in home but is not manifest-managed. Repo wins for unmanaged skills under the active catalog policy, so sync adopts the home path and overwrites it with the repository bundle." + ), source_path=resource.source_path, resource_id=resource.resource_id, ) @@ -1241,7 +1260,11 @@ def _stale_confinement_check( target_name = str(item.get("target", "")) resource_family = str(item.get("resource_family", "")) - if resource_family == "agents": + if resource_family == AGENTS_MD_FAMILY: + expected_path = (home_root / ".agents" / "AGENTS.md").resolve() + if resolved_stale != expected_path: + return "unsafe-home-path" + elif resource_family == "agents": if not has_agent_root(target_name): return "unsafe-home-path" expected_root = runtime_agent_root(home_root, target_name).resolve() @@ -1317,7 +1340,7 @@ def add_doctor_support_check( "name": f"support:{target}", "status": "blocked", "path": target_root.as_posix(), - "reason": "The selected target does not support skills.", + "reason": "The selected target does not support the requested resource family.", "code": "unsupported-family", } ) @@ -1387,6 +1410,8 @@ def _is_valid_v2_manifest_row(item: dict[str, object]) -> bool: and Path(link_target).resolve().as_posix() == link_target and content_hash is None ) + if family == AGENTS_MD_FAMILY: + return materialization == "copy" and link_target is None and isinstance(content_hash, str) if family == "agents": return materialization == "copy" and link_target is None and isinstance(content_hash, str) return False @@ -1415,6 +1440,12 @@ def is_valid_skill_bundle(path: Path) -> bool: def is_valid_resource(path: Path, source_family: str) -> bool: + if source_family == AGENTS_MD_FAMILY: + try: + render_portable_agents_md(path) + except ValueError: + return False + return True if source_family == "agents": return path.is_file() and path.suffix == ".md" return is_valid_skill_bundle(path) @@ -1425,6 +1456,10 @@ def resource_target_path( resource: CatalogResource, target: str, ) -> Path: + if resource.source_family == AGENTS_MD_FAMILY: + if target != AGENTS_MD_TARGET: + raise ValueError(f"Target {target} does not support AGENTS.md") + return home_root / ".agents" / "AGENTS.md" if resource.source_family == "agents": if not has_agent_root(target): raise ValueError(f"Target {target} does not support agents") @@ -1435,6 +1470,8 @@ def resource_target_path( def resource_block_code(source_family: str) -> str: + if source_family == AGENTS_MD_FAMILY: + return "source-invalid-agents-md" if source_family == "agents": return "source-invalid-agent" return "source-invalid-skill" @@ -1448,6 +1485,30 @@ def hash_resource(path: Path) -> str: return sha256_bytes(json.dumps(fingerprints, sort_keys=True).encode("utf-8")) +def render_portable_agents_md(source_path: Path) -> str: + if not source_path.is_file(): + raise ValueError("source-invalid-agents-md: root AGENTS.md is missing") + source = source_path.read_text(encoding="utf-8") + shared_start = source.find(SHARED_BASELINE_START) + shared_end = source.find(SHARED_BASELINE_END, shared_start + 1) + local_start = source.find(LOCAL_RULES_START) + local_end = source.find(LOCAL_RULES_END, local_start + 1) + if not (0 <= shared_start < shared_end < local_start < local_end): + raise ValueError( + "source-invalid-agents-md: expected ordered shared-baseline and standards-repository-local-rules blocks" + ) + + prefix = source[:local_start].rstrip() + suffix = source[local_end + len(LOCAL_RULES_END) :].strip() + if suffix: + return f"{prefix}\n\n{suffix}\n" + return f"{prefix}\n" + + +def hash_portable_agents_md(source_path: Path) -> str: + return sha256_bytes(render_portable_agents_md(source_path).encode("utf-8")) + + def build_fingerprint(root: Path, file_path: Path) -> dict[str, object]: relative_path = file_path.relative_to(root).as_posix() raw_bytes = file_path.read_bytes() @@ -1530,6 +1591,11 @@ def _apply_translated_agent( target_path.write_text(translated, encoding="utf-8") +def _apply_portable_agents_md(source_path: Path, target_path: Path) -> None: + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(render_portable_agents_md(source_path), encoding="utf-8") + + def write_snapshot(plan: HomeSyncPlan, relative_path: str) -> Path: ensure_state_root(plan.state_root) write_lock_file(plan.state_root) diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py index 9f8370fd..f5570673 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_home_ai_resources.py @@ -48,7 +48,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: cmd_parser.add_argument( "--targets", default="skills", - help="Target runtimes: skills, codex, copilot, opencode, comma-separated combinations, or cross/all/tutto.", + help="Targets: agents.md, skills, codex, copilot, opencode, comma-separated combinations, or cross/all/tutto. agents-md aliases agents.md.", ) if cmd != "doctor": cmd_parser.add_argument( diff --git a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py index 4e09ee52..8743baed 100644 --- a/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py +++ b/.github/skills/local-agent-sync-install-ai-resources/scripts/sync_output.py @@ -18,6 +18,7 @@ "target-modified-managed": "Manifest-managed content diverged from the recorded hash.", "source-missing": "A catalog source path does not exist.", "source-invalid-skill": "A skill bundle is missing required files.", + "source-invalid-agents-md": "Root AGENTS.md cannot produce the portable global baseline.", "stale-managed": "A previously managed resource is stale and pending prune review.", "prune-not-approved": "Prune was not approved for stale managed resources.", "stale-content-drifted": "A stale managed resource drifted and cannot be removed safely.", diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py index 60a5b657..6910727b 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py @@ -258,6 +258,100 @@ def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( assert payload["counts"]["residual"] == 0 +def test_agents_md_sync_removes_repository_local_rules_and_overwrites_home_copy( + tmp_path: Path, capsys +) -> None: + refs = tmp_path / ".github/skills/local-agent-sync-install-ai-resources/references" + refs.mkdir(parents=True) + (refs / "home-sync-catalog.yaml").write_text( + """version: 1 +defaults: + include_internal_skills: false + include_local_skills: false + include_unlisted_skills: false + unmanaged_existing_skills_policy: repo-wins + excluded_skills: [] + skill_targets: [] +resources: + - resource_id: global-agents + source_family: agents-md + source_path: AGENTS.md + include_targets: [agents.md] + target_support: documented + notes: Portable global agent baseline. +""", + encoding="utf-8", + ) + (refs / "runtime-support-matrix.yaml").write_text( + """version: 1 +rows: + - target: agents.md + resource_family: agents-md + support_level: Documented + home_path: ~/.agents/AGENTS.md + direct_copy_possible: true + translation_required: false + include_in_v1: true + evidence: [] + notes: Portable global agent baseline. +""", + encoding="utf-8", + ) + source = tmp_path / "AGENTS.md" + source.write_text( + """# Global agent policy + +`` + +Shared policy. + +`` + +`` + +Repository-only policy. + +`` +""", + encoding="utf-8", + ) + home = tmp_path / "home" + target = home / ".agents/AGENTS.md" + target.parent.mkdir(parents=True) + target.write_text("old local policy\n", encoding="utf-8") + + assert run( + parse_args( + [ + "sync", + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "agents.md", + ] + ) + ) == 0 + capsys.readouterr() + + assert target.read_text(encoding="utf-8") == """# Global agent policy + +`` + +Shared policy. + +`` +""" + manifest = json.loads( + (home / ".sync/cloud-strategy-governance/home-ai-resources/manifest.json").read_text( + encoding="utf-8" + ) + ) + assert manifest["managed_resources"][0]["target"] == "agents.md" + assert manifest["managed_resources"][0]["resource_family"] == "agents-md" + + def test_copilot_agents_are_symlinked_with_write_through( tmp_path: Path, capsys ) -> None: diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py index 5b3b1c28..ab111ad9 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py @@ -321,12 +321,32 @@ def test_load_home_sync_catalog_autodiscovers_skills_and_honors_policy( def test_parse_targets_orders_cross_aliases_and_rejects_unknown() -> None: assert parse_targets("copilot,skills") == ("skills", "copilot") - assert parse_targets("tutto") == ("skills", "codex", "copilot", "opencode") + assert parse_targets("agents-md") == ("agents.md",) + assert parse_targets("tutto") == ( + "agents.md", + "skills", + "codex", + "copilot", + "opencode", + ) with pytest.raises(ValueError, match="unknown-target: invalid"): parse_targets("skills,invalid") +def test_agents_md_request_routes_skill_and_agent_to_global_baseline_sync() -> None: + skill_text = ( + REPO_ROOT / ".github/skills/local-agent-sync-install-ai-resources/SKILL.md" + ).read_text(encoding="utf-8") + agent_text = ( + REPO_ROOT / ".github/agents/local-sync-install-ai-resources.agent.md" + ).read_text(encoding="utf-8") + + routing_contract = "`agents.md` means `sync --targets agents.md`" + assert routing_contract in skill_text + assert routing_contract in agent_text + + def test_render_install_report_omits_empty_sections_and_uses_emoji_headings() -> None: report = render_install_report( { diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_sync_output.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_sync_output.py index dda335fd..2c74c704 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_sync_output.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_sync_output.py @@ -10,7 +10,11 @@ SCRIPT_DIR = REPO_ROOT / ".github/skills/local-agent-sync-install-ai-resources/scripts" sys.path.insert(0, SCRIPT_DIR.as_posix()) -from sync_output import build_compact_install_output, dump_compact_json # noqa: E402 +from sync_output import ( # noqa: E402 + build_compact_install_output, + dump_compact_json, + render_doctor_report, +) def test_compact_install_output_is_single_line_and_bounded() -> None: @@ -58,3 +62,18 @@ def test_compact_install_output_is_single_line_and_bounded() -> None: "resource": "internal-two", }, ] + + +def test_agents_md_source_blocker_has_specific_operator_guidance() -> None: + report = render_doctor_report( + { + "selected_targets": ["agents.md"], + "validation": "blocked", + "checks": [], + "blocked_codes": ["source-invalid-agents-md"], + "next_action": {"action": "resolve_blockers"}, + } + ) + + assert "Root AGENTS.md cannot produce the portable global baseline." in report + assert "Manual review required." not in report From a559c1fa3917cda3a3eecad8628a1e8cb53d0830 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Mon, 20 Jul 2026 22:57:02 +0200 Subject: [PATCH 09/10] feat: enhance user-facing reporting guidelines for clarity and conciseness in AGENTS.md --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 52ba08b4..91e59ae5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,10 @@ This block is the portable source baseline used to generate ## Delivery And Validation +- Be extremely concise in user-facing reporting without sacrificing clarity, + correctness, safety, required evidence, or actionable next steps. Lead with + the outcome, omit repetition and incidental process detail, and expand only + when requested or necessary. - Reason from repository evidence. Do not invent runtimes, validators, sync flows, or tests. - For non-trivial work, make target state, anti-scope, assumptions, tradeoffs, From e30ed6d06efd95202bd4502bfa52302758c534a1 Mon Sep 17 00:00:00 2001 From: diegoitaliait Date: Tue, 21 Jul 2026 10:24:53 +0200 Subject: [PATCH 10/10] feat: refactor test scripts for improved readability and consistency in formatting --- .../scripts/test_install_graphify_hooks.py | 28 +-- .../scripts/test_apply_paths.py | 165 +++++++++++++----- .../scripts/test_contracts.py | 16 +- 3 files changed, 150 insertions(+), 59 deletions(-) diff --git a/tests/github/scripts/test_install_graphify_hooks.py b/tests/github/scripts/test_install_graphify_hooks.py index 91aa947b..a8456934 100644 --- a/tests/github/scripts/test_install_graphify_hooks.py +++ b/tests/github/scripts/test_install_graphify_hooks.py @@ -5,7 +5,6 @@ import subprocess from pathlib import Path - REPOSITORY_ROOT = Path(__file__).resolve().parents[3] INSTALLER = REPOSITORY_ROOT / ".github/scripts/install-graphify-hooks.sh" DELEGATE = REPOSITORY_ROOT / ".github/scripts/graphify-file-change-hook.sh" @@ -43,7 +42,9 @@ def test_installer_creates_idempotent_delegating_hooks_and_preserves_foreign_hoo hooks = repository / ".github/hooks" hooks.mkdir(parents=True) foreign_hook = hooks / "post-merge" - foreign_hook.write_text("#!/usr/bin/env bash\nprintf 'foreign\n'\n", encoding="utf-8") + foreign_hook.write_text( + "#!/usr/bin/env bash\nprintf 'foreign\n'\n", encoding="utf-8" + ) foreign_hook.chmod(0o755) _run_installer(repository) @@ -52,13 +53,16 @@ def test_installer_creates_idempotent_delegating_hooks_and_preserves_foreign_hoo } _run_installer(repository) - assert subprocess.run( - ["git", "config", "--get", "core.hooksPath"], - cwd=repository, - check=True, - text=True, - capture_output=True, - ).stdout.strip() == ".github/hooks" + assert ( + subprocess.run( + ["git", "config", "--get", "core.hooksPath"], + cwd=repository, + check=True, + text=True, + capture_output=True, + ).stdout.strip() + == ".github/hooks" + ) for name in HOOK_NAMES: hook = hooks / name @@ -71,5 +75,7 @@ def test_installer_creates_idempotent_delegating_hooks_and_preserves_foreign_hoo assert contents == first_contents[name] preserved = hooks / "post-merge.graphify-original" - assert preserved.read_text(encoding="utf-8") == "#!/usr/bin/env bash\nprintf 'foreign\n'\n" - + assert ( + preserved.read_text(encoding="utf-8") + == "#!/usr/bin/env bash\nprintf 'foreign\n'\n" + ) diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py index 6910727b..66030165 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_apply_paths.py @@ -10,6 +10,7 @@ SCRIPT_DIR = REPO_ROOT / ".github/skills/local-agent-sync-install-ai-resources/scripts" sys.path.insert(0, SCRIPT_DIR.as_posix()) +from home_sync_contract import CatalogResource, HomeSyncPolicy # noqa: E402 from home_syncing import ( # noqa: E402 HomeSyncOperation, HomeSyncPlan, @@ -17,12 +18,11 @@ _stale_confinement_check, add_materialization_operation, add_stale_managed_operations, - assess_skill_link, apply_home_sync_plan, + assess_skill_link, canonical_skill_link_target, hash_resource, ) -from home_sync_contract import CatalogResource, HomeSyncPolicy # noqa: E402 from sync_home_ai_resources import parse_args, run # noqa: E402 @@ -111,7 +111,9 @@ def test_skill_planning_uses_link_and_adopts_matching_link(tmp_path: Path) -> No assert [operation.action for operation in operations] == ["skip"] -def test_stale_manifest_skill_link_is_unlinked_without_prune_flag(tmp_path: Path) -> None: +def test_stale_manifest_skill_link_is_unlinked_without_prune_flag( + tmp_path: Path, +) -> None: target = tmp_path / "home/.agents/skills/old" target.parent.mkdir(parents=True) source = tmp_path / "repo/.github/skills/old" @@ -148,7 +150,9 @@ def test_stale_manifest_skill_link_is_unlinked_without_prune_flag(tmp_path: Path _repo_wins_policy(), ) - assert [(operation.action, operation.code) for operation in operations] == [("unlink", None)] + assert [(operation.action, operation.code) for operation in operations] == [ + ("unlink", None) + ] def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( @@ -217,7 +221,9 @@ def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( (skill / "SKILL.md").write_text(f"# {skill_id}\n", encoding="utf-8") agent = tmp_path / ".github/agents/demo-agent.agent.md" agent.parent.mkdir(parents=True) - agent.write_text("---\nname: demo-agent\ndescription: test\n---\nTest agent.\n", encoding="utf-8") + agent.write_text( + "---\nname: demo-agent\ndescription: test\n---\nTest agent.\n", encoding="utf-8" + ) home = tmp_path / "home" divergent = home / ".agents/skills/demo" @@ -228,7 +234,23 @@ def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( skill.mkdir(parents=True, exist_ok=True) (skill / "SKILL.md").write_text(f"# {skill_id}\n", encoding="utf-8") - assert run(parse_args(["sync", "--source-root", str(tmp_path), "--home-root", str(home), "--targets", "skills,codex", "--create-missing-dirs"])) == 0 + assert ( + run( + parse_args( + [ + "sync", + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "skills,codex", + "--create-missing-dirs", + ] + ) + ) + == 0 + ) target_skill = home / ".agents/skills/demo" assert target_skill.is_symlink() @@ -244,12 +266,43 @@ def test_temporary_home_sync_links_skills_preserves_home_only_and_copies_agents( import shutil shutil.rmtree(source_skill) - assert run(parse_args(["sync", "--source-root", str(tmp_path), "--home-root", str(home), "--targets", "skills,codex", "--create-missing-dirs"])) == 0 + assert ( + run( + parse_args( + [ + "sync", + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "skills,codex", + "--create-missing-dirs", + ] + ) + ) + == 0 + ) capsys.readouterr() assert not target_skill.exists() assert not target_skill.is_symlink() for mode in ("plan", "audit", "doctor"): - assert run(parse_args([mode, "--source-root", str(tmp_path), "--home-root", str(home), "--targets", "skills,codex"])) == 0 + assert ( + run( + parse_args( + [ + mode, + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "skills,codex", + ] + ) + ) + == 0 + ) payload = json.loads(capsys.readouterr().out) assert payload.get("blocked_codes", []) == [] assert payload["counts"]["linked"] == 0 @@ -320,22 +373,27 @@ def test_agents_md_sync_removes_repository_local_rules_and_overwrites_home_copy( target.parent.mkdir(parents=True) target.write_text("old local policy\n", encoding="utf-8") - assert run( - parse_args( - [ - "sync", - "--source-root", - str(tmp_path), - "--home-root", - str(home), - "--targets", - "agents.md", - ] + assert ( + run( + parse_args( + [ + "sync", + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "agents.md", + ] + ) ) - ) == 0 + == 0 + ) capsys.readouterr() - assert target.read_text(encoding="utf-8") == """# Global agent policy + assert ( + target.read_text(encoding="utf-8") + == """# Global agent policy `` @@ -343,10 +401,11 @@ def test_agents_md_sync_removes_repository_local_rules_and_overwrites_home_copy( `` """ + ) manifest = json.loads( - (home / ".sync/cloud-strategy-governance/home-ai-resources/manifest.json").read_text( - encoding="utf-8" - ) + ( + home / ".sync/cloud-strategy-governance/home-ai-resources/manifest.json" + ).read_text(encoding="utf-8") ) assert manifest["managed_resources"][0]["target"] == "agents.md" assert manifest["managed_resources"][0]["resource_family"] == "agents-md" @@ -393,32 +452,44 @@ def test_copilot_agents_are_symlinked_with_write_through( ) source = tmp_path / ".github/agents/demo-agent.agent.md" source.parent.mkdir(parents=True) - source.write_text("---\nname: demo-agent\n---\nRepository agent.\n", encoding="utf-8") + source.write_text( + "---\nname: demo-agent\n---\nRepository agent.\n", encoding="utf-8" + ) home = tmp_path / "home" - assert run( - parse_args( - [ - "sync", - "--source-root", - str(tmp_path), - "--home-root", - str(home), - "--targets", - "copilot", - "--create-missing-dirs", - ] + assert ( + run( + parse_args( + [ + "sync", + "--source-root", + str(tmp_path), + "--home-root", + str(home), + "--targets", + "copilot", + "--create-missing-dirs", + ] + ) ) - ) == 0 + == 0 + ) capsys.readouterr() target = home / ".copilot/agents/demo-agent.agent.md" assert target.is_symlink() assert target.resolve() == source.resolve() - source.write_text("---\nname: demo-agent\n---\nUpdated in repository.\n", encoding="utf-8") + source.write_text( + "---\nname: demo-agent\n---\nUpdated in repository.\n", encoding="utf-8" + ) assert target.read_text(encoding="utf-8") == source.read_text(encoding="utf-8") - target.write_text("---\nname: demo-agent\n---\nUpdated through home.\n", encoding="utf-8") - assert source.read_text(encoding="utf-8") == "---\nname: demo-agent\n---\nUpdated through home.\n" + target.write_text( + "---\nname: demo-agent\n---\nUpdated through home.\n", encoding="utf-8" + ) + assert ( + source.read_text(encoding="utf-8") + == "---\nname: demo-agent\n---\nUpdated through home.\n" + ) def test_manifest_managed_copilot_copy_migrates_to_link_when_unchanged( @@ -457,7 +528,9 @@ def test_manifest_managed_copilot_copy_migrates_to_link_when_unchanged( policy=_repo_wins_policy(), ) - assert [(operation.action, operation.code) for operation in operations] == [("link", None)] + assert [(operation.action, operation.code) for operation in operations] == [ + ("link", None) + ] def _setup_skill_source(tmp_path: Path) -> tuple[Path, str]: @@ -518,9 +591,13 @@ def test_apply_links_skill_to_home_with_write_through(tmp_path: Path) -> None: assert target_path.resolve() == source.resolve() assert (target_path / "SKILL.md").is_file() (source / "SKILL.md").write_text("# changed in repo\n", encoding="utf-8") - assert (target_path / "SKILL.md").read_text(encoding="utf-8") == "# changed in repo\n" + assert (target_path / "SKILL.md").read_text( + encoding="utf-8" + ) == "# changed in repo\n" (target_path / "SKILL.md").write_text("# changed through home\n", encoding="utf-8") - assert (source / "SKILL.md").read_text(encoding="utf-8") == "# changed through home\n" + assert (source / "SKILL.md").read_text( + encoding="utf-8" + ) == "# changed through home\n" def test_apply_delete_with_prune(tmp_path: Path) -> None: diff --git a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py index ab111ad9..6da1d6b6 100644 --- a/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py +++ b/tests/github/skills/local-agent-sync-install-ai-resources/scripts/test_contracts.py @@ -74,7 +74,9 @@ def test_empty_manifest_defaults_to_schema_v2(tmp_path: Path) -> None: assert payload == {"schema_version": 2, "managed_resources": []} -def test_v1_manifest_rows_are_normalized_as_copy_without_rewrite(tmp_path: Path) -> None: +def test_v1_manifest_rows_are_normalized_as_copy_without_rewrite( + tmp_path: Path, +) -> None: path = tmp_path / "manifest.json" path.write_text( json.dumps( @@ -179,13 +181,19 @@ def test_valid_v2_manifest_rows_load_unchanged(tmp_path: Path) -> None: "target": "copilot", "resource_family": "agents", "materialization": "symlink", - "link_target": (tmp_path / "repo/.github/agents/review.agent.md").as_posix(), + "link_target": ( + tmp_path / "repo/.github/agents/review.agent.md" + ).as_posix(), "content_hash": None, - "target_path": (tmp_path / "home/.copilot/agents/review.agent.md").as_posix(), + "target_path": ( + tmp_path / "home/.copilot/agents/review.agent.md" + ).as_posix(), }, ] path = tmp_path / "manifest.json" - path.write_text(json.dumps({"schema_version": 2, "managed_resources": rows}), encoding="utf-8") + path.write_text( + json.dumps({"schema_version": 2, "managed_resources": rows}), encoding="utf-8" + ) payload, error = load_manifest(path)