Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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' ),
),
Expand Down Expand Up @@ -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'];
Expand All @@ -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'] ?? '',
Expand All @@ -3907,15 +3917,17 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
$rebase_base,
$force,
$task,
$allow_unverified_freshness
$allow_unverified_freshness,
$require_task_tracker
);
}

if ( RemoteWorkspaceBackend::should_handle() ) {
$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);
Expand All @@ -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
);
}

Expand Down
8 changes: 5 additions & 3 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 <repo> <branch> [--from=<ref>|--base=<ref>|--base-ref=<ref>|--base-branch=<branch>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force]');
WP_CLI::error('Usage: worktree add <repo> <branch> [--from=<ref>|--base=<ref>|--base-ref=<ref>|--base-branch=<branch>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force] [--task-url=<url>|--task-ref=<ref>] [--require-task-tracker]');
return;
}
$input['repo'] = $args[1];
Expand Down Expand Up @@ -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'];
}
Expand Down
4 changes: 3 additions & 1 deletion inc/CodeTask/CodeTaskCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
2 changes: 1 addition & 1 deletion inc/CodeTask/CodeTaskWorkspaceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function clone_repo( string $url, string $name ): array|\WP_Error;
/**
* @return array<string,mixed>|\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;
}
4 changes: 2 additions & 2 deletions inc/CodeTask/WorkspaceCodeTaskWorkspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions inc/Tools/WorkspaceTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
3 changes: 2 additions & 1 deletion inc/Workspace/RemoteWorkspaceBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public function clone_repo( string $url, ?string $name = null ): array|\WP_Error
*
* @return array<string,mixed>|\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;
Expand All @@ -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' => '',
Expand Down
12 changes: 11 additions & 1 deletion inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ));
Expand Down
12 changes: 7 additions & 5 deletions inc/Workspace/WorktreeContextInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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).
Expand All @@ -1486,7 +1486,7 @@ private static function is_url_subkey( string $subkey ): bool {
* @param array<string,mixed> $args Worktree creation context.
* @return array<string,mixed>|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']) : '';
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
42 changes: 41 additions & 1 deletion tests/worktree-add-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
74 changes: 74 additions & 0 deletions tests/worktree-add-tool-tracker-contract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace DataMachine\Engine\AI\Tools {
class BaseTool {
private function progressDefinition( array $definition ): array {
return $definition;
}

private function executeAbility( string $ability, string $tool, array $input, array $required ): array {
return $input;
}
}
}

namespace {
if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

final class Worktree_Add_Tool_Tracker_Ability {
public array $input = array();

public function execute( array $input ): array {
$this->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);
}
}