diff --git a/inc/Cli/Commands/AgentBundleCommand.php b/inc/Cli/Commands/AgentBundleCommand.php index c4d5a2d44..cbfc2c1d7 100644 --- a/inc/Cli/Commands/AgentBundleCommand.php +++ b/inc/Cli/Commands/AgentBundleCommand.php @@ -808,6 +808,10 @@ private function bundle_artifacts_for_agent( array $bundle, ?array $agent ): arr ); } + foreach ( self::bundle_file_artifacts( $bundle ) as $artifact ) { + $artifacts[] = $artifact; + } + foreach ( AgentBundleArtifactExtensions::normalize_artifacts( is_array( $bundle['extension_artifacts'] ?? null ) ? $bundle['extension_artifacts'] : array() ) as $artifact ) { $artifacts[] = $artifact; } @@ -868,6 +872,7 @@ private function current_artifacts( array $agent, array $installed ): array { $artifacts = array_merge( $artifacts, + \DataMachine\Engine\AI\System\SystemTaskPromptRegistry::current_artifacts(), AgentBundleArtifactExtensions::current_artifacts( $agent, $installed, @@ -878,6 +883,45 @@ private function current_artifacts( array $agent, array $installed ): array { return $artifacts; } + /** @return array> */ + private static function bundle_file_artifacts( array $bundle ): array { + $artifacts = array(); + $files = is_array( $bundle['artifact_files'] ?? null ) ? $bundle['artifact_files'] : array(); + + foreach ( self::bundle_file_artifact_directories() as $directory => $type ) { + foreach ( is_array( $files[ $directory ] ?? null ) ? $files[ $directory ] : array() as $relative_path => $payload ) { + $artifact_id = is_array( $payload ) && is_string( $payload['artifact_id'] ?? null ) + ? (string) $payload['artifact_id'] + : self::artifact_id_from_relative_path( (string) $relative_path ); + + $artifacts[] = array( + 'artifact_type' => $type, + 'artifact_id' => $artifact_id, + 'source_path' => $directory . '/' . ltrim( (string) $relative_path, '/' ), + 'payload' => $payload, + ); + } + } + + return $artifacts; + } + + /** @return array */ + private static function bundle_file_artifact_directories(): array { + return array( + \DataMachine\Engine\Bundle\BundleSchema::PROMPTS_DIR => 'prompt', + \DataMachine\Engine\Bundle\BundleSchema::RUBRICS_DIR => 'rubric', + \DataMachine\Engine\Bundle\BundleSchema::TOOL_POLICIES_DIR => 'tool_policy', + \DataMachine\Engine\Bundle\BundleSchema::AUTH_REFS_DIR => 'auth_ref', + \DataMachine\Engine\Bundle\BundleSchema::SEED_QUEUES_DIR => 'seed_queue', + ); + } + + private static function artifact_id_from_relative_path( string $relative_path ): string { + $relative_path = preg_replace( '/\.(json|md|txt)$/i', '', $relative_path ); + return null === $relative_path ? '' : $relative_path; + } + private function pipeline_payload( array $pipeline, string $portable_slug ): array { return array( 'portable_slug' => $portable_slug, diff --git a/inc/Core/Agents/AgentBundler.php b/inc/Core/Agents/AgentBundler.php index b7c2177ac..ce72b4183 100644 --- a/inc/Core/Agents/AgentBundler.php +++ b/inc/Core/Agents/AgentBundler.php @@ -33,7 +33,9 @@ use DataMachine\Engine\Bundle\AgentPackageProjection; use DataMachine\Engine\Bundle\BundleValidationException; use DataMachine\Engine\Bundle\PortableSlug; +use DataMachine\Engine\Bundle\PromptArtifact; use DataMachine\Core\Steps\FlowStepConfig; +use DataMachine\Engine\AI\System\SystemTaskPromptRegistry; defined( 'ABSPATH' ) || exit; @@ -221,6 +223,9 @@ public function export_directory_object( string $slug, array $context = array() $pipeline_slugs = array_map( fn( AgentBundlePipelineFile $pipeline ) => $pipeline->slug(), $pipeline_documents ); $flow_slugs = array_map( fn( AgentBundleFlowFile $flow ) => $flow->slug(), $flow_documents ); + $artifact_files = array( + \DataMachine\Engine\Bundle\BundleSchema::PROMPTS_DIR => SystemTaskPromptRegistry::bundle_prompt_files(), + ); $extension_paths = array_map( static fn( array $artifact ) => (string) ( $artifact['source_path'] ?? '' ), $extension_artifacts ); $manifest = new AgentBundleManifest( gmdate( 'c' ), @@ -239,13 +244,14 @@ public function export_directory_object( string $slug, array $context = array() 'memory' => array_keys( $memory_files ), 'pipelines' => $pipeline_slugs, 'flows' => $flow_slugs, + 'prompts' => array_keys( $artifact_files[ \DataMachine\Engine\Bundle\BundleSchema::PROMPTS_DIR ] ), 'extensions' => array_values( array_filter( $extension_paths ) ), 'handler_auth' => $handler_auth, ) ); $extras = self::collect_export_extras( $agent_id, $agent ); - $directory = new AgentBundleDirectory( $manifest, $memory_files, $pipeline_documents, $flow_documents, array(), $extension_artifacts, $extras ); + $directory = new AgentBundleDirectory( $manifest, $memory_files, $pipeline_documents, $flow_documents, $artifact_files, $extension_artifacts, $extras ); return array( 'success' => true, @@ -933,7 +939,41 @@ public function import( array $bundle, ?string $new_slug = null, int $owner_id = ); } - // 6. Apply plugin-owned artifacts through their owning plugin. + // 6. Apply bundle-owned prompt artifacts without touching local overrides. + foreach ( self::bundle_file_artifacts( $bundle ) as $artifact ) { + if ( PromptArtifact::TYPE_PROMPT !== (string) $artifact['artifact_type'] ) { + continue; + } + + $artifact_key = self::artifact_key( (string) $artifact['artifact_type'], (string) $artifact['artifact_id'] ); + if ( SystemTaskPromptRegistry::has_local_override_for_artifact( $artifact ) ) { + $conflicts[] = array( + 'artifact_type' => $artifact['artifact_type'], + 'artifact_id' => $artifact['artifact_id'], + 'reason' => 'local_modified', + ); + continue; + } + + if ( ! SystemTaskPromptRegistry::apply_bundle_artifact( $artifact ) ) { + $conflicts[] = array( + 'artifact_type' => $artifact['artifact_type'], + 'artifact_id' => $artifact['artifact_id'], + 'reason' => 'missing_apply_handler', + ); + continue; + } + + $artifact_records[ $artifact_key ] = $this->bundle_artifact_record( + $bundle_metadata, + (string) $artifact['artifact_type'], + (string) $artifact['artifact_id'], + (string) $artifact['source_path'], + $artifact['payload'] ?? null + ); + } + + // 7. Apply plugin-owned artifacts through their owning plugin. $agent_context = array_merge( $agent_data, array( @@ -1320,6 +1360,45 @@ private function bundle_artifact_record( array $bundle_metadata, string $type, s ); } + /** @return array> */ + private static function bundle_file_artifacts( array $bundle ): array { + $artifacts = array(); + $files = is_array( $bundle['artifact_files'] ?? null ) ? $bundle['artifact_files'] : array(); + + foreach ( self::bundle_file_artifact_directories() as $directory => $type ) { + foreach ( is_array( $files[ $directory ] ?? null ) ? $files[ $directory ] : array() as $relative_path => $payload ) { + $artifact_id = is_array( $payload ) && is_string( $payload['artifact_id'] ?? null ) + ? (string) $payload['artifact_id'] + : self::artifact_id_from_relative_path( (string) $relative_path ); + + $artifacts[] = array( + 'artifact_type' => $type, + 'artifact_id' => $artifact_id, + 'source_path' => $directory . '/' . ltrim( (string) $relative_path, '/' ), + 'payload' => $payload, + ); + } + } + + return $artifacts; + } + + /** @return array */ + private static function bundle_file_artifact_directories(): array { + return array( + \DataMachine\Engine\Bundle\BundleSchema::PROMPTS_DIR => 'prompt', + \DataMachine\Engine\Bundle\BundleSchema::RUBRICS_DIR => 'rubric', + \DataMachine\Engine\Bundle\BundleSchema::TOOL_POLICIES_DIR => 'tool_policy', + \DataMachine\Engine\Bundle\BundleSchema::AUTH_REFS_DIR => 'auth_ref', + \DataMachine\Engine\Bundle\BundleSchema::SEED_QUEUES_DIR => 'seed_queue', + ); + } + + private static function artifact_id_from_relative_path( string $relative_path ): string { + $relative_path = preg_replace( '/\.(json|md|txt)$/i', '', $relative_path ); + return null === $relative_path ? '' : $relative_path; + } + /** @param array> $artifacts */ private static function index_artifacts( array $artifacts ): array { $indexed = array(); diff --git a/inc/Engine/AI/System/SystemTaskPromptRegistry.php b/inc/Engine/AI/System/SystemTaskPromptRegistry.php index fdac6ac28..ce5aac3d3 100644 --- a/inc/Engine/AI/System/SystemTaskPromptRegistry.php +++ b/inc/Engine/AI/System/SystemTaskPromptRegistry.php @@ -10,12 +10,20 @@ defined( 'ABSPATH' ) || exit; use DataMachine\Engine\Bundle\PromptArtifact; +use DataMachine\Engine\Bundle\BundleSchema; +use DataMachine\Engine\Tasks\TaskRegistry; +use DataMachine\Engine\AI\System\Tasks\SystemTask; /** * Resolves versioned prompt artifacts for AI-backed system tasks. */ final class SystemTaskPromptRegistry { + /** + * Option key for bundle-installed system task prompt artifacts. + */ + private const INSTALLED_ARTIFACTS_OPTION = 'datamachine_system_task_prompt_artifacts'; + /** * Build the stable artifact ID for a system task prompt. */ @@ -49,6 +57,131 @@ public static function artifact_from_definition( string $task_type, string $prom return new PromptArtifact( $artifact_id, PromptArtifact::TYPE_PROMPT, $version, $source_path, $definition['default'], (string) ( $definition['changelog'] ?? '' ), $metadata ); } + /** + * Collect default system task prompt artifacts from registered tasks. + * + * @return array Artifact ID => artifact. + */ + public static function default_artifacts(): array { + $artifacts = array(); + + foreach ( TaskRegistry::getHandlers() as $task_type => $handler_class ) { + if ( ! class_exists( $handler_class ) ) { + continue; + } + + $task = new $handler_class(); + if ( ! $task instanceof SystemTask ) { + continue; + } + + foreach ( $task->getPromptDefinitions() as $prompt_key => $definition ) { + if ( ! is_array( $definition ) ) { + continue; + } + + $artifact = self::artifact_from_definition( (string) $task_type, (string) $prompt_key, $definition ); + if ( $artifact ) { + $artifacts[ $artifact->artifact_id() ] = self::installed_artifact_for( $artifact ) ?? $artifact; + } + } + } + + ksort( $artifacts, SORT_STRING ); + return $artifacts; + } + + /** + * Build bundle prompt files for system task prompt artifacts. + * + * @return array> Relative path under prompts/ => artifact payload. + */ + public static function bundle_prompt_files(): array { + $files = array(); + foreach ( self::default_artifacts() as $artifact ) { + $files[ self::bundle_relative_path( $artifact ) ] = $artifact->to_array(); + } + + ksort( $files, SORT_STRING ); + return $files; + } + + /** + * Report current runtime artifact state for upgrade planning. + * + * Local overrides deliberately change the reported payload hash so upgrade + * planning treats them as locally modified instead of overwriting them. + * + * @return array> + */ + public static function current_artifacts(): array { + $artifacts = array(); + $overrides = SystemTask::getAllPromptOverrides(); + + foreach ( self::default_artifacts() as $artifact ) { + $metadata = $artifact->version_metadata()['metadata'] ?? array(); + $task_type = is_array( $metadata ) ? (string) ( $metadata['task_type'] ?? '' ) : ''; + $prompt_key = is_array( $metadata ) ? (string) ( $metadata['prompt_key'] ?? '' ) : ''; + $override = $overrides[ $task_type ][ $prompt_key ] ?? null; + + $artifacts[] = array( + 'artifact_type' => PromptArtifact::TYPE_PROMPT, + 'artifact_id' => $artifact->artifact_id(), + 'source_path' => BundleSchema::PROMPTS_DIR . '/' . self::bundle_relative_path( $artifact ), + 'payload' => self::payload_with_override( $artifact, is_string( $override ) ? $override : null ), + ); + } + + return $artifacts; + } + + /** + * Apply a bundle-carried system task prompt artifact without touching overrides. + * + * @param array $artifact Artifact envelope. + * @return bool True when the artifact was accepted and stored. + */ + public static function apply_bundle_artifact( array $artifact ): bool { + if ( PromptArtifact::TYPE_PROMPT !== (string) ( $artifact['artifact_type'] ?? '' ) ) { + return false; + } + + $payload = is_array( $artifact['payload'] ?? null ) ? $artifact['payload'] : array(); + if ( empty( $payload ) ) { + return false; + } + + $prompt_artifact = PromptArtifact::from_array( $payload ); + if ( ! str_starts_with( $prompt_artifact->artifact_id(), 'system-task:' ) ) { + return false; + } + + $installed = self::installed_artifacts(); + $installed[ $prompt_artifact->artifact_id() ] = $prompt_artifact->to_array(); + + return update_option( self::INSTALLED_ARTIFACTS_OPTION, $installed, false ); + } + + /** + * Whether applying the target would collide with a local prompt override. + */ + public static function has_local_override_for_artifact( array $artifact ): bool { + $payload = is_array( $artifact['payload'] ?? null ) ? $artifact['payload'] : array(); + if ( empty( $payload ) ) { + return false; + } + + $metadata = is_array( $payload['metadata'] ?? null ) ? $payload['metadata'] : array(); + $task_type = (string) ( $metadata['task_type'] ?? '' ); + $prompt_key = (string) ( $metadata['prompt_key'] ?? '' ); + if ( '' === $task_type || '' === $prompt_key ) { + return false; + } + + $overrides = SystemTask::getAllPromptOverrides(); + return isset( $overrides[ $task_type ][ $prompt_key ] ) && '' !== (string) $overrides[ $task_type ][ $prompt_key ]; + } + /** * Resolve an effective task prompt through the artifact seam. * @@ -59,6 +192,7 @@ public static function artifact_from_definition( string $task_type, string $prom * @return array{content:string, artifact_id:string, content_hash:string, source:string, version:string, artifact:?PromptArtifact} */ public static function resolve_effective_prompt( string $task_type, string $prompt_key, ?PromptArtifact $artifact, ?string $override = null ): array { + $artifact = $artifact ? ( self::installed_artifact_for( $artifact ) ?? $artifact ) : null; $override = null === $override ? null : (string) $override; $content = null !== $override && '' !== $override ? $override : ( $artifact ? $artifact->content() : '' ); $source = null !== $override && '' !== $override ? 'override' : 'artifact'; @@ -102,4 +236,52 @@ public static function resolve_effective_prompt( string $task_type, string $prom 'artifact' => $resolved['artifact'] ?? $artifact, ); } + + private static function installed_artifact_for( PromptArtifact $default_artifact ): ?PromptArtifact { + $installed = self::installed_artifacts(); + $payload = $installed[ $default_artifact->artifact_id() ] ?? null; + if ( ! is_array( $payload ) ) { + return null; + } + + try { + $artifact = PromptArtifact::from_array( $payload ); + } catch ( \Throwable $e ) { + return null; + } + + return $artifact->artifact_id() === $default_artifact->artifact_id() ? $artifact : null; + } + + /** @return array> */ + private static function installed_artifacts(): array { + $stored = get_option( self::INSTALLED_ARTIFACTS_OPTION, array() ); + return is_array( $stored ) ? $stored : array(); + } + + private static function bundle_relative_path( PromptArtifact $artifact ): string { + $metadata = $artifact->version_metadata()['metadata'] ?? array(); + $task_type = is_array( $metadata ) ? sanitize_key( (string) ( $metadata['task_type'] ?? '' ) ) : ''; + $prompt_key = is_array( $metadata ) ? sanitize_key( (string) ( $metadata['prompt_key'] ?? '' ) ) : ''; + if ( '' !== $task_type && '' !== $prompt_key ) { + return 'system-tasks/' . $task_type . '/' . $prompt_key . '.json'; + } + + return 'system-tasks/' . sanitize_key( str_replace( ':', '-', $artifact->artifact_id() ) ) . '.json'; + } + + /** @return array */ + private static function payload_with_override( PromptArtifact $artifact, ?string $override ): array { + $payload = $artifact->to_array(); + if ( null === $override || '' === $override ) { + return $payload; + } + + $payload['content'] = $override; + $payload['content_hash'] = hash( 'sha256', $override ); + $payload['metadata'] = is_array( $payload['metadata'] ?? null ) ? $payload['metadata'] : array(); + $payload['metadata']['local_override'] = true; + + return $payload; + } } diff --git a/inc/Engine/Bundle/AgentBundleArrayAdapter.php b/inc/Engine/Bundle/AgentBundleArrayAdapter.php index e285bbf4f..4ebdb4e37 100644 --- a/inc/Engine/Bundle/AgentBundleArrayAdapter.php +++ b/inc/Engine/Bundle/AgentBundleArrayAdapter.php @@ -54,7 +54,7 @@ public static function from_array_bundle( array $bundle ): AgentBundleDirectory self::memory_files_from_array_bundle( $bundle, $pipeline_slugs, $flow_slugs ), self::pipeline_files_from_array_bundle( $bundle['pipelines'] ?? array(), $pipeline_slugs ), self::flow_files_from_array_bundle( $bundle['flows'] ?? array(), $bundle['pipelines'] ?? array(), $pipeline_slugs, $flow_slugs ), - array(), + self::artifact_files_from_array_bundle( $bundle ), is_array( $bundle['extension_artifacts'] ?? null ) ? $bundle['extension_artifacts'] : array(), is_array( $bundle['extras'] ?? null ) ? $bundle['extras'] : array() ); @@ -154,6 +154,7 @@ public static function to_array_bundle( AgentBundleDirectory $directory ): array 'user_template' => $memory_files['USER.md'] ?? '', 'pipelines' => $pipelines, 'flows' => $flows, + 'artifact_files' => self::artifact_files_from_directory( $directory ), 'extension_artifacts' => $directory->extension_artifacts(), 'extras' => $directory->extras(), 'abilities_manifest' => array(), @@ -166,6 +167,42 @@ public static function to_array_bundle( AgentBundleDirectory $directory ): array return $bundle; } + /** @return array> */ + private static function artifact_files_from_array_bundle( array $bundle ): array { + $artifact_files = is_array( $bundle['artifact_files'] ?? null ) ? $bundle['artifact_files'] : array(); + $normalized = array(); + + foreach ( self::artifact_file_directories() as $directory ) { + if ( is_array( $artifact_files[ $directory ] ?? null ) ) { + $normalized[ $directory ] = $artifact_files[ $directory ]; + } + } + + return $normalized; + } + + /** @return array> */ + private static function artifact_files_from_directory( AgentBundleDirectory $directory ): array { + return array( + BundleSchema::PROMPTS_DIR => $directory->prompts(), + BundleSchema::RUBRICS_DIR => $directory->rubrics(), + BundleSchema::TOOL_POLICIES_DIR => $directory->tool_policies(), + BundleSchema::AUTH_REFS_DIR => $directory->auth_refs(), + BundleSchema::SEED_QUEUES_DIR => $directory->seed_queues(), + ); + } + + /** @return string[] */ + private static function artifact_file_directories(): array { + return array( + BundleSchema::PROMPTS_DIR, + BundleSchema::RUBRICS_DIR, + BundleSchema::TOOL_POLICIES_DIR, + BundleSchema::AUTH_REFS_DIR, + BundleSchema::SEED_QUEUES_DIR, + ); + } + /** @param array> $pipelines */ private static function pipeline_slugs( array $pipelines ): array { $used = array(); diff --git a/inc/Engine/Bundle/AgentBundleUpgradePlanner.php b/inc/Engine/Bundle/AgentBundleUpgradePlanner.php index 2102fd79f..b42b46038 100644 --- a/inc/Engine/Bundle/AgentBundleUpgradePlanner.php +++ b/inc/Engine/Bundle/AgentBundleUpgradePlanner.php @@ -128,7 +128,7 @@ public static function artifacts_from_bundle( AgentBundleDirectory $bundle ): ar foreach ( $bundle->{$method}() as $relative_path => $payload ) { $artifacts[] = array( 'artifact_type' => $type, - 'artifact_id' => self::artifact_id_from_relative_path( (string) $relative_path ), + 'artifact_id' => self::artifact_id_from_payload( $payload, (string) $relative_path ), 'source_path' => $directory . '/' . $relative_path, 'payload' => $payload, ); @@ -158,6 +158,14 @@ private static function artifact_id_from_relative_path( string $relative_path ): return null === $relative_path ? '' : $relative_path; } + private static function artifact_id_from_payload( mixed $payload, string $relative_path ): string { + if ( is_array( $payload ) && is_string( $payload['artifact_id'] ?? null ) && '' !== trim( $payload['artifact_id'] ) ) { + return (string) $payload['artifact_id']; + } + + return self::artifact_id_from_relative_path( $relative_path ); + } + /** @param array|AgentBundleInstalledArtifact> $artifacts */ private static function index_installed_artifacts( array $artifacts ): array { $indexed = array(); diff --git a/tests/prompt-auth-template-artifacts-smoke.php b/tests/prompt-auth-template-artifacts-smoke.php index c5b8833d2..68aad669c 100644 --- a/tests/prompt-auth-template-artifacts-smoke.php +++ b/tests/prompt-auth-template-artifacts-smoke.php @@ -117,7 +117,8 @@ function get_option( $key, $default_value = false ) { } if ( ! function_exists( 'update_option' ) ) { - function update_option( $key, $value ) { + function update_option( $key, $value, $autoload = null ) { + unset( $autoload ); $GLOBALS['__prompt_smoke_options'][ $key ] = $value; return true; } @@ -129,6 +130,9 @@ function update_option( $key, $value ) { use DataMachine\Engine\AI\System\Tasks\SystemTask; use DataMachine\Api\System\System; use DataMachine\Engine\Bundle\AgentBundleManifest; +use DataMachine\Engine\Bundle\AgentBundleDirectory; +use DataMachine\Engine\Bundle\AgentBundleArrayAdapter; +use DataMachine\Engine\Bundle\AgentBundleUpgradePlanner; use DataMachine\Engine\Bundle\AgentTemplateMetadata; use DataMachine\Engine\Bundle\AuthRef; use DataMachine\Engine\Bundle\AuthRefResolver; @@ -313,6 +317,56 @@ static function ( array $tasks ): array { prompt_artifact_assert_equals( 'REST prompt detail exposes artifact source path', 'system-tasks/fixture_generation/generate.md', $prompt_detail['artifact_source_path'] ?? null ); prompt_artifact_assert_equals( 'REST prompt detail exposes default artifact hash', hash( 'sha256', 'Write about {{topic}}.' ), $prompt_detail['artifact_hash'] ?? null ); +$bundle_prompt_files = SystemTaskPromptRegistry::bundle_prompt_files(); +$prompt_payload = $bundle_prompt_files['system-tasks/fixture_generation/generate.json'] ?? array(); +prompt_artifact_assert( 'system task prompt exports as bundle prompt artifact', is_array( $prompt_payload ) && 'system-task:fixture_generation:generate' === ( $prompt_payload['artifact_id'] ?? '' ) ); + +$directory = new AgentBundleDirectory( + AgentBundleManifest::from_array( + array( + 'schema_version' => 1, + 'bundle_slug' => 'system-prompt-fixture', + 'bundle_version' => '1.0.0', + 'exported_at' => '2026-04-28T00:00:00Z', + 'exported_by' => 'data-machine/test', + 'agent' => array( + 'slug' => 'fixture-agent', + 'label' => 'Fixture Agent', + 'description' => 'Exercises system prompt bundle artifacts.', + 'agent_config' => array(), + ), + 'included' => array( + 'memory' => array(), + 'pipelines' => array(), + 'flows' => array(), + 'prompts' => array_keys( $bundle_prompt_files ), + 'handler_auth' => 'refs', + ), + ) + ), + array(), + array(), + array(), + array( BundleSchema::PROMPTS_DIR => $bundle_prompt_files ) +); +$array_bundle = AgentBundleArrayAdapter::to_array_bundle( $directory ); +$round_trip = AgentBundleArrayAdapter::from_array_bundle( $array_bundle ); +prompt_artifact_assert_equals( 'array bundle preserves prompt artifact files', $prompt_payload, $round_trip->prompts()['system-tasks/fixture_generation/generate.json'] ?? null ); + +$target_artifacts = AgentBundleUpgradePlanner::artifacts_from_bundle( $directory ); +$target_prompt = array_values( array_filter( $target_artifacts, static fn( array $artifact ): bool => 'system-task:fixture_generation:generate' === ( $artifact['artifact_id'] ?? '' ) ) )[0] ?? array(); +prompt_artifact_assert_equals( 'upgrade planner uses payload artifact ID for system prompt', 'prompt', $target_prompt['artifact_type'] ?? null ); + +SystemTaskPromptRegistry::apply_bundle_artifact( $target_prompt ); +$current_without_override = SystemTaskPromptRegistry::current_artifacts()[0] ?? array(); +prompt_artifact_assert_equals( 'installed prompt artifact is current when no override exists', $prompt_payload['content_hash'], $current_without_override['payload']['content_hash'] ?? null ); + +SystemTask::setPromptOverride( 'fixture_generation', 'generate', 'Local {{topic}} override.' ); +$current_with_override = SystemTaskPromptRegistry::current_artifacts()[0] ?? array(); +prompt_artifact_assert_equals( 'local override changes current artifact hash deterministically', hash( 'sha256', 'Local {{topic}} override.' ), $current_with_override['payload']['content_hash'] ?? null ); +prompt_artifact_assert( 'local override is detectable before bundle apply', SystemTaskPromptRegistry::has_local_override_for_artifact( $target_prompt ) ); +SystemTask::setPromptOverride( 'fixture_generation', 'generate', '' ); + echo "\n[3] Agent template source/version metadata round-trips\n"; $manifest = AgentBundleManifest::from_array( array(