diff --git a/inc/Core/Agents/AgentBundler.php b/inc/Core/Agents/AgentBundler.php index ffe83ea57..1ffc0215d 100644 --- a/inc/Core/Agents/AgentBundler.php +++ b/inc/Core/Agents/AgentBundler.php @@ -26,6 +26,7 @@ use DataMachine\Engine\Bundle\AgentBundleArtifactState; use DataMachine\Engine\Bundle\AgentBundleAgentConfig; use DataMachine\Engine\Bundle\AgentBundleArtifactStatus; +use DataMachine\Engine\Bundle\AgentBundleMemoryArtifact; use DataMachine\Engine\Bundle\BundleStepIdRemapper; use DataMachine\Engine\Bundle\AgentBundleDirectory; use DataMachine\Engine\Bundle\AgentBundleFlowFile; @@ -751,8 +752,14 @@ public function import( array $bundle, ?string $new_slug = null, int $owner_id = $created_agent_id = $agent_id; } - // 2. Write agent identity files. - $this->write_agent_files( $slug, $agent_id, $owner_id, $bundle['files'] ?? array() ); + // 2. Write non-identity agent files on fresh install (learned memory + // seed, contexts/, daily/*). Authored identity (SOUL.md) is handled + // by the memory-artifact block below through the store seam on both + // paths. On upgrade nothing is seeded here: learned runtime memory + // (MEMORY.md, WAKE.md, daily/*) must never be clobbered by a deploy. + if ( ! $existing ) { + $this->write_agent_files( $slug, $agent_id, $owner_id, $bundle['files'] ?? array() ); + } // 3. Write USER.md template if provided. if ( ! empty( $bundle['user_template'] ) ) { @@ -1049,6 +1056,61 @@ public function import( array $bundle, ?string $new_slug = null, int $owner_id = ); } + // 6b. Materialize authored agent identity (SOUL.md) into the live + // memory store and track it in the ledger, on BOTH fresh install and + // upgrade. Routing through the store seam (not write_agent_files' + // direct disk write) keeps non-disk stores authoritative. Learned + // runtime memory is never in scope — AgentBundleMemoryArtifact gates + // on authority tier. On upgrade the apply is local-modification + // protected so a deploy can ship updated identity without blowing + // away local edits. + foreach ( AgentBundleMemoryArtifact::target_artifacts( $bundle ) as $artifact ) { + $artifact_key = self::artifact_key( (string) $artifact['artifact_type'], (string) $artifact['artifact_id'] ); + $record = is_array( $artifact_records[ $artifact_key ] ?? null ) ? $artifact_records[ $artifact_key ] : null; + + // On upgrade, refuse to overwrite a locally edited SOUL with a + // differing bundle version — surface it for review instead. + if ( $existing ) { + $local_payload = AgentBundleMemoryArtifact::current_payload( $agent_id, (string) $artifact['artifact_id'] ); + if ( + $record + && $this->artifact_has_local_modifications( $record, $local_payload ) + && ! hash_equals( + AgentBundleArtifactHasher::hash( $artifact['payload'] ?? null ), + AgentBundleArtifactHasher::hash( $local_payload ) + ) + ) { + $conflicts[] = array( + 'artifact_type' => $artifact['artifact_type'], + 'artifact_id' => $artifact['artifact_id'], + 'reason' => 'local_modified', + ); + continue; + } + } + + // Materialize authored identity through the memory store seam on + // BOTH fresh install and upgrade, so non-disk stores receive the + // content (write_agent_files only touches the disk store). + $applied = AgentBundleMemoryArtifact::apply( $artifact, $agent_id ); + if ( is_wp_error( $applied ) ) { + $conflicts[] = array( + 'artifact_type' => $artifact['artifact_type'], + 'artifact_id' => $artifact['artifact_id'], + 'reason' => $applied->get_error_message(), + ); + 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, @@ -1717,7 +1779,15 @@ private function sanitize_scheduling_config( array $config ): array { } /** - * Write agent identity files to disk. + * Write non-identity agent files to disk on fresh install. + * + * Seeds learned memory (MEMORY.md), contexts/, and daily/* for a brand-new + * agent. Authored identity (SOUL.md, authority tier `agent_identity`) is + * deliberately skipped here — the importer materializes it through the + * memory store seam (AgentBundleMemoryArtifact::apply) on both fresh install + * and upgrade so non-disk stores stay authoritative and the file is never + * double-written. This method is only invoked on fresh install; on upgrade + * the importer preserves learned runtime memory and never re-seeds it. * * @param string $slug Agent slug. * @param int $agent_id Imported agent ID. @@ -1730,6 +1800,15 @@ private function write_agent_files( string $slug, int $agent_id, int $owner_id, foreach ( $files as $relative_path => $content ) { $relative_path = str_replace( '\\', '/', (string) $relative_path ); + + // Authored identity (SOUL.md) is materialized through the memory + // store seam by the importer's memory-artifact block, on both fresh + // install and upgrade. Skip it here so non-disk stores stay + // authoritative and the file is never double-written. + if ( AgentBundleMemoryArtifact::is_upgradeable_agent_file( $relative_path ) ) { + continue; + } + if ( preg_match( '#^daily/(\d{4})/(\d{2})/(\d{2})\.md$#', $relative_path, $matches ) ) { ( new DailyMemory( $owner_id, $agent_id ) )->write( $matches[1], $matches[2], $matches[3], (string) $content ); continue; diff --git a/inc/Engine/Bundle/AgentBundleLifecycleProjection.php b/inc/Engine/Bundle/AgentBundleLifecycleProjection.php index 9480a9745..15f6ca605 100644 --- a/inc/Engine/Bundle/AgentBundleLifecycleProjection.php +++ b/inc/Engine/Bundle/AgentBundleLifecycleProjection.php @@ -121,6 +121,10 @@ public function target_artifacts( array $bundle, ?array $agent = null ): array { $artifacts[] = $artifact; } + foreach ( AgentBundleMemoryArtifact::target_artifacts( $bundle ) as $artifact ) { + $artifacts[] = $artifact; + } + foreach ( AgentBundleArtifactExtensions::normalize_artifacts( is_array( $bundle['extension_artifacts'] ?? null ) ? $bundle['extension_artifacts'] : array() ) as $artifact ) { $artifacts[] = $artifact; } @@ -193,6 +197,7 @@ public function current_artifacts( array $agent, array $installed ): array { return array_merge( $artifacts, + AgentBundleMemoryArtifact::current_artifacts( $agent_id, $installed ), SystemTaskPromptRegistry::current_artifacts(), AgentBundleArtifactExtensions::current_artifacts( $agent, $installed, array( 'agent_id' => $agent_id ) ) ); diff --git a/inc/Engine/Bundle/AgentBundleMemoryArtifact.php b/inc/Engine/Bundle/AgentBundleMemoryArtifact.php new file mode 100644 index 000000000..95b25488b --- /dev/null +++ b/inc/Engine/Bundle/AgentBundleMemoryArtifact.php @@ -0,0 +1,273 @@ +` layout. + */ + private const AGENT_PREFIX = 'agent/'; + + /** + * Build target memory artifact rows from a bundle array. + * + * Only authored-identity files declared in the bundle are emitted. The + * payload is the raw file contents so the package planner can hash/diff it + * exactly like any other artifact. + * + * @param array $bundle Bundle array (canonical import shape). + * @return array> + */ + public static function target_artifacts( array $bundle ): array { + $artifacts = array(); + foreach ( self::applicable_files( $bundle ) as $filename => $contents ) { + $artifacts[] = self::artifact_row( $filename, (string) $contents ); + } + + return $artifacts; + } + + /** + * Build current memory artifact rows for an agent from its installed ledger. + * + * Mirrors the target side: for every authored-identity memory artifact the + * agent has installed, project the live store's current content so the + * planner can classify it as no-op / needs-approval / auto-apply. Reading + * from the ledger (rather than enumerating every registered file) keeps the + * current set aligned with what the bundle actually manages, so an + * unmanaged live SOUL.md is never surfaced as drift. + * + * @param int $agent_id Agent ID. + * @param array> $installed Installed artifact rows. + * @return array> + */ + public static function current_artifacts( int $agent_id, array $installed ): array { + if ( $agent_id <= 0 ) { + return array(); + } + + $artifacts = array(); + foreach ( $installed as $record ) { + if ( ! is_array( $record ) ) { + continue; + } + if ( self::ARTIFACT_TYPE !== (string) ( $record['artifact_type'] ?? '' ) ) { + continue; + } + + $artifact_id = (string) ( $record['artifact_id'] ?? '' ); + $filename = self::filename_from_artifact_id( $artifact_id ); + if ( '' === $filename || ! self::is_authored_identity( $filename ) ) { + continue; + } + + $content = self::read_live( $agent_id, $filename ); + if ( null === $content ) { + continue; + } + + $artifacts[] = self::artifact_row( $filename, $content ); + } + + return $artifacts; + } + + /** + * Read the live store payload for a memory artifact by its bundle artifact ID. + * + * @param int $agent_id Agent ID. + * @param string $artifact_id Bundle artifact ID (e.g. `agent/SOUL.md`). + * @return string|null Current content, or null when absent. + */ + public static function current_payload( int $agent_id, string $artifact_id ): ?string { + if ( $agent_id <= 0 ) { + return null; + } + + $filename = self::filename_from_artifact_id( $artifact_id ); + if ( '' === $filename || ! self::is_authored_identity( $filename ) ) { + return null; + } + + return self::read_live( $agent_id, $filename ); + } + + /** + * Materialize one bundle-carried agent memory artifact into the live store. + * + * @param array $artifact Artifact envelope. + * @param int $agent_id Agent ID. + * @return array|\WP_Error|null Result, WP_Error on failure, null when not applicable. + */ + public static function apply( array $artifact, int $agent_id ): array|\WP_Error|null { + if ( self::ARTIFACT_TYPE !== (string) ( $artifact['artifact_type'] ?? '' ) || $agent_id <= 0 ) { + return null; + } + + $artifact_id = (string) ( $artifact['artifact_id'] ?? '' ); + $filename = self::filename_from_artifact_id( $artifact_id ); + if ( '' === $filename ) { + return null; + } + + // Hard guard: never let a bundle clobber learned runtime memory, even if + // the bundle declared it. Only authored identity is materializable. + if ( ! self::is_authored_identity( $filename ) ) { + return new \WP_Error( + 'datamachine_bundle_memory_not_authored', + sprintf( 'Refusing to materialize learned memory file "%s" from a bundle; only authored identity is upgradeable.', $filename ) + ); + } + + $payload = $artifact['payload'] ?? null; + if ( ! is_string( $payload ) ) { + return null; + } + + $memory = new AgentMemory( 0, $agent_id, $filename ); + $result = $memory->replace_all( $payload ); + if ( empty( $result['success'] ) ) { + return new \WP_Error( + 'datamachine_bundle_memory_write_failed', + sprintf( 'Failed to write agent memory file "%s": %s', $filename, (string) ( $result['message'] ?? 'unknown error' ) ) + ); + } + + return array( + 'artifact_type' => self::ARTIFACT_TYPE, + 'artifact_id' => $artifact_id, + 'agent_id' => $agent_id, + ); + } + + /** + * Whether a bundle-carried agent-layer file may be materialized on upgrade. + * + * Public entry point for the importer's upgrade-time guard so the + * authored-identity-vs-learned-memory rule lives in exactly one place. + * + * @param string $relative_path Agent-layer relative path (no `agent/` prefix). + */ + public static function is_upgradeable_agent_file( string $relative_path ): bool { + $filename = self::normalize_filename( $relative_path ); + + return '' !== $filename && self::is_authored_identity( $filename ); + } + + /** + * Filter a bundle's agent-layer memory map down to applicable files. + * + * @param array $bundle Bundle array. + * @return array filename => contents (filename has no prefix). + */ + private static function applicable_files( array $bundle ): array { + $files = is_array( $bundle['files'] ?? null ) ? $bundle['files'] : array(); + + $applicable = array(); + foreach ( $files as $relative_path => $contents ) { + $filename = self::normalize_filename( (string) $relative_path ); + if ( '' === $filename || ! self::is_authored_identity( $filename ) ) { + continue; + } + $applicable[ $filename ] = (string) $contents; + } + + return $applicable; + } + + /** + * Whether a memory filename is authored agent identity that a bundle owns. + * + * Authority tier `agent_identity` (SOUL.md by default) is the authored, + * versioned identity. Everything else in the agent layer (MEMORY.md, + * WAKE.md, daily/*) is learned runtime memory and must not be overwritten. + */ + private static function is_authored_identity( string $filename ): bool { + // daily/* and any nested learned memory is never authored identity. + if ( str_contains( $filename, '/' ) ) { + return false; + } + + $meta = MemoryFileRegistry::get( $filename ); + if ( is_array( $meta ) ) { + $tier = (string) ( $meta['authority_tier'] ?? '' ); + if ( '' !== $tier ) { + return 'agent_identity' === $tier; + } + } + + // Unregistered files: fall back to the canonical authored-identity file. + return 'SOUL.md' === $filename; + } + + /** + * Read the live store content for an agent memory file. + * + * @return string|null Content, or null when the file does not exist. + */ + private static function read_live( int $agent_id, string $filename ): ?string { + $memory = new AgentMemory( 0, $agent_id, $filename ); + $result = $memory->read(); + + return $result->exists ? (string) $result->content : null; + } + + /** @return array */ + private static function artifact_row( string $filename, string $contents ): array { + return array( + 'artifact_type' => self::ARTIFACT_TYPE, + 'artifact_id' => self::AGENT_PREFIX . $filename, + 'source_path' => BundleSchema::MEMORY_DIR . '/' . self::AGENT_PREFIX . $filename, + 'payload' => $contents, + ); + } + + private static function filename_from_artifact_id( string $artifact_id ): string { + $artifact_id = str_replace( '\\', '/', trim( $artifact_id ) ); + if ( str_starts_with( $artifact_id, self::AGENT_PREFIX ) ) { + $artifact_id = substr( $artifact_id, strlen( self::AGENT_PREFIX ) ); + } + + return self::normalize_filename( $artifact_id ); + } + + private static function normalize_filename( string $relative_path ): string { + $relative_path = str_replace( '\\', '/', trim( $relative_path ) ); + $relative_path = ltrim( $relative_path, '/' ); + if ( '' === $relative_path || str_contains( $relative_path, '..' ) ) { + return ''; + } + + return $relative_path; + } +} diff --git a/inc/Engine/Bundle/AgentBundleUpgradeActionHandlers.php b/inc/Engine/Bundle/AgentBundleUpgradeActionHandlers.php index 8ff5b10ba..0293b4b78 100644 --- a/inc/Engine/Bundle/AgentBundleUpgradeActionHandlers.php +++ b/inc/Engine/Bundle/AgentBundleUpgradeActionHandlers.php @@ -64,7 +64,7 @@ public static function apply( array $artifact, array $agent, array $context ): m $agent_id = (int) ( $agent['agent_id'] ?? 0 ); $payload = $artifact['payload'] ?? null; - if ( $agent_id <= 0 || null === $payload || ! in_array( $type, array( 'agent_config', 'pipeline', 'flow', 'prompt', 'rubric' ), true ) ) { + if ( $agent_id <= 0 || null === $payload || ! in_array( $type, array( 'agent_config', 'pipeline', 'flow', 'prompt', 'rubric', 'memory' ), true ) ) { return null; } @@ -73,6 +73,11 @@ public static function apply( array $artifact, array $agent, array $context ): m 'artifact_type' => $type, 'artifact_id' => (string) ( $artifact['artifact_id'] ?? '' ), ); + } elseif ( 'memory' === $type ) { + $applied = AgentBundleMemoryArtifact::apply( $artifact, $agent_id ); + if ( null === $applied ) { + return null; + } } elseif ( 'agent_config' === $type ) { if ( ! is_array( $payload ) ) { return null; diff --git a/tests/Unit/Core/Agents/AgentBundlerImportTest.php b/tests/Unit/Core/Agents/AgentBundlerImportTest.php index 58da4cce0..f7fd11cc7 100644 --- a/tests/Unit/Core/Agents/AgentBundlerImportTest.php +++ b/tests/Unit/Core/Agents/AgentBundlerImportTest.php @@ -485,6 +485,63 @@ public function test_import_seeds_agent_daily_memory_into_runtime_store(): void $this->assertSame( 1, $search['data']['match_count'] ?? 0 ); } + public function test_upgrade_materializes_authored_soul_without_clobbering_learned_memory(): void { + $store = new DailyMemoryImportFakeStore(); + $this->memory_store_filter = static fn( $default, array $context ) => $store; + add_filter( 'wp_agent_memory_store', $this->memory_store_filter, 10, 2 ); + + // Fresh install: a flow-less, memory-bearing bundle carrying authored SOUL. + $bundle = $this->fixture_bundle( 'soul-upgrade-agent' ); + $bundle['pipelines'] = array(); + $bundle['flows'] = array(); + $bundle['files']['SOUL.md'] = "# Identity\n\noriginal soul\n"; + $bundle_dir = sys_get_temp_dir() . '/datamachine-soul-bundle-' . getmypid(); + $this->remove_tree( $bundle_dir ); + $this->assertTrue( $this->bundler->to_directory( $bundle, $bundle_dir ), 'Bundle directory write succeeds.' ); + $install_bundle = $this->bundler->from_directory( $bundle_dir ); + $this->assertIsArray( $install_bundle, 'Install bundle reads back from directory.' ); + + $installed = $this->bundler->import( $install_bundle, null, $this->owner_id ); + $this->assertTrue( (bool) $installed['success'], 'Fresh install of a memory-bearing bundle succeeds.' ); + + $agent = $this->agents_repo->get_by_slug( 'soul-upgrade-agent' ); + $agent_id = (int) $agent['agent_id']; + + $soul = new \DataMachine\Core\FilesRepository\AgentMemory( 0, $agent_id, 'SOUL.md' ); + $this->assertTrue( $soul->read()->exists, 'Fresh install writes SOUL.md to the live store.' ); + $this->assertStringContainsString( 'original soul', $soul->read()->content, 'Fresh install seeds the bundle SOUL content.' ); + + // The agent accumulates LEARNED memory after install — this must survive an upgrade. + $learned = new \DataMachine\Core\FilesRepository\AgentMemory( 0, $agent_id, 'MEMORY.md' ); + $learned->replace_all( "# Memory\n\nlearned-sentinel\n" ); + + // SOUL is tracked as an installed memory artifact so future upgrades diff cleanly. + $installed_types = array_map( + static fn( AgentBundleInstalledArtifact $artifact ): string => $artifact->to_array()['artifact_type'], + ( new InstalledBundleArtifacts() )->list_for_bundle( 'soul-upgrade-agent', $agent_id ) + ); + $this->assertContains( 'memory', $installed_types, 'Authored SOUL is recorded in the bundle artifact ledger.' ); + + // Upgrade: ship a NEW SOUL. The bundle carries no MEMORY.md. + $upgrade_bundle = $this->fixture_bundle( 'soul-upgrade-agent' ); + $upgrade_bundle['pipelines'] = array(); + $upgrade_bundle['flows'] = array(); + $upgrade_bundle['files']['SOUL.md'] = "# Identity\n\nupgraded soul\n"; + $this->remove_tree( $bundle_dir ); + $this->assertTrue( $this->bundler->to_directory( $upgrade_bundle, $bundle_dir ), 'Upgrade bundle directory write succeeds.' ); + $upgrade_bundle = $this->bundler->from_directory( $bundle_dir ); + $this->remove_tree( $bundle_dir ); + + $result = $this->bundler->import( $upgrade_bundle, null, $this->owner_id, false, array( 'is_upgrade' => true ) ); + $this->assertTrue( (bool) $result['success'], 'Upgrade of a memory-bearing bundle succeeds.' ); + + $soul_after = new \DataMachine\Core\FilesRepository\AgentMemory( 0, $agent_id, 'SOUL.md' ); + $this->assertStringContainsString( 'upgraded soul', $soul_after->read()->content, 'Upgrade materializes the new authored SOUL into the live store.' ); + + $learned_after = new \DataMachine\Core\FilesRepository\AgentMemory( 0, $agent_id, 'MEMORY.md' ); + $this->assertStringContainsString( 'learned-sentinel', $learned_after->read()->content, 'Upgrade preserves learned runtime memory it did not ship.' ); + } + private function remove_tree( string $path ): void { if ( ! is_dir( $path ) ) { return; diff --git a/tests/agent-bundle-memory-artifact-smoke.php b/tests/agent-bundle-memory-artifact-smoke.php new file mode 100644 index 000000000..15e3af60f --- /dev/null +++ b/tests/agent-bundle-memory-artifact-smoke.php @@ -0,0 +1,188 @@ +code = $code; + $this->message = $message; + } + public function get_error_code() { + return $this->code; + } + public function get_error_message() { + return $this->message; + } + } +} + +require_once dirname( __DIR__ ) . '/vendor/autoload.php'; + +use DataMachine\Engine\AI\MemoryFileRegistry; +use DataMachine\Engine\Bundle\AgentBundleMemoryArtifact; + +$failures = 0; +$total = 0; + +function assert_memory( string $label, bool $condition ): void { + global $failures, $total; + ++$total; + if ( $condition ) { + echo " PASS: {$label}\n"; + return; + } + echo " FAIL: {$label}\n"; + ++$failures; +} + +function assert_memory_equals( string $label, $expected, $actual ): void { + $ok = $expected === $actual; + assert_memory( $label, $ok ); + if ( ! $ok ) { + echo ' expected: ' . var_export( $expected, true ) . "\n"; + echo ' actual: ' . var_export( $actual, true ) . "\n"; + } +} + +echo "=== Agent Bundle Memory Artifact Smoke (#2818) ===\n"; + +// Register the canonical identity + learned files so the authority-tier gate +// resolves deterministically (mirrors bootstrap registrations). +MemoryFileRegistry::reset(); +MemoryFileRegistry::register( 'SOUL.md', 20, array( 'layer' => MemoryFileRegistry::LAYER_AGENT ) ); +MemoryFileRegistry::register( 'MEMORY.md', 30, array( 'layer' => MemoryFileRegistry::LAYER_AGENT ) ); + +echo "\n[1] Authored identity (SOUL.md) is a target artifact; learned memory is not\n"; + +// Bundle 'files' is the agent-layer memory map with the agent/ prefix stripped, +// exactly as AgentBundleArrayAdapter::to_array_bundle produces it. +$bundle = array( + 'files' => array( + 'SOUL.md' => "new soul\n", + 'MEMORY.md' => "learned\n", + 'daily/2026/06/28.md' => "today\n", + ), +); + +$targets = AgentBundleMemoryArtifact::target_artifacts( $bundle ); +$by_id = array(); +foreach ( $targets as $artifact ) { + $by_id[ (string) $artifact['artifact_id'] ] = $artifact; +} + +assert_memory_equals( 'exactly one memory target emitted', 1, count( $targets ) ); +assert_memory( 'SOUL.md surfaces as agent/SOUL.md target', isset( $by_id['agent/SOUL.md'] ) ); +assert_memory( 'learned MEMORY.md is NOT a target', ! isset( $by_id['agent/MEMORY.md'] ) ); +assert_memory( 'learned daily/* is NOT a target', ! isset( $by_id['agent/daily/2026/06/28.md'] ) ); +assert_memory_equals( 'target artifact type is memory', 'memory', $by_id['agent/SOUL.md']['artifact_type'] ?? null ); +assert_memory_equals( 'target source path points at memory/agent/SOUL.md', 'memory/agent/SOUL.md', $by_id['agent/SOUL.md']['source_path'] ?? null ); +assert_memory_equals( 'target payload carries the new identity', "new soul\n", $by_id['agent/SOUL.md']['payload'] ?? null ); + +echo "\n[2] Upgrade-time guard only materializes authored identity\n"; +assert_memory( 'SOUL.md is upgradeable', AgentBundleMemoryArtifact::is_upgradeable_agent_file( 'SOUL.md' ) ); +assert_memory( 'MEMORY.md is NOT upgradeable', ! AgentBundleMemoryArtifact::is_upgradeable_agent_file( 'MEMORY.md' ) ); +assert_memory( 'daily/* is NOT upgradeable', ! AgentBundleMemoryArtifact::is_upgradeable_agent_file( 'daily/2026/06/28.md' ) ); + +echo "\n[3] apply() refuses learned memory even if forced through\n"; +$forced = AgentBundleMemoryArtifact::apply( + array( + 'artifact_type' => 'memory', + 'artifact_id' => 'agent/MEMORY.md', + 'payload' => "clobber\n", + ), + 7 +); +assert_memory( 'applying learned memory returns WP_Error', is_wp_error( $forced ) ); +assert_memory_equals( 'error code names the authored-identity guard', 'datamachine_bundle_memory_not_authored', $forced instanceof WP_Error ? $forced->get_error_code() : null ); + +$ignored = AgentBundleMemoryArtifact::apply( + array( + 'artifact_type' => 'prompt', + 'artifact_id' => 'something', + 'payload' => "x\n", + ), + 7 +); +assert_memory( 'apply() declines non-memory artifacts', null === $ignored ); + +echo "\n[4] Fallback gate: unregistered SOUL.md still treated as authored identity\n"; +MemoryFileRegistry::reset(); +assert_memory( 'SOUL.md authored-identity fallback holds without registration', AgentBundleMemoryArtifact::is_upgradeable_agent_file( 'SOUL.md' ) ); +assert_memory( 'unregistered MEMORY.md is not authored identity', ! AgentBundleMemoryArtifact::is_upgradeable_agent_file( 'MEMORY.md' ) ); + +echo "\nTotal assertions: {$total}\n"; +if ( 0 !== $failures ) { + echo "Failures: {$failures}\n"; + exit( 1 ); +} + +echo "All assertions passed.\n";