diff --git a/inc/Abilities/AbilityRegistry.php b/inc/Abilities/AbilityRegistry.php index 5315fbe..f0bd44d 100644 --- a/inc/Abilities/AbilityRegistry.php +++ b/inc/Abilities/AbilityRegistry.php @@ -11,37 +11,13 @@ class AbilityRegistry { - public static function canonical_slug( string $slug ): string { - if ( str_starts_with($slug, 'datamachine/') ) { - return 'datamachine-code/' . substr($slug, strlen('datamachine/')); - } - - return $slug; - } - /** - * Register a DMC-owned ability and, for shipped datamachine/* slugs, a deprecated alias. + * Register a DMC-owned ability. * - * @param string $slug Legacy or canonical ability slug. + * @param string $slug Canonical ability slug. * @param array $args Ability registration args. */ public static function register( string $slug, array $args ): void { - $canonical = self::canonical_slug($slug); - wp_register_ability($canonical, $args); - - if ( $canonical === $slug || ! str_starts_with($slug, 'datamachine/') ) { - return; - } - - $alias_args = $args; - $alias_args['meta'] = array_merge( - $alias_args['meta'] ?? array(), - array( - 'deprecated' => true, - 'replacement' => $canonical, - ) - ); - - wp_register_ability($slug, $alias_args); + wp_register_ability($slug, $args); } } diff --git a/inc/Abilities/CodeTaskAbilities.php b/inc/Abilities/CodeTaskAbilities.php index 2a4a0f0..7ebe18c 100644 --- a/inc/Abilities/CodeTaskAbilities.php +++ b/inc/Abilities/CodeTaskAbilities.php @@ -52,7 +52,7 @@ public function __construct() { public function register(): void { AbilityRegistry::register( - 'datamachine/create-code-task', + 'datamachine-code/create-code-task', array( 'label' => 'Create Code Task', 'description' => 'Create an isolated workspace worktree from a structured evidence packet.', diff --git a/inc/Abilities/GitHubAbilities.php b/inc/Abilities/GitHubAbilities.php index 4e654f3..62606d6 100644 --- a/inc/Abilities/GitHubAbilities.php +++ b/inc/Abilities/GitHubAbilities.php @@ -107,7 +107,7 @@ public function __construct() { private function registerAbilities(): void { $register_callback = function () { AbilityRegistry::register( - 'datamachine/list-github-issues', + 'datamachine-code/list-github-issues', array( 'label' => 'List GitHub Issues', 'description' => 'List issues from a GitHub repository with optional filters', @@ -162,7 +162,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-issue', + 'datamachine-code/get-github-issue', array( 'label' => 'Get GitHub Issue', 'description' => 'Get a single GitHub issue with full details', @@ -196,7 +196,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/update-github-issue', + 'datamachine-code/update-github-issue', array( 'label' => 'Update GitHub Issue', 'description' => 'Update a GitHub issue (title, body, labels, assignees, state)', @@ -252,7 +252,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/create-github-issue', + 'datamachine-code/create-github-issue', array( 'label' => 'Create GitHub Issue', 'description' => 'Create a new GitHub issue with optional labels, assignees, and milestone', @@ -304,7 +304,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/create-github-pull-request', + 'datamachine-code/create-github-pull-request', array( 'label' => 'Create GitHub Pull Request', 'description' => 'Open a new GitHub pull request from a head branch into a base branch', @@ -371,7 +371,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/comment-github-issue', + 'datamachine-code/comment-github-issue', array( 'label' => 'Comment on GitHub Issue', 'description' => 'Add a comment to a GitHub issue', @@ -413,7 +413,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/comment-github-pull-request', + 'datamachine-code/comment-github-pull-request', array( 'label' => 'Comment on GitHub Pull Request', 'description' => 'Add a comment to a GitHub pull request without broader issue-management permissions', @@ -455,7 +455,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/add-github-labels', + 'datamachine-code/add-github-labels', array( 'label' => 'Add GitHub Labels', 'description' => 'Add one or more labels to a GitHub issue or pull request without replacing existing labels.', @@ -487,7 +487,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/remove-github-label', + 'datamachine-code/remove-github-label', array( 'label' => 'Remove GitHub Label', 'description' => 'Remove a single label from a GitHub issue or pull request without touching other labels.', @@ -518,7 +518,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/upsert-github-pull-review-comment', + 'datamachine-code/upsert-github-pull-review-comment', array( 'label' => 'Upsert GitHub Pull Request Review Comment', 'description' => 'Create or update one managed bot-authored GitHub pull request review comment identified by a hidden marker', @@ -570,7 +570,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/merge-github-pull-request', + 'datamachine-code/merge-github-pull-request', array( 'label' => 'Merge GitHub Pull Request', 'description' => 'Merge an open GitHub pull request after verifying the expected head SHA', @@ -623,7 +623,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/cleanup-github-pull-request', + 'datamachine-code/cleanup-github-pull-request', array( 'label' => 'Cleanup GitHub Pull Request', 'description' => 'Delete a merged pull request head branch through the GitHub API without checking out local branches', @@ -672,7 +672,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/list-github-pulls', + 'datamachine-code/list-github-pulls', array( 'label' => 'List GitHub Pull Requests', 'description' => 'List pull requests from a GitHub repository', @@ -715,7 +715,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-pull', + 'datamachine-code/get-github-pull', array( 'label' => 'Get GitHub Pull Request', 'description' => 'Get a single GitHub pull request with normalized metadata', @@ -749,7 +749,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/list-github-pull-files', + 'datamachine-code/list-github-pull-files', array( 'label' => 'List GitHub Pull Request Files', 'description' => 'List changed files for a GitHub pull request', @@ -792,7 +792,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-check-runs', + 'datamachine-code/get-github-check-runs', array( 'label' => 'Get GitHub Check Runs', 'description' => 'Get GitHub check runs for a commit SHA or ref with an overall summary', @@ -837,7 +837,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-commit-statuses', + 'datamachine-code/get-github-commit-statuses', array( 'label' => 'Get GitHub Commit Statuses', 'description' => 'Get unmanaged GitHub commit statuses for a commit SHA or ref', @@ -874,7 +874,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-actions-artifact', + 'datamachine-code/get-github-actions-artifact', array( 'label' => 'Get GitHub Actions Artifact', 'description' => 'Download a GitHub Actions artifact for a pull request or commit SHA and optionally parse JSON files from the ZIP', @@ -928,7 +928,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-pull-review-context', + 'datamachine-code/get-github-pull-review-context', array( 'label' => 'Get GitHub Pull Request Review Context', 'description' => 'Get a review-ready payload for a GitHub pull request and its changed files', @@ -1014,7 +1014,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-repo-review-profile', + 'datamachine-code/get-github-repo-review-profile', array( 'label' => 'Get GitHub Repository Review Profile', 'description' => 'Get bounded repository-level review context for a GitHub repository', @@ -1064,7 +1064,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-pr-documentation-impact', + 'datamachine-code/get-github-pr-documentation-impact', array( 'label' => 'Get GitHub PR Documentation Impact', 'description' => 'Build a heuristic documentation-impact packet for a GitHub pull request', @@ -1110,7 +1110,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/list-github-tree', + 'datamachine-code/list-github-tree', array( 'label' => 'List GitHub Repository Tree', 'description' => 'List files in a GitHub repository tree at a branch or ref', @@ -1149,7 +1149,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/get-github-file', + 'datamachine-code/get-github-file', array( 'label' => 'Get GitHub Files', 'description' => 'Get decoded content for one or more files in a GitHub repository', @@ -1200,7 +1200,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/create-or-update-github-file', + 'datamachine-code/create-or-update-github-file', array( 'label' => 'Create or Update GitHub File', 'description' => 'Create or update a file in a GitHub repository via the Contents API (upsert). If the file exists, it is updated; if not, it is created.', @@ -1252,7 +1252,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/list-github-repos', + 'datamachine-code/list-github-repos', array( 'label' => 'List GitHub Repositories', 'description' => 'List repositories for a user or organization', @@ -1299,7 +1299,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/github-status', + 'datamachine-code/github-status', array( 'label' => 'Get GitHub Integration Status', 'description' => 'Inspect GitHub authentication, default repository, and registered repository status.', diff --git a/inc/Abilities/GitSyncAbilities.php b/inc/Abilities/GitSyncAbilities.php index fb514ae..3c12546 100644 --- a/inc/Abilities/GitSyncAbilities.php +++ b/inc/Abilities/GitSyncAbilities.php @@ -61,7 +61,7 @@ private function registerAbilities(): void { // ----------------------------------------------------------------- AbilityRegistry::register( - 'datamachine/gitsync-list', + 'datamachine-code/gitsync-list', array( 'label' => 'List GitSync Bindings', 'description' => 'List every registered GitSync binding with a lightweight summary.', @@ -84,7 +84,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/gitsync-status', + 'datamachine-code/gitsync-status', array( 'label' => 'GitSync Binding Status', 'description' => 'Report on-disk + upstream status for a single GitSync binding.', @@ -122,7 +122,7 @@ private function registerAbilities(): void { // ----------------------------------------------------------------- AbilityRegistry::register( - 'datamachine/gitsync-bind', + 'datamachine-code/gitsync-bind', array( 'label' => 'Bind GitSync Path', 'description' => 'Register a binding between a site-owned local directory (relative to ABSPATH) and a GitHub repository. First pull materializes files.', @@ -153,7 +153,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/gitsync-unbind', + 'datamachine-code/gitsync-unbind', array( 'label' => 'Unbind GitSync Path', 'description' => 'Remove a binding. Directory preserved by default; pass purge=true to delete it.', @@ -182,7 +182,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/gitsync-pull', + 'datamachine-code/gitsync-pull', array( 'label' => 'Pull GitSync Binding', 'description' => 'Download all files from the pinned branch to the local directory. Uses GitHub Contents API — no git binary required.', @@ -219,7 +219,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/gitsync-submit', + 'datamachine-code/gitsync-submit', array( 'label' => 'Submit GitSync Binding as Pull Request', 'description' => 'Upload changed local files to the sticky proposal branch (gitsync/) by default, or to a keyed proposal branch (gitsync/-) when proposal is provided, and open or update a PR against the pinned branch.', @@ -262,7 +262,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/gitsync-push', + 'datamachine-code/gitsync-push', array( 'label' => 'Push GitSync Binding Directly', 'description' => 'Commit changed local files directly to the pinned branch — no PR. Requires policy.write_enabled=true AND policy.safe_direct_push=true (two-key authorization).', @@ -296,7 +296,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/gitsync-policy-update', + 'datamachine-code/gitsync-policy-update', array( 'label' => 'Update GitSync Binding Policy', 'description' => 'Update one or more policy fields on an existing binding (write_enabled, safe_direct_push, allowed_paths, conflict, auto_pull, pull_interval).', diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index ccc05b9..7e00729 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -69,7 +69,7 @@ private function registerAbilities(): void { // ----------------------------------------------------------------- AbilityRegistry::register( - 'datamachine/workspace-path', + 'datamachine-code/workspace-path', array( 'label' => 'Get Workspace Path', 'description' => 'Get the agent workspace directory path. Optionally create the directory.', @@ -99,7 +99,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-list', + 'datamachine-code/workspace-list', array( 'label' => 'List Workspace Repos', 'description' => 'List repositories in the agent workspace.', @@ -146,7 +146,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-capabilities', + 'datamachine-code/workspace-capabilities', array( 'label' => 'Inspect Workspace Capabilities', 'description' => 'Inspect the current Data Machine Code workspace backend and whether local git operations can execute in this runtime.', @@ -175,7 +175,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-show', + 'datamachine-code/workspace-show', array( 'label' => 'Show Workspace Repo', 'description' => 'Show detailed info about a workspace repository (branch, remote, latest commit, dirty status).', @@ -217,7 +217,7 @@ private function registerAbilities(): void { // ----------------------------------------------------------------- AbilityRegistry::register( - 'datamachine/workspace-read', + 'datamachine-code/workspace-read', array( 'label' => 'Read Workspace File', 'description' => 'Read the contents of a text file from a workspace repository.', @@ -266,7 +266,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-ls', + 'datamachine-code/workspace-ls', array( 'label' => 'List Workspace Directory', 'description' => 'List directory contents within a workspace repository.', @@ -311,7 +311,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-grep', + 'datamachine-code/workspace-grep', array( 'label' => 'Search Workspace Files', 'description' => 'Search text files within a workspace repository using a regular expression pattern.', @@ -380,7 +380,7 @@ private function registerAbilities(): void { // ----------------------------------------------------------------- AbilityRegistry::register( - 'datamachine/workspace-clone', + 'datamachine-code/workspace-clone', array( 'label' => 'Clone Workspace Repo', 'description' => 'Clone a git repository into the workspace as a primary checkout. Worktrees are created separately via `workspace-worktree-add`.', @@ -423,7 +423,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-adopt', + 'datamachine-code/workspace-adopt', array( 'label' => 'Adopt Workspace Repo', 'description' => 'Validate an existing git primary checkout already located under the workspace root so it can be managed by workspace commands.', @@ -459,7 +459,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-remove', + 'datamachine-code/workspace-remove', array( 'label' => 'Remove Workspace Repo', 'description' => 'Remove a workspace handle. Refuses to remove a primary that has linked worktrees.', @@ -488,7 +488,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-write', + 'datamachine-code/workspace-write', array( 'label' => 'Write Workspace File', 'description' => 'Create or overwrite a file in a workspace repository.', @@ -527,7 +527,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-edit', + 'datamachine-code/workspace-edit', array( 'label' => 'Edit Workspace File', 'description' => 'Find-and-replace text in a workspace repository file.', @@ -589,7 +589,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-apply-patch', + 'datamachine-code/workspace-apply-patch', array( 'label' => 'Apply Workspace Patch', 'description' => 'Apply a unified diff to a workspace repository through git apply. Mutating ops on the primary checkout require allow_primary_mutation=true. The patch is checked before apply and fails closed on context mismatch.', @@ -633,7 +633,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-status', + 'datamachine-code/workspace-git-status', array( 'label' => 'Workspace Git Status', 'description' => 'Get git status information for a workspace handle (primary or worktree).', @@ -675,7 +675,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-log', + 'datamachine-code/workspace-git-log', array( 'label' => 'Workspace Git Log', 'description' => 'Read git log entries for a workspace handle.', @@ -720,7 +720,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-diff', + 'datamachine-code/workspace-git-diff', array( 'label' => 'Workspace Git Diff', 'description' => 'Read git diff output for a workspace handle.', @@ -766,7 +766,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-pull', + 'datamachine-code/workspace-git-pull', array( 'label' => 'Workspace Git Pull', 'description' => 'Run git pull --ff-only for a workspace handle. Mutating ops on the primary checkout require allow_primary_mutation=true.', @@ -804,7 +804,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-add', + 'datamachine-code/workspace-git-add', array( 'label' => 'Workspace Git Add', 'description' => 'Stage repository paths with git add. Mutating ops on the primary checkout require allow_primary_mutation=true.', @@ -847,7 +847,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-delete', + 'datamachine-code/workspace-delete', array( 'label' => 'Delete Workspace Path', 'description' => 'Delete a tracked or untracked file or directory from a workspace repository. Tracked paths are removed via git rm; untracked paths are unlinked from disk. Mutating ops on the primary checkout require allow_primary_mutation=true.', @@ -899,7 +899,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-commit', + 'datamachine-code/workspace-git-commit', array( 'label' => 'Workspace Git Commit', 'description' => 'Commit staged changes in a workspace handle. Mutating ops on the primary checkout require allow_primary_mutation=true.', @@ -938,7 +938,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-push', + 'datamachine-code/workspace-git-push', array( 'label' => 'Workspace Git Push', 'description' => 'Push commits for a workspace handle. `fixed_branch` policy applies only to the primary checkout; worktrees may push any branch.', @@ -1000,7 +1000,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-rebase', + 'datamachine-code/workspace-git-rebase', array( 'label' => 'Workspace Git Rebase', 'description' => 'Fetch and rebase a workspace handle, returning structured conflict information without auto-resolving conflicts.', @@ -1043,7 +1043,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-git-reset', + 'datamachine-code/workspace-git-reset', array( 'label' => 'Workspace Git Reset', 'description' => 'Run git reset --soft/--mixed/--hard for a workspace handle. Hard reset requires allow_destructive=true.', @@ -1092,7 +1092,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-pr-status', + 'datamachine-code/workspace-pr-status', array( 'label' => 'Workspace Pull Request Status', 'description' => 'Resolve a workspace pull request and return mergeability/freshness state from GitHub.', @@ -1126,7 +1126,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-pr-rebase', + 'datamachine-code/workspace-pr-rebase', array( 'label' => 'Workspace Pull Request Rebase', 'description' => 'Bring a workspace pull request branch up to date with its base branch, optionally dropping configured conflict paths, squashing, and force-with-lease pushing.', @@ -1173,7 +1173,7 @@ private function registerAbilities(): void { // ----------------------------------------------------------------- AbilityRegistry::register( - 'datamachine/workspace-worktree-add', + 'datamachine-code/workspace-worktree-add', array( 'label' => 'Add Workspace Worktree', 'description' => 'Create a git worktree for a branch under `@`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. When `inject_context` is true (default), the originating site\'s composed AGENTS.md is made visible to OpenCode: symlinked into the worktree root when no repo-owned AGENTS.md exists, otherwise added via local OpenCode instructions so both files load. Site agent memory is snapshotted into `.claude/CLAUDE.local.md`, and injected paths are added to the worktree\'s per-checkout `info/exclude`. When `bootstrap` is true (default), submodule init plus root or one-level nested package-manager/composer installs run after creation so the worktree is immediately test/build-ready; set false to create a bare checkout.', @@ -1310,7 +1310,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-refresh-context', + 'datamachine-code/workspace-worktree-refresh-context', array( 'label' => 'Refresh Worktree Context', 'description' => 'Re-read the originating site\'s agent memory, refresh the site AGENTS.md projection when possible, and rewrite the injected context file (`.claude/CLAUDE.local.md`) in an existing worktree. Must be run from the site that created the worktree — cross-machine refresh is not supported.', @@ -1347,7 +1347,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-finalize', + 'datamachine-code/workspace-worktree-finalize', array( 'label' => 'Finalize Workspace Worktree', 'description' => 'Attach lifecycle metadata to a worktree after a coding-agent session opens a PR, completes, or marks the worktree cleanup-eligible. This metadata is a cleanup signal only; dirty/unpushed safety gates still apply.', @@ -1388,7 +1388,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-hygiene-report', + 'datamachine-code/workspace-hygiene-report', array( 'label' => 'Workspace Hygiene Report', 'description' => 'Build a non-destructive workspace hygiene report with disk, size, worktree, and local cleanup dry-run summaries.', @@ -1445,7 +1445,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-inventory-refresh', + 'datamachine-code/workspace-worktree-inventory-refresh', array( 'label' => 'Refresh Worktree Inventory', 'description' => 'Reconcile the DB-backed worktree inventory from the current filesystem/git worktree view. Current rows are upserted; stale known rows are marked missing_path.', @@ -1471,7 +1471,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-cleanup-run', + 'datamachine-code/workspace-cleanup-run', array( 'label' => 'Schedule Workspace Cleanup Run', 'description' => 'Schedule a background workspace cleanup system task. Review/dry-run commands are separate synchronous abilities.', @@ -1522,7 +1522,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-list', + 'datamachine-code/workspace-worktree-list', array( 'label' => 'List Workspace Worktrees', 'description' => 'List all worktrees in the workspace (optionally filtered by repo and lifecycle state). Defaults to a fast cheap-inventory listing on large workspaces; opt in to per-worktree git status and disk probes via include_status / include_disk.', @@ -1653,7 +1653,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-remove', + 'datamachine-code/workspace-worktree-remove', array( 'label' => 'Remove Workspace Worktree', 'description' => 'Remove a worktree by repo and branch (or branch slug). Refuses if the worktree has uncommitted changes unless `force` is true.', @@ -1691,7 +1691,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-prune', + 'datamachine-code/workspace-worktree-prune', array( 'label' => 'Prune Workspace Worktrees', 'description' => 'Run git worktree prune across all primary checkouts to drop stale registry entries.', @@ -1717,7 +1717,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-cleanup', + 'datamachine-code/workspace-worktree-cleanup', array( 'label' => 'Cleanup Merged Worktrees', 'description' => 'Remove worktrees whose branch is merged to the remote default branch. Detects merge via `upstream: gone` (remote branch deleted, e.g. by GitHub auto-delete on PR merge) or closed+merged PR via the GitHub API. Deletes the local branch and prunes the git registry after removal. Dry-run supported.', @@ -1789,7 +1789,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-reconcile-metadata', + 'datamachine-code/workspace-worktree-reconcile-metadata', array( 'label' => 'Reconcile Worktree Metadata', 'description' => 'Build, apply, or schedule bounded apply jobs for lifecycle metadata reconciliation. Never removes worktrees; apply paths revalidate identity and cleanup-eligibility safety gates before writing.', @@ -1846,7 +1846,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-active-no-signal-report', + 'datamachine-code/workspace-worktree-active-no-signal-report', array( 'label' => 'Report Active Worktrees Without Cleanup Signal', 'description' => 'Build a bounded, review-only evidence report for active_no_signal worktrees. Gathers PR, dirty, unpushed, remote tracking, and default-branch evidence without deleting worktrees or branches.', @@ -1885,7 +1885,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-active-no-signal-finalized-apply', + 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply', array( 'label' => 'Promote Finalized Active Worktrees', 'description' => 'Promote active_no_signal rows with merged PR evidence into explicit cleanup_eligible metadata. Reviewable and bounded; never deletes worktrees.', @@ -1929,7 +1929,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-active-no-signal-equivalent-clean-apply', + 'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply', array( 'label' => 'Promote Equivalent Clean Active Worktrees', 'description' => 'Promote active_no_signal rows with effective_status=equivalent_clean into explicit cleanup_eligible metadata. Reviewable and bounded; never deletes worktrees.', @@ -1973,7 +1973,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-active-no-signal-merged-apply', + 'datamachine-code/workspace-worktree-active-no-signal-merged-apply', array( 'label' => 'Promote Merged Active Worktrees', 'description' => 'Promote clean active_no_signal rows with suggested_action=merged_to_default into explicit cleanup_eligible metadata. Reviewable and bounded; never deletes worktrees.', @@ -2017,7 +2017,51 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-cleanup-artifacts', + 'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply', + array( + 'label' => 'Promote Clean Remote Active Worktrees', + 'description' => 'Promote clean active_no_signal rows with suggested_action=remote_tracking_clean into explicit cleanup_eligible metadata. Reviewable and bounded; never deletes worktrees or remote branches.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'dry_run' => array( + 'type' => 'boolean', + 'description' => 'If true, preview metadata promotions without writing.', + ), + 'limit' => array( + 'type' => 'integer', + 'description' => 'Maximum active_no_signal rows to inspect in this page. Defaults to 25.', + ), + 'offset' => array( + 'type' => 'integer', + 'description' => 'Pagination offset into the active_no_signal inventory ordering.', + ), + 'until_budget' => array( + 'type' => 'string', + 'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'planned' => array( 'type' => 'array' ), + 'written' => array( 'type' => 'array' ), + 'skipped' => array( 'type' => 'array' ), + 'summary' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeActiveNoSignalRemoteCleanApply' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + AbilityRegistry::register( + 'datamachine-code/workspace-worktree-cleanup-artifacts', array( 'label' => 'Cleanup Worktree Artifacts', 'description' => 'Remove profile-derived, reconstructable artifact directories inside workspace worktrees. Dry-run defaults to bounded inventory mode (limit=' . Workspace::ARTIFACT_CLEANUP_DEFAULT_LIMIT . ', cheap top-level scan, no per-worktree git probes) so huge workspaces stay responsive. Requires a dry-run plan before deletion and revalidates exact paths before applying.', @@ -2074,7 +2118,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-emergency-cleanup', + 'datamachine-code/workspace-worktree-emergency-cleanup', array( 'label' => 'Emergency Cleanup Worktrees', 'description' => 'Build or apply a disk-pressure emergency cleanup plan using cheap workspace inventory first. The plan prioritizes artifact/cache deletion and oldest finalized or cleanup-eligible worktrees without running full git status or GitHub checks before initial output.', @@ -2117,7 +2161,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-worktree-bounded-cleanup-eligible-apply', + 'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply', array( 'label' => 'Bounded Cleanup Apply for Obvious Worktrees', 'description' => 'Apply only worktrees with explicit lifecycle cleanup_eligible metadata in a bounded batch using cheap workspace inventory. Can explicitly include repaired metadata rows for operator-approved cleanup. Revalidates dirty/unpushed/missing-metadata/external/primary safety gates immediately before each removal. Optionally schedules per-candidate chunk jobs for resumable async apply. Produces evidence with processed/removed/skipped/bytes_reclaimed/continuation.', @@ -2184,7 +2228,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-cleanup-plan', + 'datamachine-code/workspace-cleanup-plan', array( 'label' => 'Build DB-backed Workspace Cleanup Plan', 'description' => 'Freeze a non-destructive cleanup plan into DMC cleanup run/item database rows and return a stable run_id. File plans are an escape hatch on lower-level commands only.', @@ -2221,7 +2265,7 @@ private function registerAbilities(): void { ); AbilityRegistry::register( - 'datamachine/workspace-cleanup-apply', + 'datamachine-code/workspace-cleanup-apply', array( 'label' => 'Apply Workspace Cleanup Run', 'description' => 'Apply pending rows from a DB-backed cleanup run by run_id after current-state revalidation.', @@ -2244,7 +2288,7 @@ private function registerAbilities(): void { foreach ( array( 'status', 'evidence', 'resume', 'cancel' ) as $cleanup_operation ) { AbilityRegistry::register( - 'datamachine/workspace-cleanup-' . $cleanup_operation, + 'datamachine-code/workspace-cleanup-' . $cleanup_operation, array( 'label' => 'Workspace Cleanup ' . ucfirst($cleanup_operation), 'description' => 'Operate on a DB-backed workspace cleanup run by run_id.', @@ -3361,6 +3405,30 @@ public static function worktreeActiveNoSignalMergedApply( array $input ): array| return $workspace->worktree_active_no_signal_merged_apply($opts); } + /** + * Promote clean remote-tracking active/no-signal evidence into cleanup metadata. + * + * @param array $input Input parameters (dry_run, limit, offset). + * @return array|\WP_Error + */ + public static function worktreeActiveNoSignalRemoteCleanApply( array $input ): array|\WP_Error { + $workspace = new Workspace(); + $opts = array( + 'dry_run' => ! empty($input['dry_run']), + ); + if ( array_key_exists('limit', $input) ) { + $opts['limit'] = (int) $input['limit']; + } + if ( array_key_exists('offset', $input) ) { + $opts['offset'] = (int) $input['offset']; + } + if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { + $opts['until_budget'] = trim( (string) $input['until_budget']); + } + + return $workspace->worktree_active_no_signal_remote_clean_apply($opts); + } + /** * Remove profile-derived artifacts inside workspace worktrees. * diff --git a/inc/Abilities/WorkspaceDiffAbilities.php b/inc/Abilities/WorkspaceDiffAbilities.php index c47e120..8d9d39a 100644 --- a/inc/Abilities/WorkspaceDiffAbilities.php +++ b/inc/Abilities/WorkspaceDiffAbilities.php @@ -24,7 +24,7 @@ class WorkspaceDiffAbilities { public function __construct() { $register_callback = function () { AbilityRegistry::register( - 'datamachine/workspace-diff-summary', + 'datamachine-code/workspace-diff-summary', array( 'label' => 'Workspace Diff Summary', 'description' => 'Summarize changed files and compact diff metadata for a workspace handle.', @@ -63,7 +63,7 @@ public function __construct() { ); AbilityRegistry::register( - 'datamachine/workspace-diff-validate', + 'datamachine-code/workspace-diff-validate', array( 'label' => 'Validate Workspace Diff', 'description' => 'Validate workspace diff shape using allowed/denied path patterns and optional test-change requirements.', diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index e7ac2bd..f88c3eb 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2081,7 +2081,7 @@ private function renderGitOperationResult( string $operation, array $result, arr * bounded-cleanup-eligible-apply, emergency-cleanup, reconcile-metadata, * active-no-signal-report, active-no-signal-finalized-apply, * active-no-signal-equivalent-clean-apply, - * active-no-signal-merged-apply, + * active-no-signal-merged-apply, active-no-signal-remote-clean-apply, * refresh-context, finalize, mark-cleanup-eligible. * * [] @@ -2266,7 +2266,7 @@ private function renderGitOperationResult( string $operation, array $result, arr * * [--stage=] * : For `abandoned`, resume from a specific orchestration stage. Supported - * values: reconcile, finalized, equivalent-clean, merged, bounded. + * values: reconcile, finalized, equivalent-clean, merged, remote-clean, bounded. * * [--offset=] * : For `cleanup --dry-run`, `cleanup-artifacts --dry-run`, @@ -2424,7 +2424,7 @@ public function worktree( array $args, array $assoc_args ): void { $operation = $args[0] ?? ''; if ( '' === $operation ) { - WP_CLI::error('Usage: wp datamachine-code workspace worktree [] [] [--flags]'); + WP_CLI::error('Usage: wp datamachine-code workspace worktree [] [] [--flags]'); return; } @@ -2497,6 +2497,7 @@ public function worktree( array $args, array $assoc_args ): void { 'active-no-signal-finalized-apply' => 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply', 'active-no-signal-equivalent-clean-apply' => 'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply', 'active-no-signal-merged-apply' => 'datamachine-code/workspace-worktree-active-no-signal-merged-apply', + 'active-no-signal-remote-clean-apply' => 'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply', 'refresh-context' => 'datamachine-code/workspace-worktree-refresh-context', 'finalize' => 'datamachine-code/workspace-worktree-finalize', 'mark-cleanup-eligible' => 'datamachine-code/workspace-worktree-finalize', @@ -2680,7 +2681,8 @@ public function worktree( array $args, array $assoc_args ): void { case 'active-no-signal-finalized-apply': case 'active-no-signal-equivalent-clean-apply': case 'active-no-signal-merged-apply': - if ( in_array($operation, array( 'active-no-signal-finalized-apply', 'active-no-signal-equivalent-clean-apply', 'active-no-signal-merged-apply' ), true) ) { + case 'active-no-signal-remote-clean-apply': + if ( in_array($operation, array( 'active-no-signal-finalized-apply', 'active-no-signal-equivalent-clean-apply', 'active-no-signal-merged-apply', 'active-no-signal-remote-clean-apply' ), true) ) { $input['dry_run'] = ! empty($assoc_args['dry-run']); } if ( isset($assoc_args['limit']) ) { @@ -2777,10 +2779,11 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'finalized' => 1, 'equivalent-clean' => 2, 'merged' => 3, - 'bounded' => 4, + 'remote-clean' => 4, + 'bounded' => 5, ); if ( ! isset($stage_order[ $stage ]) ) { - return new \WP_Error('invalid_worktree_abandoned_stage', 'Invalid --stage value. Use reconcile, finalized, equivalent-clean, merged, or bounded.', array( 'status' => 400 )); + return new \WP_Error('invalid_worktree_abandoned_stage', 'Invalid --stage value. Use reconcile, finalized, equivalent-clean, merged, remote-clean, or bounded.', array( 'status' => 400 )); } if ( '' !== $until_budget ) { $budget_seconds = $this->parse_worktree_abandoned_budget($until_budget); @@ -2795,6 +2798,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'finalized' => 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply', 'equivalent_clean' => 'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply', 'merged' => 'datamachine-code/workspace-worktree-active-no-signal-merged-apply', + 'remote_clean' => 'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply', 'bounded_apply' => 'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply', 'prune' => 'datamachine-code/workspace-worktree-prune', ); @@ -2884,6 +2888,10 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'stage' => 'merged', 'ability' => $abilities['merged'], ), + 'remote_clean' => array( + 'stage' => 'remote-clean', + 'ability' => $abilities['remote_clean'], + ), ); $effective_passes = $apply ? $passes : 1; @@ -3462,6 +3470,10 @@ function ( $wt ) { $this->render_worktree_active_no_signal_merged_apply_result($result, $assoc_args); return; + case 'active-no-signal-remote-clean-apply': + $this->render_worktree_active_no_signal_remote_clean_apply_result($result, $assoc_args); + return; + case 'cleanup-artifacts': $this->render_worktree_artifact_cleanup_result($result, $assoc_args); return; @@ -4739,6 +4751,97 @@ private function render_worktree_active_no_signal_merged_apply_result( array $re WP_CLI::success(sprintf('Promoted %d merged-to-default worktree(s) to cleanup_eligible metadata.', count($written))); } + /** + * Render remote-clean active/no-signal metadata apply output. + * + * @param array $result Apply result. + * @param array $assoc_args CLI assoc args. + * @return void + */ + private function render_worktree_active_no_signal_remote_clean_apply_result( array $result, array $assoc_args ): void { + $format = isset($assoc_args['format']) ? (string) $assoc_args['format'] : 'table'; + if ( 'json' === $format ) { + $json = wp_json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + WP_CLI::log(false === $json ? '{}' : $json); + return; + } + + $summary = (array) ( $result['summary'] ?? array() ); + $planned = (array) ( $result['planned'] ?? array() ); + $written = (array) ( $result['written'] ?? array() ); + $skipped = (array) ( $result['skipped'] ?? array() ); + $dry_run = ! empty($result['dry_run']); + + WP_CLI::log('Remote-clean active/no-signal apply summary:'); + $summary_rows = array( + array( + 'metric' => 'inspected', + 'count' => (int) ( $summary['inspected'] ?? 0 ), + ), + array( + 'metric' => 'planned', + 'count' => (int) ( $summary['planned'] ?? count($planned) ), + ), + array( + 'metric' => 'written', + 'count' => (int) ( $summary['written'] ?? count($written) ), + ), + array( + 'metric' => 'skipped', + 'count' => (int) ( $summary['skipped'] ?? count($skipped) ), + ), + ); + foreach ( (array) ( $summary['skipped_by_reason'] ?? array() ) as $reason => $count ) { + $summary_rows[] = array( + 'metric' => 'skipped:' . $reason, + 'count' => (int) $count, + ); + } + $this->format_items($summary_rows, array( 'metric', 'count' ), array( 'format' => 'table' ), 'metric'); + + $rows = $dry_run ? $planned : $written; + if ( ! empty($rows) ) { + WP_CLI::log(''); + WP_CLI::log($dry_run ? 'Would promote:' : 'Promoted:'); + $items = array_map( + fn( $row ) => array( + 'handle' => $row['handle'] ?? '', + 'branch' => $row['branch'] ?? '', + 'remote_ref' => $row['metadata']['cleanup_eligibility_evidence']['remote_ref'] ?? '', + 'state' => $row['metadata']['lifecycle_state'] ?? '', + ), + $rows + ); + $this->format_items($items, array( 'handle', 'branch', 'remote_ref', 'state' ), array( 'format' => 'table' ), 'handle'); + } + + if ( ! empty($skipped) ) { + WP_CLI::log(''); + WP_CLI::log('Skipped:'); + $items = array_map( + fn( $row ) => array( + 'handle' => $row['handle'] ?? '', + 'action' => $row['action'] ?? '', + 'reason_code' => $row['reason_code'] ?? '', + 'reason' => $row['reason'] ?? '', + ), + array_slice($skipped, 0, 10) + ); + $this->format_items($items, array( 'handle', 'action', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle'); + } + + if ( ! empty($result['pagination']['next_command']) ) { + WP_CLI::log(''); + WP_CLI::log('Next page: ' . (string) $result['pagination']['next_command']); + } + + if ( $dry_run ) { + WP_CLI::success(sprintf('%d remote-clean worktree(s) would be promoted to cleanup_eligible metadata.', count($planned))); + return; + } + WP_CLI::success(sprintf('Promoted %d remote-clean worktree(s) to cleanup_eligible metadata.', count($written))); + } + /** * Render artifact-only cleanup output. * diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 0592115..d667350 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -2001,6 +2001,90 @@ public function worktree_active_no_signal_merged_apply( array $opts = array() ): ); } + /** + * Promote clean remote-tracking active/no-signal rows into cleanup metadata. + * + * @param array $opts Options. + * @return array|\WP_Error + */ + public function worktree_active_no_signal_remote_clean_apply( array $opts = array() ): array|\WP_Error { + $dry_run = ! empty($opts['dry_run']); + $report = $this->worktree_active_no_signal_report(array_merge($opts, array( 'next_command_operation' => 'active-no-signal-remote-clean-apply' ))); + if ( is_wp_error($report) ) { + return $report; + } + + $planned = array(); + $written = array(); + $skipped = array(); + foreach ( (array) ( $report['rows'] ?? array() ) as $row ) { + if ( 'remote_tracking_clean' !== (string) ( $row['suggested_action'] ?? '' ) ) { + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, 'not_remote_tracking_clean', 'row is not a clean remote-tracking candidate'); + continue; + } + $handle = (string) ( $row['handle'] ?? '' ); + $current_metadata = '' !== $handle ? WorktreeContextInjector::get_metadata($handle) : array(); + if ( WorktreeContextInjector::STATE_ACTIVE !== (string) ( $current_metadata['lifecycle_state'] ?? '' ) ) { + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, 'not_active_lifecycle_state', 'row is no longer active lifecycle metadata'); + continue; + } + $row['metadata'] = $current_metadata; + + $metadata = $this->build_active_no_signal_remote_clean_metadata($row); + if ( is_wp_error($metadata) ) { + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, $metadata->get_error_code(), $metadata->get_error_message()); + continue; + } + + $planned[] = array( + 'handle' => (string) ( $row['handle'] ?? '' ), + 'repo' => (string) ( $row['repo'] ?? '' ), + 'branch' => (string) ( $row['branch'] ?? '' ), + 'path' => (string) ( $row['path'] ?? '' ), + 'metadata' => $metadata, + ); + + if ( $dry_run ) { + continue; + } + + WorktreeContextInjector::store_lifecycle_metadata( (string) ( $row['handle'] ?? '' ), $metadata ); + $written[] = end($planned); + } + + $summary = array( + 'inspected' => (int) ( $report['summary']['inspected'] ?? count( (array) ( $report['rows'] ?? array() ) ) ), + 'planned' => count($planned), + 'written' => count($written), + 'skipped' => count($skipped), + 'skipped_by_reason' => array(), + 'candidate_action' => 'remote_tracking_clean', + 'candidate_evidence' => 'clean worktree with no unpushed commits and an existing remote tracking branch', + ); + foreach ( $skipped as $skip ) { + $reason = (string) ( $skip['reason_code'] ?? 'unknown' ); + $summary['skipped_by_reason'][ $reason ] = (int) ( $summary['skipped_by_reason'][ $reason ] ?? 0 ) + 1; + } + + return array( + 'success' => true, + 'mode' => 'active_no_signal_remote_clean_apply', + 'dry_run' => $dry_run, + 'applied' => ! $dry_run, + 'review_only' => false, + 'planned' => $planned, + 'written' => $written, + 'skipped' => $skipped, + 'summary' => $summary, + 'pagination' => $this->build_active_no_signal_apply_pagination( (array) ( $report['pagination'] ?? array() ), 'active-no-signal-remote-clean-apply', $dry_run, $opts, count( $written ) ), + 'evidence' => array( + 'scope' => 'promote clean remote-tracking active_no_signal rows into cleanup_eligible metadata', + 'safety' => 'Revalidates clean worktree, no unpushed commits, remote branch existence, primary protection, and branch identity before writing metadata. Does not delete worktrees or remote branches.', + ), + 'generated_at' => gmdate('c'), + ); + } + /** * Build apply-specific pagination from the underlying active/no-signal report. * @@ -2248,6 +2332,136 @@ private function build_active_no_signal_merged_to_default_metadata( array $row ) return $metadata; } + /** + * Build cleanup metadata from one clean remote-tracking evidence row. + * + * @param array $row Evidence row. + * @return array|\WP_Error + */ + private function build_active_no_signal_remote_clean_metadata( array $row ): array|\WP_Error { + $handle = (string) ( $row['handle'] ?? '' ); + $repo = (string) ( $row['repo'] ?? '' ); + $branch = (string) ( $row['branch'] ?? '' ); + $path = (string) ( $row['path'] ?? '' ); + + $evidence = $this->build_current_remote_tracking_clean_cleanup_evidence($handle, $repo, $branch, $path); + if ( is_wp_error($evidence) ) { + return $evidence; + } + + $base_metadata = is_array($row['metadata'] ?? null) ? $row['metadata'] : array(); + $metadata = array_merge( + $base_metadata, + array( + 'handle' => $handle, + 'repo' => $repo, + 'branch' => $branch, + 'path' => (string) ( $evidence['path'] ?? $path ), + 'observed_at' => gmdate('c'), + 'last_seen_at' => gmdate('c'), + ), + WorktreeContextInjector::build_finalizer_metadata(WorktreeContextInjector::STATE_CLEANUP_ELIGIBLE) + ); + $metadata['auto_finalized_by'] = 'active_no_signal_remote_clean_apply'; + $metadata['auto_finalized_signal'] = 'remote-tracking-clean'; + $metadata['auto_finalized_reason'] = 'active/no-signal report found a clean local worktree whose work is preserved by its remote branch'; + $metadata['cleanup_eligibility_evidence'] = $evidence; + + return $metadata; + } + + /** + * Recompute clean remote-tracking evidence for the current worktree state. + * + * @param string $handle Worktree handle. + * @param string $repo Repository name. + * @param string $branch Branch name. + * @param string $path Worktree path. + * @return array|\WP_Error + */ + private function build_current_remote_tracking_clean_cleanup_evidence( string $handle, string $repo, string $branch, string $path ): array|\WP_Error { + foreach ( + array( + 'handle' => $handle, + 'repo' => $repo, + 'branch' => $branch, + 'path' => $path, + ) as $field => $value + ) { + if ( '' === $value ) { + return new \WP_Error('missing_identity', 'missing required identity field: ' . $field); + } + } + + if ( in_array($branch, $this->protected_base_branch_names(), true) ) { + return new \WP_Error('primary_protected_branch', 'refusing to auto-finalize a protected primary branch worktree'); + } + + $validation = $this->validate_containment($path, $this->workspace_path); + if ( ! $validation['valid'] ) { + return new \WP_Error('external_worktree', 'worktree path is outside the workspace root'); + } + + $real_path = (string) ( $validation['real_path'] ?? '' ); + if ( '' === $real_path || ! is_dir($real_path) ) { + return new \WP_Error('missing_worktree', 'worktree path no longer exists'); + } + + $git_marker = rtrim($real_path, '/') . '/.git'; + if ( is_dir($git_marker) ) { + return new \WP_Error('primary_checkout', 'refusing to mark a primary checkout cleanup_eligible'); + } + if ( ! is_file($git_marker) ) { + return new \WP_Error('not_a_worktree', 'worktree marker missing'); + } + + $current_branch = $this->resolve_worktree_branch_from_head_file($real_path); + if ( $branch !== $current_branch ) { + return new \WP_Error('branch_identity_mismatch', 'worktree branch identity changed before apply'); + } + + $dirty = $this->probe_worktree_dirty_count($real_path, self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($dirty) ) { + return $dirty; + } + if ( 0 !== (int) $dirty ) { + return new \WP_Error('dirty_worktree', 'worktree is dirty'); + } + + $unpushed = $this->count_unpushed_commits($real_path, self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($unpushed) ) { + return $unpushed; + } + if ( 0 !== (int) $unpushed ) { + return new \WP_Error('unpushed_commits', 'worktree has unpushed commits'); + } + + $primary_path = $this->get_primary_path($repo); + if ( '' === $primary_path || ! is_dir($primary_path . '/.git') ) { + return new \WP_Error('primary_missing', 'primary checkout missing'); + } + + $remote_ref = 'refs/remotes/origin/' . $branch; + $remote = $this->run_git($primary_path, sprintf('rev-parse --verify --quiet %s', escapeshellarg($remote_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($remote) || $this->is_git_timeout_error($remote) ) { + return new \WP_Error('remote_tracking_missing', 'remote tracking branch no longer exists'); + } + + $evidence = array(); + $evidence['signal'] = 'remote-tracking-clean'; + $evidence['handle'] = $handle; + $evidence['repo'] = $repo; + $evidence['branch'] = $branch; + $evidence['dirty'] = (int) $dirty; + $evidence['unpushed'] = (int) $unpushed; + $evidence['remote_ref'] = $remote_ref; + $evidence['remote_tracking'] = true; + $evidence['reason'] = 'clean local worktree has no unpushed commits and the branch exists on origin; removing the local checkout does not delete the remote branch'; + $evidence['detected_at'] = gmdate('c'); + + return $evidence; + } + /** * Recompute effective-clean evidence for the current worktree state. * @@ -2961,6 +3175,10 @@ private function suggest_active_no_signal_action( array $row ): string { return 'contained_non_default_remote'; } + if ( true === ( $row['remote_tracking'] ?? null ) ) { + return 'remote_tracking_clean'; + } + if ( null === ( $row['pr'] ?? null ) && empty($row['pr_error']) ) { return 'no_pr_branch_review'; } @@ -2983,6 +3201,7 @@ private function describe_active_no_signal_action( array $row ): string { 'merged_to_default' => 'local branch has no commits outside the remote default ref', 'patch_equivalent_default' => 'local commits are patch-equivalent to the remote default ref', 'contained_non_default_remote' => 'worktree HEAD is contained in a non-default remote branch', + 'remote_tracking_clean' => 'clean local worktree has no unpushed commits and the branch exists on origin', 'no_pr_branch_review' => 'no exact branch-head PR was found; review age/task context before cleanup', default => 'not enough evidence gathered', }; diff --git a/tests/smoke-github-check-status-context.php b/tests/smoke-github-check-status-context.php index ec8fd02..da3dc5b 100644 --- a/tests/smoke-github-check-status-context.php +++ b/tests/smoke-github-check-status-context.php @@ -174,8 +174,8 @@ function wp_json_encode( $data, int $options = 0 ) $settings_source = file_get_contents(__DIR__ . '/../inc/Handlers/GitHub/GitHubSettings.php'); $tool_source = file_get_contents(__DIR__ . '/../inc/Tools/GitHubTools.php'); - $assert(str_contains($ability_source, "'datamachine/get-github-check-runs'"), 'check-runs ability is registered'); - $assert(str_contains($ability_source, "'datamachine/get-github-commit-statuses'"), 'commit-statuses ability is registered'); + $assert(str_contains($ability_source, "'datamachine-code/get-github-check-runs'"), 'check-runs ability is registered'); + $assert(str_contains($ability_source, "'datamachine-code/get-github-commit-statuses'"), 'commit-statuses ability is registered'); $assert(str_contains($handler_source, "'check_runs' === $" . 'data_source'), 'fetch handler routes check_runs data source'); $assert(str_contains($handler_source, "'commit_statuses' === $" . 'data_source'), 'fetch handler routes commit_statuses data source'); $assert(str_contains($settings_source, "'include_checks'"), 'handler settings expose include_checks'); diff --git a/tests/smoke-github-create-abilities.php b/tests/smoke-github-create-abilities.php index cb06d3a..fe5418d 100644 --- a/tests/smoke-github-create-abilities.php +++ b/tests/smoke-github-create-abilities.php @@ -255,7 +255,6 @@ function wp_remote_retrieve_body( $response ): string $issue_ability = $GLOBALS['dmc_registered_abilities']['datamachine-code/create-github-issue'] ?? null; $pr_ability = $GLOBALS['dmc_registered_abilities']['datamachine-code/create-github-pull-request'] ?? null; $file_ability = $GLOBALS['dmc_registered_abilities']['datamachine-code/create-or-update-github-file'] ?? null; - $issue_alias = $GLOBALS['dmc_registered_abilities']['datamachine/create-github-issue'] ?? null; $assert('create-github-issue ability is registered', null !== $issue_ability); $assert('create-github-issue uses createIssue execute_callback', array( GitHubAbilities::class, 'createIssue' ) === ( $issue_ability['execute_callback'] ?? null )); @@ -267,9 +266,7 @@ function wp_remote_retrieve_body( $response ): string $assert('create-github-issue exposes milestone', array_key_exists('milestone', $issue_ability['input_schema']['properties'] ?? array())); $assert('create-github-issue is hidden from REST', false === ( $issue_ability['meta']['show_in_rest'] ?? null )); $assert('create-github-issue category matches family', 'datamachine-code-github' === ( $issue_ability['category'] ?? '' )); - $assert('legacy create-github-issue alias is registered', null !== $issue_alias); - $assert('legacy create-github-issue alias is deprecated', true === ( $issue_alias['meta']['deprecated'] ?? false )); - $assert('legacy create-github-issue alias points to canonical slug', 'datamachine-code/create-github-issue' === ( $issue_alias['meta']['replacement'] ?? '' )); + $assert('legacy create-github-issue alias is not registered', ! isset($GLOBALS['dmc_registered_abilities']['datamachine/create-github-issue'])); $assert('create-github-pull-request ability is registered', null !== $pr_ability); $assert('create-github-pull-request uses createPullRequest execute_callback', array( GitHubAbilities::class, 'createPullRequest' ) === ( $pr_ability['execute_callback'] ?? null )); diff --git a/tests/smoke-github-pr-comment-tool.php b/tests/smoke-github-pr-comment-tool.php index 82d670a..e84d9af 100644 --- a/tests/smoke-github-pr-comment-tool.php +++ b/tests/smoke-github-pr-comment-tool.php @@ -108,7 +108,7 @@ protected function buildErrorResponse( string $message, string $tool_name ): arr $ability_source = file_get_contents(__DIR__ . '/../inc/Abilities/GitHubAbilities.php'); $tool_source = file_get_contents(__DIR__ . '/../inc/Tools/GitHubTools.php'); - $assert('PR comment ability is registered', str_contains($ability_source, 'datamachine/comment-github-pull-request')); + $assert('PR comment ability is registered', str_contains($ability_source, 'datamachine-code/comment-github-pull-request')); $assert('ability executes commentOnPullRequest', str_contains($ability_source, "'execute_callback' => array( self::class, 'commentOnPullRequest' )")); $assert('tool is included in configuration checks', str_contains($tool_source, "'comment_github_pull_request'")); diff --git a/tests/smoke-github-readonly-tools.php b/tests/smoke-github-readonly-tools.php index a0ffbe7..192c2f6 100644 --- a/tests/smoke-github-readonly-tools.php +++ b/tests/smoke-github-readonly-tools.php @@ -343,7 +343,7 @@ public function execute( array $parameters ): array $get_file_definition = $tools->registered['get_github_file']['definition_callback'] ?? null; $get_file_definition = $get_file_definition ? call_user_func($get_file_definition) : array(); $assert('get_github_file does not expose file writes', ! $param_exists($get_file_definition, 'commit_message')); - $assert('get_github_file uses clean plural output', str_contains($ability_source, "'datamachine/get-github-file'") && str_contains($ability_source, "'files'") && str_contains($ability_source, 'normalizeFileContentPaths')); + $assert('get_github_file uses clean plural output', str_contains($ability_source, "'datamachine-code/get-github-file'") && str_contains($ability_source, "'files'") && str_contains($ability_source, 'normalizeFileContentPaths')); $assert('get_github_file supports path or paths', str_contains($tool_source, "'paths'") && str_contains($tool_source, "'path'") && str_contains($tool_source, "'max_total_size'")); $assert('list_github_tree supports optional path filtering', str_contains($ability_source, '$path_prefix')); diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index c9134a6..321260c 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -834,6 +834,7 @@ public function execute( array $input ): array $active_finalized_ability = new FakeActiveNoSignalAbility('finalized-apply'); $active_equivalent_clean_ability = new FakeActiveNoSignalAbility('equivalent-clean-apply'); $active_merged_ability = new FakeActiveNoSignalAbility('merged-apply'); + $active_remote_clean_ability = new FakeActiveNoSignalAbility('remote-clean-apply'); $reconcile_metadata_ability = new FakeReconcileMetadataAbility(); $bounded_apply_ability = new FakeBoundedCleanupEligibleApplyAbility(); $prune_ability = new FakePruneAbility(); @@ -857,6 +858,7 @@ public function execute( array $input ): array 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply' => $active_finalized_ability, 'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply' => $active_equivalent_clean_ability, 'datamachine-code/workspace-worktree-active-no-signal-merged-apply' => $active_merged_ability, + 'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply' => $active_remote_clean_ability, 'datamachine-code/workspace-worktree-reconcile-metadata' => $reconcile_metadata_ability, 'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply' => $bounded_apply_ability, 'datamachine-code/workspace-worktree-prune' => $prune_ability, @@ -1085,6 +1087,13 @@ public function execute( array $input ): array $command->worktree(array( 'active-no-signal-merged-apply' ), array( 'dry-run' => true, 'limit' => 5, 'offset' => 10, 'until-budget' => '30s', 'format' => 'json' )); datamachine_code_cleanup_assert('30s' === ( $active_merged_ability->last_input['until_budget'] ?? '' ), 'merged active/no-signal apply forwards time budget'); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'active-no-signal-remote-clean-apply' ), array( 'dry-run' => true, 'limit' => 5, 'offset' => 10, 'until-budget' => '30s', 'format' => 'json' )); + datamachine_code_cleanup_assert('30s' === ( $active_remote_clean_ability->last_input['until_budget'] ?? '' ), 'remote-clean active/no-signal apply forwards time budget'); + $active_remote_clean_json = json_decode(WP_CLI::$logs[0] ?? '', true); + datamachine_code_cleanup_assert(str_contains($active_remote_clean_json['pagination']['next_command'] ?? '', 'active-no-signal-remote-clean-apply --dry-run'), 'remote-clean active/no-signal JSON continuation stays on dry-run apply'); + WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree(array( 'active-no-signal-finalized-apply' ), array( 'dry-run' => true, 'limit' => 5, 'offset' => 10, 'until-budget' => '30s' )); @@ -1104,6 +1113,7 @@ public function execute( array $input ): array $abandoned_forwarded_budget = (string) ( $active_finalized_ability->last_input['until_budget'] ?? '' ); datamachine_code_cleanup_assert(1 === preg_match('/^\d+s$/', $abandoned_forwarded_budget) && (int) $abandoned_forwarded_budget <= 30, 'abandoned forwards remaining time budget to active/no-signal marking'); datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($active_finalized_ability->inputs, -2)), 'abandoned drains active/no-signal classifier pages in apply mode'); + datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($active_remote_clean_ability->inputs, -2)), 'abandoned drains remote-clean active/no-signal classifier pages before bounded cleanup'); datamachine_code_cleanup_assert(true === ( $bounded_apply_ability->last_input['force'] ?? null ), 'abandoned forwards force only to bounded cleanup removal'); datamachine_code_cleanup_assert(false === ( $bounded_apply_ability->last_input['dry_run'] ?? null ), 'abandoned --apply removes eligible rows'); datamachine_code_cleanup_assert(1 === $prune_ability->calls, 'abandoned prunes stale git metadata after cleanup pass'); @@ -1122,6 +1132,14 @@ public function execute( array $input ): array datamachine_code_cleanup_assert($reconcile_call_count === count($reconcile_metadata_ability->inputs), 'abandoned resume skips completed reconciliation stage'); datamachine_code_cleanup_assert(7 === (int) ( $active_finalized_ability->last_input['offset'] ?? -1 ), 'abandoned resume forwards offset to requested classifier stage'); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'abandoned' ), array( 'apply' => true, 'stage' => 'remote-clean', 'offset' => 11, 'limit' => 1, 'passes' => 1, 'format' => 'json' )); + $abandoned_remote_clean_resume_json = json_decode(WP_CLI::$logs[0] ?? '', true); + datamachine_code_cleanup_assert(JSON_ERROR_NONE === json_last_error(), 'abandoned remote-clean resume JSON output parses cleanly'); + datamachine_code_cleanup_assert('remote-clean' === ( $abandoned_remote_clean_resume_json['stage'] ?? '' ), 'abandoned remote-clean resume reports requested stage'); + datamachine_code_cleanup_assert(11 === (int) ( $active_remote_clean_ability->last_input['offset'] ?? -1 ), 'abandoned resume forwards offset to remote-clean stage'); + $reconcile_metadata_ability->stall_at_offset = 90; WP_CLI::$logs = array(); WP_CLI::$successes = array(); diff --git a/tests/smoke-worktree-metadata-reconcile.php b/tests/smoke-worktree-metadata-reconcile.php index e78e930..c7c7d68 100644 --- a/tests/smoke-worktree-metadata-reconcile.php +++ b/tests/smoke-worktree-metadata-reconcile.php @@ -924,7 +924,7 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $assert('contained_non_default_remote', $merged_rows['demo@non-default-contained']['upstream_equivalence']['effective_status'] ?? '', 'clean non-default-contained branch exposes containment evidence'); $assert('unsafe_dirty_or_unpushed', $merged_rows['demo@dirty-default-merged']['suggested_action'] ?? '', 'dirty contained branch remains unsafe'); $assert('unsafe_dirty_or_unpushed', $merged_rows['demo@unpushed-default-merged']['suggested_action'] ?? '', 'unpushed contained branch remains unsafe'); - $assert('no_pr_branch_review', $merged_rows['demo@ambiguous-default']['suggested_action'] ?? '', 'ambiguous unmerged branch remains manual review'); + $assert('remote_tracking_clean', $merged_rows['demo@ambiguous-default']['suggested_action'] ?? '', 'clean remote-tracking branch is classified as cleanup-safe local-only checkout'); $merged_dry_run = $ws->worktree_active_no_signal_merged_apply(array( 'dry_run' => true, 'limit' => 100, 'offset' => 0 )); $assert(true, ! is_wp_error($merged_dry_run) && ( $merged_dry_run['success'] ?? false ), 'merged-to-default active/no-signal dry-run succeeds'); @@ -964,7 +964,21 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $assert('contained-non-default-remote', $stored_non_default['cleanup_eligibility_evidence']['signal'] ?? '', 'non-default containment apply records containment signal'); $assert('', \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@dirty-default-merged')['cleanup_eligible_at'] ?? '', 'dirty merged-to-default row remains active'); $assert('', \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@unpushed-default-merged')['cleanup_eligible_at'] ?? '', 'unpushed merged-to-default row remains active'); - $assert('', \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@ambiguous-default')['cleanup_eligible_at'] ?? '', 'ambiguous row remains active'); + $remote_clean_dry_run = $ws->worktree_active_no_signal_remote_clean_apply(array( 'dry_run' => true, 'limit' => 100, 'offset' => 0 )); + $assert(true, ! is_wp_error($remote_clean_dry_run) && ( $remote_clean_dry_run['success'] ?? false ), 'remote-clean active/no-signal dry-run succeeds'); + $assert(true, 1 <= (int) ( $remote_clean_dry_run['summary']['planned'] ?? 0 ), 'remote-clean dry-run plans clean remote-tracking local checkouts'); + $assert(true, in_array('demo@ambiguous-default', array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), (array) ( $remote_clean_dry_run['planned'] ?? array() )), true), 'remote-clean dry-run includes clean remote-tracking target row'); + $assert('', \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@ambiguous-default')['cleanup_eligible_at'] ?? '', 'remote-clean dry-run leaves metadata unchanged'); + $remote_clean_apply = $ws->worktree_active_no_signal_remote_clean_apply(array( 'limit' => 100, 'offset' => 0 )); + $assert(true, ! is_wp_error($remote_clean_apply) && ( $remote_clean_apply['success'] ?? false ), 'remote-clean active/no-signal apply succeeds'); + $assert(true, 1 <= (int) ( $remote_clean_apply['summary']['written'] ?? 0 ), 'remote-clean apply writes clean remote-tracking metadata'); + $assert(true, in_array('demo@ambiguous-default', array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), (array) ( $remote_clean_apply['written'] ?? array() )), true), 'remote-clean apply writes target clean remote-tracking metadata'); + $stored_ambiguous = \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@ambiguous-default'); + $assert('cleanup_eligible', $stored_ambiguous['lifecycle_state'] ?? '', 'remote-clean apply stores cleanup_eligible state'); + $assert('remote-tracking-clean', $stored_ambiguous['cleanup_eligibility_evidence']['signal'] ?? '', 'remote-clean apply records remote tracking evidence signal'); + $assert('refs/remotes/origin/ambiguous-default', $stored_ambiguous['cleanup_eligibility_evidence']['remote_ref'] ?? '', 'remote-clean apply records preserving remote ref'); + $assert('merged-to-default', \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@default-merged')['cleanup_eligibility_evidence']['signal'] ?? '', 'remote-clean apply does not overwrite merged-to-default evidence'); + $assert('upstream-equivalent-clean', \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata('demo@patch-equivalent-default')['cleanup_eligibility_evidence']['signal'] ?? '', 'remote-clean apply does not overwrite upstream-equivalent evidence'); if ($failures > 0 ) { echo "\n{$failures} / {$total} assertions failed.\n";