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: 9 additions & 12 deletions inc/Cli/CommandRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@
* Command Registry
*
* Single source of truth mapping `wp datamachine ...` command strings to their
* implementing command classes. Both the WP-CLI bootstrap (which calls
* WP_CLI::add_command for each entry) and the gated AGENTS.md `datamachine`
* section generator (which reflects over each class to enumerate real
* subcommands) read from this map, so the documented CLI surface can never
* drift from what is actually registered.
* implementing command classes. The WP-CLI bootstrap calls
* WP_CLI::add_command for each entry. Generated agent guidance advertises a
* bounded set of routing entrypoints and tests those roots against this map;
* live `--help` remains authoritative for the complete command surface.
*
* This mirrors the pattern proven in extrachill-cli's CommandRegistry — the map
* is the registration source AND the documentation source, eliminating the
* hand-typed heredoc that previously narrated core's command surface from a
* downstream plugin (Extra-Chill/data-machine#2640, #2613).
* This keeps command registration centralized while Data Machine itself owns
* the concise routing guidance previously narrated by a downstream plugin
* (Extra-Chill/data-machine#2640, #2613).
*
* @package DataMachine\Cli
*/
Expand All @@ -28,12 +26,11 @@ class CommandRegistry {
*
* Keys are the exact strings passed to WP_CLI::add_command (the command
* namespace, e.g. "datamachine memory" or "datamachine step-types").
* Order here determines both registration order and documentation order.
* Order here determines registration order.
*
* Singular/plural aliases that resolve to the same class are intentionally
* included so `WP_CLI::add_command` registers every accepted spelling. The
* AGENTS.md section generator de-duplicates by class when grouping, so the
* documented surface lists each command once.
* AGENTS.md introspection helpers de-duplicate by class when grouping.
*
* @return array<string, class-string>
*/
Expand Down
172 changes: 21 additions & 151 deletions inc/migrations/agents-md.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
* no-noise behavior on installs with no coding agent.
* - ON: core registers the AGENTS.md composable file (so `wp datamachine memory
* compose AGENTS.md` writes it and auto-regeneration keeps it fresh) and the
* core-owned sections, including the `datamachine` section whose command list
* is REFLECTED from core's registered command classes (never hand-typed).
* core-owned sections, including concise Data Machine routing and discovery
* guidance. The live CLI help remains the complete command authority.
*
* Why composition lives in core: core already owns SectionRegistry,
* MemoryFileRegistry, ComposableFileGenerator, ComposableFileInvalidation, and
Expand All @@ -32,7 +32,6 @@

use DataMachine\Engine\AI\MemoryFileRegistry;
use DataMachine\Engine\AI\SectionRegistry;
use DataMachine\Cli\CommandRegistry;

/**
* Whether AGENTS.md composition is enabled for this install.
Expand Down Expand Up @@ -86,7 +85,7 @@ function datamachine_register_agents_md_file(): void {
*
* Sections registered here describe Data Machine itself. The external coding
* runtime installer owns generic WordPress coding guidance, while Data Machine
* Code owns its `datamachine-code` section and workspace inventory.
* Code owns its workspace lifecycle section.
*
* @return void
*/
Expand Down Expand Up @@ -119,16 +118,16 @@ function () use ( $wp ) {
array_merge( $core_meta, array( 'freshness' => 'generated' ) )
);

// The reflected `datamachine` command-surface section.
// Data Machine routing and discovery guidance.
SectionRegistry::register(
'AGENTS.md',
'datamachine',
10,
'datamachine_agents_md_render_datamachine_section',
array(
'owner' => 'data-machine',
'freshness' => 'snapshot',
'conditions' => 'Registered when DATAMACHINE_COMPOSE_AGENTS_MD is enabled; command list reflected from registered command classes.',
'freshness' => 'static',
'conditions' => 'Registered when DATAMACHINE_COMPOSE_AGENTS_MD is enabled.',
)
);

Expand Down Expand Up @@ -157,159 +156,30 @@ function () use ( $wp ) {
/**
* Render the `## Data Machine` AGENTS.md section.
*
* The command list is reflected from CommandRegistry::map() — the same map that
* feeds WP-CLI registration in Bootstrap.php — so adding or renaming a
* `datamachine` subcommand surfaces here automatically with no heredoc edit, and
* a command removed from core (e.g. the now-relocated `analytics`) disappears
* from the documentation by construction.
* Persistent agent context carries ownership, routing, and discovery guidance.
* The live WP-CLI help remains authoritative for the complete command map.
*
* @return string
*/
function datamachine_agents_md_render_datamachine_section(): string {
$wp = datamachine_agents_md_wp_cli_cmd();

$lines = array();
$lines[] = '## Data Machine';
$lines[] = '';
$lines[] = 'Data Machine is your operating layer — memory, automation, and orchestration via WP-CLI.';
$lines[] = '';
$lines[] = "Discover the full command surface: `{$wp} datamachine --help`. Always run `--help` on any subcommand to see its options.";
$lines[] = '';
return <<<MD
## Data Machine

foreach ( datamachine_agents_md_command_groups() as $command => $subcommands ) {
$pipe = implode( '|', $subcommands );
Data Machine is the WordPress-side operating layer for memory, automation, content operations, communication, agent configuration, and system execution.

if ( '' !== $pipe ) {
$lines[] = "- `{$wp} {$command} {$pipe}`";
} else {
$lines[] = "- `{$wp} {$command}`";
}
}

$lines[] = '';
$lines[] = 'Use `--help` on any command to discover options and subcommands.';

return implode( "\n", $lines );
}

/**
* Build the ordered command => subcommand-names map for the section.
*
* Reflects each command class via the shared CliCommandIntrospector (preferred)
* and degrades to a local reflection fallback when the shared helper is
* unavailable or lacks the method. De-duplicates by class so singular/plural
* aliases that resolve to the same class document once, preserving the first
* registered spelling.
*
* @return array<string, string[]> Command label => ordered subcommand names.
*/
function datamachine_agents_md_command_groups(): array {
$groups = array();
$seen = array();

foreach ( CommandRegistry::map() as $command => $command_class ) {
if ( isset( $seen[ $command_class ] ) ) {
continue;
}
$seen[ $command_class ] = true;

$groups[ $command ] = datamachine_agents_md_subcommand_names( $command_class );
}

return $groups;
}

/**
* Resolve a command class's subcommand names, preferring the shared helper.
*
* @param class-string $command_class Command class.
* @return string[] Ordered subcommand names ('__default' omitted).
*/
function datamachine_agents_md_subcommand_names( string $command_class ): array {
$introspector = '\\DataMachine\\Engine\\AI\\CliCommandIntrospector';

if ( class_exists( $introspector ) && method_exists( $introspector, 'describe_class' ) ) {
$subcommands = call_user_func( array( $introspector, 'describe_class' ), $command_class );
$names = array();
foreach ( (array) $subcommands as $sub ) {
$name = is_array( $sub ) ? ( $sub['name'] ?? '' ) : '';
if ( '' === $name || '__default' === $name ) {
continue;
}
$names[] = $name;
}

// describe_class returns [] for classes whose only subcommand is an
// __invoke (flat invoke commands) on helper versions that predate
// __invoke support (#2639). Fall back locally so those still document.
if ( ! empty( $names ) ) {
return $names;
}
}
**Default routing**
- Memory and composed context: `{$wp} datamachine memory --help`
- Flows and pipelines: `{$wp} datamachine flows` and `{$wp} datamachine pipelines`
- Job and worker state: `{$wp} datamachine jobs --help` and `{$wp} datamachine worker --help`
- Content and media operations: `{$wp} datamachine posts --help`, `{$wp} datamachine blocks --help`, or `{$wp} datamachine image --help`
- Communication and approval queues: `{$wp} datamachine email --help` and `{$wp} datamachine pending-actions --help`
- Agent and system configuration: `{$wp} datamachine agent --help` and `{$wp} datamachine system --help`

return datamachine_agents_md_reflect_subcommand_names( $command_class );
}

/**
* Local reflection fallback for subcommand names.
*
* Context-safe: reflects over the class source without touching the live WP-CLI
* runner. Mirrors WP-CLI's subcommand resolution — public, non-static,
* own-declared methods; `@subcommand <name>` annotation wins over the method
* name; magic methods are skipped except `__invoke`, which maps to the namespace
* itself ('__default') and is therefore omitted from the documented list.
*
* @param class-string $command_class Command class.
* @return string[] Ordered subcommand names.
*/
function datamachine_agents_md_reflect_subcommand_names( string $command_class ): array {
if ( ! class_exists( $command_class ) ) {
return array();
}

try {
$reflection = new \ReflectionClass( $command_class );
} catch ( \Throwable $e ) {
return array();
}

$names = array();

foreach ( $reflection->getMethods( \ReflectionMethod::IS_PUBLIC ) as $method ) {
if ( $method->isStatic() ) {
continue;
}
if ( $method->getDeclaringClass()->getName() !== $reflection->getName() ) {
continue;
}

$method_name = $method->getName();
$doc = $method->getDocComment();
$doc = is_string( $doc ) ? $doc : '';

$annotated = '';
if ( preg_match( '/@subcommand\s+(\S+)/', $doc, $m ) ) {
$annotated = $m[1];
}

if ( '__invoke' === $method_name && '' === $annotated ) {
// Flat invoke command — the namespace itself, no sub-word.
continue;
}

if ( '' !== $annotated ) {
$names[] = $annotated;
continue;
}

if ( 0 === strpos( $method_name, '__' ) ) {
continue;
}

$names[] = str_replace( '_', '-', $method_name );
}

return $names;
**Discovery**
Use `{$wp} datamachine --help` for the live command map and `{$wp} datamachine <command> --help` for the current options and subcommands.
MD;
}

/**
Expand Down
70 changes: 22 additions & 48 deletions tests/agents-md-gated-composition-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
* Verifies:
* 1. The gate `datamachine_agents_md_enabled()` is constant-only and default-OFF.
* 2. When OFF, the file/section registration helpers are no-ops.
* 3. The reflected `datamachine` section renders core commands with subcommand
* names sourced from reflection — and the relocated `analytics` command is
* absent from the CommandRegistry map by construction.
* 4. The local reflection fallback resolves @subcommand annotations and treats
* a flat __invoke command as the namespace itself (omitted from the list).
* 3. The `datamachine` section renders bounded routing and live-help discovery
* rather than an exhaustive command map.
* 4. The CommandRegistry remains authoritative and excludes relocated commands.
*
* Run with: php tests/agents-md-gated-composition-smoke.php
*
Expand Down Expand Up @@ -77,10 +75,28 @@ function datamachine_assert( bool $cond, string $message, array &$failures ): vo
datamachine_register_agents_md_sections();
datamachine_assert( true, 'File + section registration helpers are no-ops while gate OFF', $failures );

// --- 3. CommandRegistry map: analytics is gone, real commands present. ------
// --- 3. Generated section is routing-oriented, not an exhaustive CLI map. ----

$rendered = datamachine_agents_md_render_datamachine_section();
datamachine_assert( str_contains( $rendered, '## Data Machine' ), 'Section has a Data Machine heading', $failures );
datamachine_assert( str_contains( $rendered, '**Default routing**' ), 'Section provides default routing', $failures );
datamachine_assert( str_contains( $rendered, 'datamachine memory --help' ), 'Section routes memory work', $failures );
datamachine_assert( str_contains( $rendered, 'datamachine jobs --help' ), 'Section routes job and evidence work', $failures );
datamachine_assert( str_contains( $rendered, 'datamachine --help' ), 'Section points to live command discovery', $failures );
datamachine_assert( ! str_contains( $rendered, 'artifact-content|artifacts|cleanup' ), 'Section omits exhaustive reflected subcommands', $failures );

// --- 4. CommandRegistry map: analytics is gone, real commands present. ------

$map = \DataMachine\Cli\CommandRegistry::map();

foreach ( array( 'memory', 'flows', 'pipelines', 'jobs', 'worker', 'posts', 'blocks', 'image', 'email', 'pending-actions', 'agent', 'system' ) as $advertised_root ) {
datamachine_assert(
isset( $map[ 'datamachine ' . $advertised_root ] ),
"Advertised routing root `datamachine {$advertised_root}` is registered",
$failures
);
}

datamachine_assert(
! array_key_exists( 'datamachine analytics', $map ),
'CommandRegistry map does NOT contain a `datamachine analytics` entry (relocated to DMB)',
Expand All @@ -101,48 +117,6 @@ function datamachine_assert( bool $cond, string $message, array &$failures ): vo
$failures
);

// --- 4. Local reflection fallback resolves subcommands correctly. -----------

// @subcommand-annotated command.
final class DM_Smoke_AnnotatedCommand {
/**
* Read a thing.
*
* @subcommand read
*/
public function read(): void {}

/**
* Write a thing.
*
* @subcommand write
*/
public function write(): void {}
}

$names = datamachine_agents_md_reflect_subcommand_names( DM_Smoke_AnnotatedCommand::class );
sort( $names );
datamachine_assert(
array( 'read', 'write' ) === $names,
'Reflection fallback resolves @subcommand-annotated methods (read, write)',
$failures
);

// Flat __invoke command — the namespace itself, omitted from the list.
final class DM_Smoke_InvokeCommand {
/**
* Run the thing.
*/
public function __invoke(): void {}
}

$invoke_names = datamachine_agents_md_reflect_subcommand_names( DM_Smoke_InvokeCommand::class );
datamachine_assert(
array() === $invoke_names,
'Reflection fallback omits a flat __invoke command from the subcommand list',
$failures
);

// --- Report. ----------------------------------------------------------------

if ( empty( $failures ) ) {
Expand Down
Loading