Skip to content

Commit 4eba1e1

Browse files
authored
fix(agents-md): generate datamachine-code sections from real command tree (#671) (#686)
* fix(agents-md): generate datamachine-code sections from real command tree (#671) The datamachine-code AGENTS.md section hardcoded subcommand pipe-lists (`workspace adopt|clone|list|show|path|hygiene|remove|worktree`, etc.) that drifted from the registered CLI surface and omitted the entire file-I/O surface an agent works through inside a worktree (read/write/grep/edit/ git/patch/ls). An agent reading AGENTS.md had no idea those existed. Add a self-contained CommandIntrospector that reflects over the DMC command CLASSES (ReflectionClass over `@subcommand` + PHPDoc summaries) — context safe, never touches the live WP_CLI runner, so it works in web/cron compose contexts. Wire the workspace/github/gitsync lines in AgentsMdSections to generate their pipe-lists from it, with the prior hand-typed lists kept only as graceful fallbacks. This is a DMC-local stopgap pending the shared substrate-level introspector tracked in Extra-Chill/data-machine#2613; both should migrate to the shared helper once it lands. Extends the AGENTS.md marker smoke test to assert the generation wiring is present (no hardcoded list) and to exercise the introspector against the real command classes, proving read/write/grep/edit/git/patch/ls are produced. * style: clear phpcs warnings on agents-md section generator * fix: drop dead try/catch in CommandIntrospector reflection class_exists() autoloads and confirms the class before the ReflectionClass constructor runs, so the constructor cannot throw ReflectionException — the try/catch was dead code (phpstan catch.neverThrown at level 7). This was the only PHPStan error this PR introduced; the remaining AgentsMdSections.php findings are pre-existing on main (cross-plugin DataMachine\Engine\AI class references not in DMC's analysis paths) and identical before/after this PR.
1 parent ad3080e commit 4eba1e1

3 files changed

Lines changed: 240 additions & 9 deletions

File tree

inc/Runtime/AgentsMdSections.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ private static function register_datamachine_section( string $wp ): void {
8888
? "On this site, scope memory commands with `--agent={$agent_slug}` when reading, writing, searching, or composing agent memory."
8989
: 'On multi-agent installs, pass `--agent=<slug>` to memory commands when auto-resolution is ambiguous.';
9090

91+
// Generate DMC's own command surface from reflection so the lists
92+
// never drift from the registered truth (see #671). The fallback
93+
// pipe-lists are only used if a command class is somehow
94+
// unavailable in this compose context.
95+
$workspace_subcmds = CommandIntrospector::pipe_list(
96+
'\\DataMachineCode\\Cli\\Commands\\WorkspaceCommand',
97+
'adopt|clone|list|show|path|hygiene|remove|worktree|read|write|grep|edit|git|patch|ls'
98+
);
99+
$github_subcmds = CommandIntrospector::pipe_list(
100+
'\\DataMachineCode\\Cli\\Commands\\GitHubCommand',
101+
'issues|pulls|repos|status|view|close|review-flow|comment'
102+
);
103+
$gitsync_subcmds = CommandIntrospector::pipe_list(
104+
'\\DataMachineCode\\Cli\\Commands\\GitSyncCommand',
105+
'bind|list|status|pull|submit|push|policy|unbind'
106+
);
107+
91108
return <<<MD
92109
## Data Machine
93110
@@ -124,10 +141,10 @@ private static function register_datamachine_section( string $wp ): void {
124141
125142
**Code (data-machine-code):** All code changes happen in Data Machine Code worktrees under `{$workspace_path}`. DMC owns workspace lifecycle, evidence capture, GitHub workflow glue, and GitSync; file CRUD inside a worktree uses whatever tool is fastest.
126143
- Workspace root: `{$workspace_path}`
127-
- **Workspace lifecycle:** `{$wp} datamachine-code workspace adopt|clone|list|show|path|hygiene|remove|worktree` — keeps the on-disk registry consistent and enforces the `<repo>@<slug>` handle convention.
144+
- **Workspace:** `{$wp} datamachine-code workspace {$workspace_subcmds}` — lifecycle (clone/adopt/list/show/path/hygiene/remove/worktree), plus the file-I/O surface you work through inside a worktree (`read`, `write`, `grep`, `edit`, `patch`, `ls`, `git`). Keeps the on-disk registry consistent and enforces the `<repo>@<slug>` handle convention.
128145
- **Worktrees:** `{$wp} datamachine-code workspace worktree add|list|remove|prune|cleanup|cleanup-artifacts|reconcile-metadata|refresh-context|finalize|mark-cleanup-eligible` — create isolated branches, refresh agent context, attach lifecycle metadata, and clean up safely.
129-
- **GitHub:** `{$wp} datamachine-code github issues|pulls|repos|status|view|close|review-flow|comment` — list/read GitHub state, manage issues, install review flows, and comment on reviews.
130-
- **Git sync:** `{$wp} datamachine-code gitsync bind|list|status|pull|submit|push|policy|unbind` — bind site-owned directories to remotes; `submit` opens or updates the PR path, while `push` writes directly to the configured branch.
146+
- **GitHub:** `{$wp} datamachine-code github {$github_subcmds}` — list/read GitHub state, manage issues and PRs, install review flows, and comment on reviews.
147+
- **Git sync:** `{$wp} datamachine-code gitsync {$gitsync_subcmds}` — bind site-owned directories to remotes; `submit` opens or updates the PR path, while `push` writes directly to the configured branch.
131148
- **Editing inside a worktree:** any tool. Local agents on the same disk should use native file I/O and raw `git`; routing edits through workspace abilities is ceremony, not safety.
132149
- **Workflow:** reuse the existing primary when one exists for the remote; otherwise `workspace clone <repo>` once → `worktree add <repo> <branch>` → edit files in the worktree with any tool → commit → push → PR.
133150
- **Primary freshness:** before using a primary checkout for investigation or verification, inspect `workspace list|show|hygiene` freshness metadata. If the primary is stale, run `workspace git pull <repo> --allow-primary-mutation` or create the worktree from an explicit remote ref with `worktree add <repo> <branch> --from=origin/<base>`. Do not clone a second top-level primary for the same remote just to get fresh code.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
/**
3+
* Reflection-based introspection of Data Machine Code WP-CLI command classes.
4+
*
5+
* AGENTS.md sections must describe the *real* command surface, not a hand-typed
6+
* pipe-list that silently drifts (see Extra-Chill/data-machine-code#671). This
7+
* helper reflects over the command classes' `@subcommand` annotations and PHPDoc
8+
* summaries to produce truthful subcommand lists.
9+
*
10+
* IMPORTANT: this is a DMC-local stopgap pending the shared, substrate-level
11+
* command-tree introspector tracked in Extra-Chill/data-machine#2613. Once that
12+
* lands, both this helper and the consuming AGENTS.md sections should migrate to
13+
* the shared implementation. Until then, keep this self-contained.
14+
*
15+
* Context safety: this runs on `plugins_loaded` in web/cron compose contexts,
16+
* NOT only under WP-CLI. It therefore reflects over autoloadable command CLASSES
17+
* (via ReflectionClass) and never touches the live WP_CLI runner. Reflecting a
18+
* class does not instantiate it, so command-class constructor dependencies are
19+
* never exercised.
20+
*
21+
* @package DataMachineCode\Runtime
22+
*/
23+
24+
namespace DataMachineCode\Runtime;
25+
26+
defined('ABSPATH') || exit;
27+
28+
final class CommandIntrospector {
29+
30+
/**
31+
* Reflect over a command class and return its subcommands as
32+
* `[ 'name' => 'short description', ... ]`, preserving declaration order.
33+
*
34+
* Each public method annotated with `@subcommand <name>` becomes an entry.
35+
* The description is the first non-tag line of the method's PHPDoc summary.
36+
*
37+
* Returns an empty array when the class is unavailable or has no subcommands,
38+
* so callers can fall back gracefully without fatals in any context.
39+
*
40+
* @param string $command_class Fully-qualified command class name.
41+
* @return array<string,string> Ordered map of subcommand => description.
42+
*/
43+
public static function subcommands( string $command_class ): array {
44+
// class_exists() triggers autoloading; once it returns true the
45+
// ReflectionClass constructor cannot throw, so no try/catch is needed.
46+
if ( ! class_exists($command_class) ) {
47+
return array();
48+
}
49+
50+
$reflection = new \ReflectionClass($command_class);
51+
$subcommands = array();
52+
53+
foreach ( $reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method ) {
54+
if ( $method->isStatic() || $method->getDeclaringClass()->getName() !== $reflection->getName() ) {
55+
continue;
56+
}
57+
58+
$doc = $method->getDocComment();
59+
if ( false === $doc || '' === $doc ) {
60+
continue;
61+
}
62+
63+
$name = self::extract_subcommand_name($doc);
64+
if ( '' === $name ) {
65+
continue;
66+
}
67+
68+
$subcommands[ $name ] = self::extract_summary($doc);
69+
}
70+
71+
return $subcommands;
72+
}
73+
74+
/**
75+
* Return only the subcommand names for a class, in declaration order.
76+
*
77+
* @param string $command_class Fully-qualified command class name.
78+
* @return string[] Ordered list of subcommand names.
79+
*/
80+
public static function subcommand_names( string $command_class ): array {
81+
return array_keys(self::subcommands($command_class));
82+
}
83+
84+
/**
85+
* Render a `a|b|c` pipe-list of a class's subcommand names.
86+
*
87+
* Falls back to the supplied default string when reflection yields nothing,
88+
* so AGENTS.md never renders an empty command line.
89+
*
90+
* @param string $command_class Fully-qualified command class name.
91+
* @param string $fallback Pipe-list to use when reflection is unavailable.
92+
* @return string
93+
*/
94+
public static function pipe_list( string $command_class, string $fallback = '' ): string {
95+
$names = self::subcommand_names($command_class);
96+
if ( empty($names) ) {
97+
return $fallback;
98+
}
99+
100+
return implode('|', $names);
101+
}
102+
103+
/**
104+
* Pull the `@subcommand <name>` value out of a PHPDoc block.
105+
*
106+
* @param string $doc Raw docblock text.
107+
* @return string Subcommand name, or '' when not annotated.
108+
*/
109+
private static function extract_subcommand_name( string $doc ): string {
110+
if ( preg_match('/@subcommand\s+(\S+)/', $doc, $matches) ) {
111+
return trim($matches[1]);
112+
}
113+
114+
return '';
115+
}
116+
117+
/**
118+
* Extract the first non-empty, non-tag summary line from a docblock.
119+
*
120+
* @param string $doc Raw docblock text.
121+
* @return string Short description, or '' when none is present.
122+
*/
123+
private static function extract_summary( string $doc ): string {
124+
$lines = preg_split('/\r\n|\r|\n/', $doc);
125+
if ( false === $lines ) {
126+
return '';
127+
}
128+
129+
foreach ( $lines as $line ) {
130+
$line = trim($line);
131+
$line = ltrim($line, '/*');
132+
$line = trim($line);
133+
134+
if ( '' === $line ) {
135+
continue;
136+
}
137+
138+
// Skip annotation/usage lines; we only want the prose summary.
139+
if ( '@' === $line[0] || '#' === $line[0] ) {
140+
continue;
141+
}
142+
143+
return $line;
144+
}
145+
146+
return '';
147+
}
148+
}

tests/smoke-agents-md-marker.php

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,17 @@
4545
$expected_memory_agent_note = 'On multi-agent installs, pass `--agent=<slug>` to memory commands when auto-resolution is ambiguous.';
4646
$expected_agent_cli = 'datamachine agent list|create|access|token|installed|install|diff';
4747
$stale_agent_cli = 'datamachine agents list|create|access|tokens';
48-
$expected_workspace_cli = 'datamachine-code workspace adopt|clone|list|show|path|hygiene|remove|worktree';
48+
// Workspace / GitHub / GitSync subcommand lists are now generated from the
49+
// command classes via CommandIntrospector (see #671) instead of hardcoded
50+
// pipe-lists, so the source asserts on the generation wiring + interpolation
51+
// tokens rather than a frozen string. The actual produced surface is exercised
52+
// against the real command classes further down.
53+
$expected_workspace_token = 'datamachine-code workspace {$workspace_subcmds}';
54+
$expected_github_token = 'datamachine-code github {$github_subcmds}';
55+
$expected_gitsync_token = 'datamachine-code gitsync {$gitsync_subcmds}';
56+
$expected_introspect_wiring = 'CommandIntrospector::pipe_list(';
57+
$stale_hardcoded_workspace = 'workspace adopt|clone|list|show|path|hygiene|remove|worktree';
4958
$expected_worktree_cli = 'datamachine-code workspace worktree add|list|remove|prune|cleanup|cleanup-artifacts|reconcile-metadata|refresh-context|finalize|mark-cleanup-eligible';
50-
$expected_gitsync_cli = 'gitsync bind|list|status|pull|submit|push|policy|unbind';
5159
$expected_inventory_time = 'Generated {$generated_at} from cloned repos';
5260
$expected_inventory_truth = 'workspace list` is the source of truth';
5361
$expected_inventory_refresh = 'datamachine memory compose AGENTS.md{$agent_suffix}';
@@ -88,8 +96,16 @@
8896
! str_contains($source, $stale_agent_cli)
8997
);
9098
$assert(
91-
'workspace lifecycle guidance includes current command family',
92-
str_contains($source, $expected_workspace_cli)
99+
'workspace guidance is generated from the command class, not hardcoded',
100+
str_contains($source, $expected_workspace_token) && str_contains($source, $expected_introspect_wiring)
101+
);
102+
$assert(
103+
'stale hardcoded workspace pipe-list is gone (#671)',
104+
! str_contains($source, $stale_hardcoded_workspace)
105+
);
106+
$assert(
107+
'GitHub guidance is generated from the command class',
108+
str_contains($source, $expected_github_token)
93109
);
94110
$assert(
95111
'worktree guidance includes current maintenance surfaces',
@@ -112,8 +128,8 @@
112128
str_contains($source, $expected_abilities_note) && ! str_contains($source, $stale_abilities_cli)
113129
);
114130
$assert(
115-
'GitSync guidance distinguishes compact sync family',
116-
str_contains($source, $expected_gitsync_cli)
131+
'GitSync guidance is generated from the command class',
132+
str_contains($source, $expected_gitsync_token)
117133
);
118134
$assert(
119135
'AGENTS.md sections declare Data Machine Code ownership',
@@ -144,6 +160,56 @@
144160
! str_contains($entrypoint, $expected_agent_cli)
145161
);
146162

163+
/*
164+
* Exercise the reflection introspector against the real command classes in a
165+
* non-WP-CLI context. This proves the workspace file-I/O surface that #671 was
166+
* about (read/write/grep/edit/git/patch/ls) is actually produced, and that the
167+
* helper runs without the WP_CLI runner being present.
168+
*/
169+
echo "\nCommandIntrospector against real command classes:\n";
170+
171+
if (! defined('ABSPATH') ) {
172+
define('ABSPATH', __DIR__ . '/');
173+
}
174+
if (! class_exists('WP_CLI') ) {
175+
eval('class WP_CLI {}');
176+
}
177+
if (! class_exists('DataMachine\\Cli\\BaseCommand') ) {
178+
eval('namespace DataMachine\\Cli; class BaseCommand {}');
179+
}
180+
181+
require_once __DIR__ . '/../inc/Runtime/CommandIntrospector.php';
182+
require_once __DIR__ . '/../inc/Cli/Commands/WorkspaceCommand.php';
183+
require_once __DIR__ . '/../inc/Cli/Commands/GitHubCommand.php';
184+
require_once __DIR__ . '/../inc/Cli/Commands/GitSyncCommand.php';
185+
186+
$workspace_subs = \DataMachineCode\Runtime\CommandIntrospector::subcommand_names('\\DataMachineCode\\Cli\\Commands\\WorkspaceCommand');
187+
$github_subs = \DataMachineCode\Runtime\CommandIntrospector::subcommand_names('\\DataMachineCode\\Cli\\Commands\\GitHubCommand');
188+
$gitsync_subs = \DataMachineCode\Runtime\CommandIntrospector::subcommand_names('\\DataMachineCode\\Cli\\Commands\\GitSyncCommand');
189+
190+
$required_file_io = array( 'read', 'write', 'grep', 'edit', 'git', 'patch', 'ls' );
191+
$missing_file_io = array_values(array_diff($required_file_io, $workspace_subs));
192+
193+
$assert(
194+
'workspace reflection surfaces the file-I/O subcommands omitted by #671',
195+
empty($missing_file_io)
196+
);
197+
if (! empty($missing_file_io) ) {
198+
echo ' missing: ' . implode(', ', $missing_file_io) . "\n";
199+
}
200+
$assert(
201+
'workspace reflection still surfaces lifecycle subcommands',
202+
empty(array_diff(array( 'clone', 'adopt', 'worktree', 'hygiene' ), $workspace_subs))
203+
);
204+
$assert(
205+
'github reflection surfaces issue + PR subcommands',
206+
empty(array_diff(array( 'issues', 'pulls', 'review-flow', 'comment' ), $github_subs))
207+
);
208+
$assert(
209+
'gitsync reflection surfaces bind/submit/push subcommands',
210+
empty(array_diff(array( 'bind', 'submit', 'push', 'policy' ), $gitsync_subs))
211+
);
212+
147213
if (! empty($failures) ) {
148214
echo "\nFAIL: " . count($failures) . " assertion(s)\n";
149215
foreach ( $failures as $f ) {

0 commit comments

Comments
 (0)