Skip to content

Commit 925da26

Browse files
authored
Merge pull request #808 from Extra-Chill/fix/surface-stale-checkouts-in-agents-md
Surface stale primaries in AGENTS inventory
2 parents 90bd720 + c674164 commit 925da26

5 files changed

Lines changed: 370 additions & 17 deletions

File tree

inc/Runtime/AgentsMdSections.php

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ public static function register(): void {
2626
}
2727

2828
$registry_class = '\DataMachine\Engine\AI\MemoryFileRegistry';
29-
if ( ! is_callable(array( $registry_class, 'register' )) ) {
30-
return;
31-
}
32-
$register = array( $registry_class, 'register' );
29+
$register = array( $registry_class, 'register' );
3330
/** @var callable $register */
3431

3532
call_user_func(
@@ -149,6 +146,7 @@ private static function render_workspace_policy_intro( string $workspace_path ):
149146
* @param string $default Default policy markdown.
150147
* @param string $workspace_path Resolved DMC workspace root.
151148
*/
149+
/** @var mixed $filtered */
152150
$filtered = apply_filters('datamachine_code_workspace_policy_intro', $default, $workspace_path);
153151
if ( ! is_string($filtered) ) {
154152
return $default;
@@ -175,6 +173,7 @@ private static function render_workspace_policy_section( string $workspace_path,
175173
* @param string $workspace_path Resolved DMC workspace root.
176174
* @param string $wp WP-CLI command prefix.
177175
*/
176+
/** @var mixed $filtered */
178177
$filtered = apply_filters('datamachine_code_workspace_policy_section', $default, $workspace_path, $wp);
179178
if ( ! is_string($filtered) ) {
180179
return $default;
@@ -288,10 +287,7 @@ private static function resolve_wp_cli_cmd(): string {
288287
*/
289288
private static function register_section( string $file, string $section, int $priority, callable $callback, array $metadata ): void {
290289
$registry_class = '\DataMachine\Engine\AI\SectionRegistry';
291-
if ( ! is_callable(array( $registry_class, 'register' )) ) {
292-
return;
293-
}
294-
$register = array( $registry_class, 'register' );
290+
$register = array( $registry_class, 'register' );
295291
/** @var callable $register */
296292

297293
call_user_func($register, $file, $section, $priority, $callback, $metadata);
@@ -374,21 +370,32 @@ private static function render_workspace_inventory_section( string $wp ): string
374370
$mode = 'compact';
375371
}
376372

377-
$lines = array();
373+
$lines = array();
374+
$attention_lines = array();
378375
foreach ( $by_repo as $repo => $bucket ) {
379-
$primary = $bucket['primary'];
376+
$primary = is_array($bucket['primary']) ? $bucket['primary'] : array();
380377
$worktrees = $bucket['worktrees'];
381378
$wt_count = count($worktrees);
382379
$branch = $primary['branch'] ?? null;
383380
$remote = $primary['remote'] ?? null;
384381
$branch_str = ( null !== $branch && '' !== $branch ) ? sprintf(' (`%s`)', $branch) : '';
382+
$freshness = is_array($primary['primary_freshness'] ?? null) ? $primary['primary_freshness'] : null;
383+
384+
$attention = self::format_primary_freshness_attention($repo, $freshness);
385+
if ( '' !== $attention ) {
386+
$attention_lines[] = $attention;
387+
}
385388

386389
if ( 'compact' === $mode ) {
387390
$suffix_parts = array();
388391
$suffix_parts[] = sprintf('%d %s', $wt_count, 1 === $wt_count ? 'worktree' : 'worktrees');
389392
if ( null !== $remote && '' !== $remote ) {
390393
$suffix_parts[] = $remote;
391394
}
395+
$freshness_badge = self::format_primary_freshness_badge($freshness);
396+
if ( '' !== $freshness_badge ) {
397+
$suffix_parts[] = $freshness_badge;
398+
}
392399
$lines[] = sprintf('- **%s**%s — %s', $repo, $branch_str, implode(' · ', $suffix_parts));
393400
continue;
394401
}
@@ -397,6 +404,10 @@ private static function render_workspace_inventory_section( string $wp ): string
397404
if ( null !== $remote && '' !== $remote ) {
398405
$header .= '' . $remote;
399406
}
407+
$freshness_badge = self::format_primary_freshness_badge($freshness);
408+
if ( '' !== $freshness_badge ) {
409+
$header .= ' · ' . $freshness_badge;
410+
}
400411
$lines[] = $header;
401412

402413
usort(
@@ -415,11 +426,12 @@ private static function render_workspace_inventory_section( string $wp ): string
415426
}
416427
}
417428

418-
$body = implode("\n", $lines);
419-
$generated_at = gmdate('c');
420-
$workspace_path = $listing['path'];
421-
$agent_slug = self::resolve_agent_slug();
422-
$agent_suffix = '' !== $agent_slug ? ' --agent=' . $agent_slug : '';
429+
$body = implode("\n", $lines);
430+
$attention_block = self::render_primary_freshness_attention_block($attention_lines);
431+
$generated_at = gmdate('c');
432+
$workspace_path = $listing['path'];
433+
$agent_slug = self::resolve_agent_slug();
434+
$agent_suffix = '' !== $agent_slug ? ' --agent=' . $agent_slug : '';
423435

424436
return <<<MD
425437
## Workspace Inventory
@@ -428,6 +440,101 @@ private static function render_workspace_inventory_section( string $wp ): string
428440
429441
Refresh this file with `{$wp} datamachine memory compose AGENTS.md{$agent_suffix}` after workspace changes if the inventory looks stale.
430442
443+
{$attention_block}
444+
445+
{$body}
446+
MD;
447+
}
448+
449+
private static function primary_freshness_needs_attention( ?array $freshness ): bool {
450+
if ( null === $freshness ) {
451+
return false;
452+
}
453+
454+
$status = (string) ( $freshness['status'] ?? '' );
455+
return in_array($status, array( 'stale', 'diverged', 'detached', 'unknown', 'no_upstream', 'ahead' ), true);
456+
}
457+
458+
private static function primary_freshness_needs_refresh( ?array $freshness ): bool {
459+
if ( null === $freshness ) {
460+
return false;
461+
}
462+
463+
$status = (string) ( $freshness['status'] ?? '' );
464+
return in_array($status, array( 'stale', 'diverged' ), true);
465+
}
466+
467+
private static function format_primary_freshness_badge( ?array $freshness ): string {
468+
if ( ! self::primary_freshness_needs_attention($freshness) ) {
469+
return '';
470+
}
471+
472+
$status = (string) ( $freshness['status'] ?? 'unknown' );
473+
$parts = array( 'primary ' . $status );
474+
if ( isset($freshness['behind']) && is_numeric($freshness['behind']) && (int) $freshness['behind'] > 0 ) {
475+
$parts[] = sprintf('behind %d', (int) $freshness['behind']);
476+
}
477+
if ( isset($freshness['ahead']) && is_numeric($freshness['ahead']) && (int) $freshness['ahead'] > 0 ) {
478+
$parts[] = sprintf('ahead %d', (int) $freshness['ahead']);
479+
}
480+
481+
return implode(', ', $parts);
482+
}
483+
484+
private static function format_primary_freshness_attention( string $repo, ?array $freshness ): string {
485+
if ( ! self::primary_freshness_needs_attention($freshness) ) {
486+
return '';
487+
}
488+
489+
$status = (string) ( $freshness['status'] ?? 'unknown' );
490+
$branch = (string) ( $freshness['branch'] ?? '' );
491+
$upstream = (string) ( $freshness['upstream'] ?? '' );
492+
$details = array();
493+
if ( '' !== $branch ) {
494+
$details[] = sprintf('branch `%s`', $branch);
495+
}
496+
if ( '' !== $upstream ) {
497+
$details[] = sprintf('upstream `%s`', $upstream);
498+
}
499+
if ( isset($freshness['behind']) && is_numeric($freshness['behind']) ) {
500+
$details[] = sprintf('behind %d', (int) $freshness['behind']);
501+
}
502+
if ( isset($freshness['ahead']) && is_numeric($freshness['ahead']) ) {
503+
$details[] = sprintf('ahead %d', (int) $freshness['ahead']);
504+
}
505+
506+
$line = sprintf('- **%s** primary is `%s`', $repo, $status);
507+
if ( ! empty($details) ) {
508+
$line .= ' (' . implode(', ', $details) . ')';
509+
}
510+
511+
$command = (string) ( $freshness['suggested_command'] ?? '' );
512+
if ( '' !== $command && self::primary_freshness_needs_refresh($freshness) ) {
513+
$line .= sprintf('. Refresh: `%s`', $command);
514+
}
515+
516+
return $line . '.';
517+
}
518+
519+
private static function render_primary_freshness_attention_block( array $attention_lines ): string {
520+
if ( empty($attention_lines) ) {
521+
return '';
522+
}
523+
524+
$max_lines = 20;
525+
$shown = array_slice($attention_lines, 0, $max_lines);
526+
$omitted = count($attention_lines) - count($shown);
527+
if ( $omitted > 0 ) {
528+
$shown[] = sprintf('- %d more primary checkout(s) need attention; run `wp datamachine-code workspace list` for the full set.', $omitted);
529+
}
530+
531+
$body = implode("\n", $shown);
532+
533+
return <<<MD
534+
**Primary Checkout Attention**
535+
536+
These primary checkouts may be stale or unsafe to read. Refresh them or create a worktree from an explicit remote ref before using them as source evidence.
537+
431538
{$body}
432539
MD;
433540
}

inc/Workspace/WorkspaceCoreUtilities.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,7 +1091,7 @@ private function protect_directory( string $path ): void {
10911091
* The payload mirrors the workspace `name`/`repo` taxonomy used elsewhere
10921092
* in this class:
10931093
*
1094-
* - `op`: one of `clone`, `adopt`, `remove`, `worktree_add`, `worktree_remove`.
1094+
* - `op`: one of `clone`, `adopt`, `remove`, `primary_refresh`, `worktree_add`, `worktree_remove`.
10951095
* - `repo`: bare repository name (no `@<slug>` suffix).
10961096
* - `name`: workspace entry name on disk (`<repo>` for primaries,
10971097
* `<repo>@<slug>` for worktrees).
@@ -1112,7 +1112,7 @@ protected function emit_workspace_changed( string $op, string $repo, string $nam
11121112
* @since 0.31.0
11131113
*
11141114
* @param array{op: string, repo: string, name: string, path: string} $payload {
1115-
* @type string $op One of clone|adopt|remove|worktree_add|worktree_remove.
1115+
* @type string $op One of clone|adopt|remove|primary_refresh|worktree_add|worktree_remove.
11161116
* @type string $repo Bare repository name (no @-suffix).
11171117
* @type string $name Workspace entry name (<repo> or <repo>@<slug>).
11181118
* @type string $path Absolute path of the workspace entry.

inc/Workspace/WorkspaceGitOperations.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ public function git_pull( string $handle, bool $allow_dirty = false, bool $allow
121121
return $result;
122122
}
123123

124+
if ( empty($parsed['is_worktree']) ) {
125+
$this->emit_workspace_changed('primary_refresh', $parsed['repo'], $parsed['dir_name'], $repo_path);
126+
}
127+
124128
return array(
125129
'success' => true,
126130
'message' => trim( (string) $result['output']),
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DataMachine\Engine\AI {
6+
final class MemoryFileRegistry {
7+
public const LAYER_SHARED = 'shared';
8+
9+
public static function register( string $file, int $priority, array $metadata ): void {}
10+
}
11+
12+
final class SectionRegistry {
13+
public static array $sections = array();
14+
15+
public static function register( string $file, string $section, int $priority, callable $callback, array $metadata ): void {
16+
self::$sections[ $section ] = compact('file', 'section', 'priority', 'callback', 'metadata');
17+
}
18+
}
19+
}
20+
21+
namespace DataMachineCode\Workspace {
22+
final class Workspace {
23+
public function get_path(): string {
24+
return '/tmp/dmc-workspace';
25+
}
26+
27+
public function list_repos(): array {
28+
return array(
29+
'success' => true,
30+
'path' => '/tmp/dmc-workspace',
31+
'repos' => array(
32+
array(
33+
'name' => 'current-repo',
34+
'repo' => 'current-repo',
35+
'git' => true,
36+
'is_worktree' => false,
37+
'branch' => 'main',
38+
'remote' => 'https://example.com/current-repo.git',
39+
'primary_freshness' => array(
40+
'status' => 'current',
41+
'branch' => 'main',
42+
'upstream' => 'origin/main',
43+
'behind' => 0,
44+
'ahead' => 0,
45+
),
46+
),
47+
array(
48+
'name' => 'stale-repo',
49+
'repo' => 'stale-repo',
50+
'git' => true,
51+
'is_worktree' => false,
52+
'branch' => 'trunk',
53+
'remote' => 'https://example.com/stale-repo.git',
54+
'primary_freshness' => array(
55+
'status' => 'stale',
56+
'branch' => 'trunk',
57+
'upstream' => 'origin/trunk',
58+
'behind' => 7,
59+
'ahead' => 0,
60+
'suggested_command' => 'wp datamachine-code workspace git pull stale-repo --allow-primary-refresh',
61+
),
62+
),
63+
array(
64+
'name' => 'stale-repo@fix-example',
65+
'repo' => 'stale-repo',
66+
'git' => true,
67+
'is_worktree' => true,
68+
'branch_slug' => 'fix-example',
69+
'branch' => 'fix/example',
70+
),
71+
),
72+
);
73+
}
74+
}
75+
}
76+
77+
namespace {
78+
if ( ! defined('ABSPATH') ) {
79+
define('ABSPATH', '/var/www/html');
80+
}
81+
82+
function datamachine_agents_md_enabled(): bool {
83+
return true;
84+
}
85+
86+
function is_multisite(): bool {
87+
return false;
88+
}
89+
90+
function apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixed {
91+
return $value;
92+
}
93+
94+
function is_wp_error( mixed $thing ): bool {
95+
return false;
96+
}
97+
98+
function assert_contains( string $needle, string $haystack, string $message ): void {
99+
if ( ! str_contains($haystack, $needle) ) {
100+
throw new RuntimeException($message);
101+
}
102+
}
103+
104+
function assert_not_contains( string $needle, string $haystack, string $message ): void {
105+
if ( str_contains($haystack, $needle) ) {
106+
throw new RuntimeException($message);
107+
}
108+
}
109+
110+
require_once dirname(__DIR__) . '/inc/Runtime/CommandIntrospector.php';
111+
require_once dirname(__DIR__) . '/inc/Runtime/AgentsMdSections.php';
112+
113+
\DataMachineCode\Runtime\AgentsMdSections::register();
114+
115+
$sections = \DataMachine\Engine\AI\SectionRegistry::$sections;
116+
if ( ! isset($sections['workspace-inventory']) ) {
117+
throw new RuntimeException('workspace-inventory section was not registered');
118+
}
119+
120+
$rendered = $sections['workspace-inventory']['callback']();
121+
122+
assert_contains('**Primary Checkout Attention**', $rendered, 'primary freshness attention block missing');
123+
assert_contains('These primary checkouts may be stale or unsafe to read.', $rendered, 'primary freshness guidance missing');
124+
assert_contains('- **stale-repo** primary is `stale` (branch `trunk`, upstream `origin/trunk`, behind 7, ahead 0). Refresh: `wp datamachine-code workspace git pull stale-repo --allow-primary-refresh`.', $rendered, 'stale primary details missing');
125+
assert_contains('- **stale-repo** (`trunk`) — 1 worktree · https://example.com/stale-repo.git · primary stale, behind 7', $rendered, 'compact stale primary badge missing');
126+
assert_not_contains('- **current-repo** primary is `current`', $rendered, 'current primary should not appear in attention block');
127+
128+
fwrite(STDOUT, "agents-md workspace freshness smoke passed\n");
129+
}

0 commit comments

Comments
 (0)