diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 5b2f52b8..3049865e 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -25,6 +25,7 @@ use DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator; use DataMachineCode\Workspace\WorkspaceReader; use DataMachineCode\Workspace\WorkspaceWriter; +use DataMachineCode\Workspace\WorktreeContextInjector; use DataMachineCode\Support\GitRunner; use DataMachineCode\Support\RuntimeCapabilities; @@ -1344,6 +1345,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Optional short task/issue reference (e.g. `org/repo#123`) recorded alongside task_url. Falls back to DATAMACHINE_TASK_REF env when omitted.', ), + 'require_task_tracker' => array( + 'type' => 'boolean', + 'description' => 'Require a valid task URL or task reference before creating the worktree. Defaults true; trusted operator-local callers may explicitly set false.', + ), ), 'required' => array( 'repo', 'branch' ), ), @@ -3886,7 +3891,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error { $allow_unverified_freshness = array_key_exists('allow_unverified_freshness', $input) ? (bool) $input['allow_unverified_freshness'] : false; // Default rebase_base=false; only true when explicitly requested. $rebase_base = array_key_exists('rebase_base', $input) ? (bool) $input['rebase_base'] : false; - $force = ! empty($input['force']); + $force = ! empty($input['force']); + $require_task_tracker = array_key_exists('require_task_tracker', $input) ? (bool) $input['require_task_tracker'] : true; $task = array(); if ( isset($input['task_url']) && '' !== trim( (string) $input['task_url']) ) { $task['task_url'] = (string) $input['task_url']; @@ -3896,6 +3902,10 @@ public static function worktreeAdd( array $input ): array|\WP_Error { } $workspace = new Workspace(); + $task = WorktreeContextInjector::resolve_task_metadata($task) ?? array(); + if ( $require_task_tracker && empty($task) && RemoteWorkspaceBackend::should_handle() && ! self::hasLocalPrimaryCheckout($workspace, (string) ( $input['repo'] ?? '' )) ) { + return new \WP_Error('worktree_task_tracker_required', 'Refusing to create a managed worktree without a valid task URL or task reference.', array( 'status' => 400 )); + } if ( RemoteWorkspaceBackend::should_handle() && self::hasLocalPrimaryCheckout($workspace, (string) ( $input['repo'] ?? '' )) ) { return $workspace->worktree_add( $input['repo'] ?? '', @@ -3907,7 +3917,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error { $rebase_base, $force, $task, - $allow_unverified_freshness + $allow_unverified_freshness, + $require_task_tracker ); } @@ -3915,7 +3926,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error { $result = ( new RemoteWorkspaceBackend() )->worktree_add( $input['repo'] ?? '', $input['branch'] ?? '', - $input['from'] ?? null + $input['from'] ?? null, + $task ); if ( ! self::shouldFallbackToLocalWorkspace($result) ) { return self::decorate_remote_workspace_result('worktree_add', $result); @@ -3932,7 +3944,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error { $rebase_base, $force, $task, - $allow_unverified_freshness + $allow_unverified_freshness, + $require_task_tracker ); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index ab5b1f11..cb37ec8f 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -3889,8 +3889,8 @@ private function renderGitOperationResult( string $operation, array $result, arr * wp datamachine-code workspace worktree finalize data-machine@fix-foo --pr=https://github.com/org/repo/pull/123 * wp datamachine-code workspace worktree mark-cleanup-eligible data-machine@fix-foo * - * # Record a task/issue URL on a worktree for ownership tracking - * wp datamachine-code workspace worktree add data-machine fix/foo --task-url=https://github.com/org/repo/issues/42 + * # Require a task/issue URL before creating an agent-managed worktree + * wp datamachine-code workspace worktree add data-machine fix/foo --task-url=https://github.com/org/repo/issues/42 --require-task-tracker * * @subcommand worktree */ @@ -3990,7 +3990,7 @@ public function worktree( array $args, array $assoc_args ): void { switch ( $operation ) { case 'add': if ( empty($args[1]) || empty($args[2]) ) { - WP_CLI::error('Usage: worktree add [--from=|--base=|--base-ref=|--base-branch=] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force]'); + WP_CLI::error('Usage: worktree add [--from=|--base=|--base-ref=|--base-branch=] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force] [--task-url=|--task-ref=] [--require-task-tracker]'); return; } $input['repo'] = $args[1]; @@ -4028,6 +4028,8 @@ public function worktree( array $args, array $assoc_args ): void { $input['rebase_base'] = ! empty($assoc_args['rebase-base']); // --force is an explicit disk-budget override for add. $input['force'] = ! empty($assoc_args['force']); + // CLI is the explicit operator-local path; strict tracking is opt-in. + $input['require_task_tracker'] = ! empty($assoc_args['require-task-tracker']); if ( isset($assoc_args['task-url']) && '' !== trim( (string) $assoc_args['task-url']) ) { $input['task_url'] = (string) $assoc_args['task-url']; } diff --git a/inc/CodeTask/CodeTaskCreator.php b/inc/CodeTask/CodeTaskCreator.php index cc8a9dc9..55cf5052 100644 --- a/inc/CodeTask/CodeTaskCreator.php +++ b/inc/CodeTask/CodeTaskCreator.php @@ -70,7 +70,9 @@ public function create( EvidencePacket $packet, array $args = array() ): array|\ true, ! empty($args['allow_stale']), false, - ! empty($args['force']) + ! empty($args['force']), + array( 'task_url' => $packet->source_url() ), + true ); if ( $worktree instanceof \WP_Error ) { diff --git a/inc/CodeTask/CodeTaskWorkspaceInterface.php b/inc/CodeTask/CodeTaskWorkspaceInterface.php index 97f15cb1..a143442a 100644 --- a/inc/CodeTask/CodeTaskWorkspaceInterface.php +++ b/inc/CodeTask/CodeTaskWorkspaceInterface.php @@ -26,7 +26,7 @@ public function clone_repo( string $url, string $name ): array|\WP_Error; /** * @return array|\WP_Error */ - public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force ): array|\WP_Error; + public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force, array $task, bool $require_task_tracker ): array|\WP_Error; public function get_repo_path( string $handle ): string; } diff --git a/inc/CodeTask/WorkspaceCodeTaskWorkspace.php b/inc/CodeTask/WorkspaceCodeTaskWorkspace.php index f9724d47..3caa4af7 100644 --- a/inc/CodeTask/WorkspaceCodeTaskWorkspace.php +++ b/inc/CodeTask/WorkspaceCodeTaskWorkspace.php @@ -29,8 +29,8 @@ public function clone_repo( string $url, string $name ): array|\WP_Error { return $this->workspace->clone_repo($url, $name); } - public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force ): array|\WP_Error { - return $this->workspace->worktree_add($repo, $branch, $from, $inject_context, $bootstrap, $allow_stale, $rebase_base, $force); + public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force, array $task, bool $require_task_tracker ): array|\WP_Error { + return $this->workspace->worktree_add($repo, $branch, $from, $inject_context, $bootstrap, $allow_stale, $rebase_base, $force, $task, false, $require_task_tracker); } public function get_repo_path( string $handle ): string { diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index 4c0b59e3..bf1127a7 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -550,8 +550,9 @@ public function handleGitPull( array $parameters ): array public function handleWorktreeAdd( array $parameters ): array { $input = array( - 'repo' => $parameters['repo'] ?? '', - 'branch' => $parameters['branch'] ?? '', + 'repo' => $parameters['repo'] ?? '', + 'branch' => $parameters['branch'] ?? '', + 'require_task_tracker' => true, ); foreach ( array( 'from', 'task_url', 'task_ref' ) as $key ) { diff --git a/inc/Workspace/RemoteWorkspaceBackend.php b/inc/Workspace/RemoteWorkspaceBackend.php index 24a2a4d9..2ea9bb4b 100644 --- a/inc/Workspace/RemoteWorkspaceBackend.php +++ b/inc/Workspace/RemoteWorkspaceBackend.php @@ -107,7 +107,7 @@ public function clone_repo( string $url, ?string $name = null ): array|\WP_Error * * @return array|\WP_Error */ - public function worktree_add( string $repo_name, string $branch, ?string $from = null ): array|\WP_Error { + public function worktree_add( string $repo_name, string $branch, ?string $from = null, array $task = array() ): array|\WP_Error { $repo = $this->resolve_repo($repo_name); if ( is_wp_error($repo) ) { return $repo; @@ -126,6 +126,7 @@ public function worktree_add( string $repo_name, string $branch, ?string $from = 'repo' => $repo, 'branch' => $branch, 'base_ref' => null !== $from && '' !== $from ? $from : '', + 'task' => $task, 'pending_files' => array(), 'changed_files' => array(), 'last_commit_sha' => '', diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 3eb44375..1da5258e 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -70,9 +70,10 @@ trait WorkspaceWorktreeLifecycle { * @param bool $force Bypass the disk-budget refusal threshold (default false). * @param array $task Optional task metadata recorded on the worktree. * @param bool $allow_unverified_freshness Bypass fetch-failure freshness verification (default false). + * @param bool $require_task_tracker Reject creation without task metadata (default false). * @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, disk_budget?: array, context_injected?: bool, context_files?: string[], context_skip_reason?: string, bootstrap?: array, fetch_failed?: bool, fetch_error?: string, stale_commits_behind?: int, upstream?: string, base_stale_commits_behind?: int, base_upstream?: string, default_branch_commits_behind?: int, default_branch_ref?: string, gate_threshold?: int, rebase_attempted?: bool, rebase_succeeded?: bool, rebase_error?: string, rebase_target?: string}|\WP_Error */ - public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false ): array|\WP_Error { + public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false, bool $require_task_tracker = false ): array|\WP_Error { $visible = $this->require_workspace_visible(); if ( null !== $visible ) { return $visible; @@ -92,6 +93,15 @@ public function worktree_add( string $repo, string $branch, ?string $from = null return new \WP_Error('invalid_branch', 'Branch name is required.', array( 'status' => 400 )); } + $task = WorktreeContextInjector::resolve_task_metadata($task) ?? array(); + if ( $require_task_tracker && empty($task) ) { + return new \WP_Error( + 'worktree_task_tracker_required', + 'Refusing to create a managed worktree without a valid task URL or task reference. Supply task_url or task_ref, or use an operator-local creation path that does not require a tracker.', + array( 'status' => 400 ) + ); + } + $slug = $this->slugify_branch($branch); if ( '' === $slug ) { return new \WP_Error('invalid_branch', sprintf('Branch "%s" produced an empty slug.', $branch), array( 'status' => 400 )); diff --git a/inc/Workspace/WorktreeContextInjector.php b/inc/Workspace/WorktreeContextInjector.php index 66e5691c..cede309f 100644 --- a/inc/Workspace/WorktreeContextInjector.php +++ b/inc/Workspace/WorktreeContextInjector.php @@ -161,7 +161,7 @@ public static function build_lifecycle_metadata( array $args = array() ): array $user = self::resolve_origin_user(); $agent = self::resolve_origin_agent(); $session = self::resolve_origin_session(); - $task = self::resolve_origin_task($args); + $task = self::resolve_task_metadata($args); $created_at = gmdate('c'); @@ -1474,7 +1474,7 @@ private static function is_url_subkey( string $subkey ): bool { } /** - * Resolve a task/issue reference for the worktree, when available. + * Resolve valid task metadata for the worktree, when available. * * Sources, in order: * 1. Caller-supplied `task_url` / `task_ref` in `$args` (explicit wins). @@ -1486,7 +1486,7 @@ private static function is_url_subkey( string $subkey ): bool { * @param array $args Worktree creation context. * @return array|null */ - private static function resolve_origin_task( array $args = array() ): ?array { + public static function resolve_task_metadata( array $args = array() ): ?array { $task = array(); $task_url = isset($args['task_url']) && '' !== trim( (string) $args['task_url']) ? trim( (string) $args['task_url']) : ''; @@ -1496,7 +1496,8 @@ private static function resolve_origin_task( array $args = array() ): ?array { $task_url = trim($env_task_url); } } - if ( '' !== $task_url && preg_match('#^https?://#', $task_url) ) { + $task_url_parts = '' !== $task_url ? parse_url($task_url) : false; + if ( is_array($task_url_parts) && isset($task_url_parts['host'], $task_url_parts['scheme']) && in_array(strtolower((string) $task_url_parts['scheme']), array( 'http', 'https' ), true) ) { $task['task_url'] = $task_url; } @@ -1507,7 +1508,8 @@ private static function resolve_origin_task( array $args = array() ): ?array { $task_ref = trim($env_task_ref); } } - if ( '' !== $task_ref ) { + $normalized_task_ref = strtolower($task_ref); + if ( '' !== $normalized_task_ref && ! preg_match('/\s/', $normalized_task_ref) ) { $task['task_ref'] = $task_ref; } diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index dcb5a020..3b48bc6a 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -289,11 +289,51 @@ function create_primary_checkout( string $workspace_root ): void { assert_true(str_contains($refused->get_error_message(), 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25'), 'disk pressure refusal must include the next cleanup command'); assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-disk-refused'), 'disk pressure refusal left a worktree directory behind'); - $result = $workspace->worktree_add('homeboy', 'audit-primitives-20260616', 'origin/main', false, false, false, false, true); + $ability_default = WorkspaceAbilities::worktreeAdd( + array( + 'repo' => 'homeboy', + 'branch' => 'ability-default-tracker-required', + 'from' => 'origin/main', + 'inject_context' => false, + 'bootstrap' => false, + 'force' => true, + ) + ); + assert_true(is_wp_error($ability_default), 'agent-facing worktree ability accepted missing tracker metadata by default'); + assert_true('worktree_task_tracker_required' === $ability_default->get_error_code(), 'agent-facing worktree ability returned an unexpected error code'); + assert_true(! is_dir($workspace_root . '/homeboy@ability-default-tracker-required'), 'agent-facing tracker refusal left a worktree directory behind'); + + $ability_operator_local = WorkspaceAbilities::worktreeAdd( + array( + 'repo' => 'homeboy', + 'branch' => 'ability-operator-local', + 'from' => 'origin/main', + 'inject_context' => false, + 'bootstrap' => false, + 'force' => true, + 'require_task_tracker' => false, + ) + ); + assert_true(! is_wp_error($ability_operator_local), is_wp_error($ability_operator_local) ? $ability_operator_local->get_error_message() : 'explicit operator-local ability creation failed'); + assert_true(is_dir($workspace_root . '/homeboy@ability-operator-local'), 'explicit operator-local ability creation did not materialize a worktree'); + + $strict_missing = $workspace->worktree_add('homeboy', 'audit-primitives-tracker-required', 'origin/main', false, false, false, false, true, array(), false, true); + assert_true(is_wp_error($strict_missing), 'strict worktree creation accepted missing tracker metadata'); + assert_true('worktree_task_tracker_required' === $strict_missing->get_error_code(), 'strict worktree creation returned an unexpected error code'); + assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-tracker-required'), 'strict tracker refusal left a worktree directory behind'); + + putenv('DATAMACHINE_TASK_URL=https://example.test/issues/environment'); + $result = $workspace->worktree_add('homeboy', 'audit-primitives-20260616', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/explicit' ), false, true); assert_true(! is_wp_error($result), is_wp_error($result) ? $result->get_error_message() : 'worktree_add failed'); assert_true(is_dir($result['path']), 'successful worktree_add path is not accessible'); assert_true(isset($wpdb->rows['homeboy@audit-primitives-20260616']), 'successful worktree_add was not persisted'); assert_true('refused' !== ( $result['disk_budget']['status'] ?? '' ), 'normal worktree_add should pass the disk budget gate without hard refusal'); + assert_true('https://example.test/issues/explicit' === ( $wpdb->rows['homeboy@audit-primitives-20260616']['task_url'] ?? '' ), 'explicit tracker metadata did not override the environment fallback'); + + $environment_tracker = $workspace->worktree_add('homeboy', 'audit-primitives-environment-tracker', 'origin/main', false, false, false, false, true, array(), false, true); + assert_true(! is_wp_error($environment_tracker), is_wp_error($environment_tracker) ? $environment_tracker->get_error_message() : 'environment tracker fallback failed'); + assert_true('https://example.test/issues/environment' === ( $wpdb->rows['homeboy@audit-primitives-environment-tracker']['task_url'] ?? '' ), 'environment tracker metadata was not persisted'); + putenv('DATAMACHINE_TASK_URL'); $show = $workspace->show_repo('homeboy@audit-primitives-20260616'); assert_true(! is_wp_error($show), 'persisted worktree is not visible to show_repo'); diff --git a/tests/worktree-add-tool-tracker-contract.php b/tests/worktree-add-tool-tracker-contract.php new file mode 100644 index 00000000..d2bd329c --- /dev/null +++ b/tests/worktree-add-tool-tracker-contract.php @@ -0,0 +1,74 @@ +input = $input; + return array( 'success' => true ); + } + } + + $worktree_add_tool_tracker_ability = new Worktree_Add_Tool_Tracker_Ability(); + function wp_get_ability( string $name ): Worktree_Add_Tool_Tracker_Ability { + global $worktree_add_tool_tracker_ability; + return $worktree_add_tool_tracker_ability; + } + + function is_wp_error( mixed $value ): bool { + return false; + } + + require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceAliasResolver.php'; + require_once dirname(__DIR__) . '/inc/Tools/WorkspaceTools.php'; + + final class Worktree_Add_Tool_Tracker_Contract extends \DataMachineCode\Tools\WorkspaceTools { + public function __construct() { + } + } + + function worktree_add_tool_tracker_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new \RuntimeException($message); + } + } + + try { + $tool = new Worktree_Add_Tool_Tracker_Contract(); + $tool->handleWorktreeAdd( + array( + 'repo' => 'example', + 'branch' => 'feature/tracked', + 'require_task_tracker' => false, + ) + ); + + worktree_add_tool_tracker_assert(true === ( $worktree_add_tool_tracker_ability->input['require_task_tracker'] ?? null ), 'agent tool allowed tracker enforcement to be disabled'); + $definition = $tool->getWorktreeAddDefinition(); + $properties = (array) ( $definition['parameters']['properties'] ?? array() ); + worktree_add_tool_tracker_assert(! array_key_exists('require_task_tracker', $properties), 'agent tool schema exposes a tracker-enforcement override'); + fwrite(STDOUT, "worktree-add-tool-tracker-contract ok\n"); + } catch (\Throwable $e) { + fwrite(STDERR, $e->getMessage() . "\n"); + exit(1); + } +}