Skip to content

Commit bf8c8d6

Browse files
chubes4chubes4
andauthored
feat(workspace): inject originating site agent context into worktrees (#46)
Adds a default-on injection step to `workspace worktree add` that snapshots the originating site's agent memory (MEMORY.md, USER.md, RULES.md) into the new worktree so a fresh agent session cooking in that worktree starts with the same architectural context its parent site has accumulated. Written files: <worktree>/.claude/CLAUDE.local.md — Claude Code convention <worktree>/.opencode/AGENTS.local.md — OpenCode convention Both files receive the same runtime-agnostic payload. The injected paths are added to the repository's `info/exclude` (NOT .gitignore, so the tracked repo is never dirtied). git's exclude lookup always uses the common git dir — per-worktree info/exclude is a no-op — so we resolve commondir from the worktree's `.git` file and write there. The patterns are narrow enough to be harmless across other worktrees and the primary checkout, where no injected files ever exist. New CLI: wp datamachine-code workspace worktree add <repo> <branch> [--skip-context-injection] wp datamachine-code workspace worktree refresh-context <handle> New ability: datamachine/workspace-worktree-refresh-context Worktree-add ability gains an `inject_context` input (default true); set false to create a bare worktree. Metadata (`created_from_site`: URL + agent slug + abspath + timestamp) is persisted in the `datamachine_worktree_metadata` option keyed by workspace handle, and dropped when the worktree is removed. When DM's agent memory layer is unavailable (plugin inactive, running outside a site context) injection becomes a graceful no-op: the worktree is still created, the response surfaces `context_injected=false` with a skip reason, and no error fires. Closes #45 Co-authored-by: chubes4 <chris@chubes.net>
1 parent 784e12c commit bf8c8d6

4 files changed

Lines changed: 708 additions & 28 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -706,36 +706,47 @@ private function registerAbilities(): void {
706706
'datamachine/workspace-worktree-add',
707707
array(
708708
'label' => 'Add Workspace Worktree',
709-
'description' => 'Create a git worktree for a branch under `<repo>@<branch-slug>`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally.',
709+
'description' => 'Create a git worktree for a branch under `<repo>@<branch-slug>`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. When `inject_context` is true (default), the originating site\'s agent memory is snapshotted into `.claude/CLAUDE.local.md` and `.opencode/AGENTS.local.md` and added to the worktree\'s per-checkout `info/exclude`.',
710710
'category' => 'datamachine-code-workspace',
711711
'input_schema' => array(
712712
'type' => 'object',
713713
'properties' => array(
714-
'repo' => array(
714+
'repo' => array(
715715
'type' => 'string',
716716
'description' => 'Primary repo name (no @-suffix).',
717717
),
718-
'branch' => array(
718+
'branch' => array(
719719
'type' => 'string',
720720
'description' => 'Branch to check out in the worktree (e.g. fix/foo-bar). Slashes become dashes in the on-disk slug.',
721721
),
722-
'from' => array(
722+
'from' => array(
723723
'type' => 'string',
724724
'description' => 'Base ref when creating the branch (default origin/HEAD).',
725725
),
726+
'inject_context' => array(
727+
'type' => 'boolean',
728+
'description' => 'Inject the originating site\'s agent context (MEMORY.md, USER.md, RULES.md) into the new worktree. Default true. Set false to create a bare worktree.',
729+
),
726730
),
727731
'required' => array( 'repo', 'branch' ),
728732
),
729733
'output_schema' => array(
730734
'type' => 'object',
731735
'properties' => array(
732-
'success' => array( 'type' => 'boolean' ),
733-
'handle' => array( 'type' => 'string' ),
734-
'path' => array( 'type' => 'string' ),
735-
'branch' => array( 'type' => 'string' ),
736-
'slug' => array( 'type' => 'string' ),
737-
'created_branch' => array( 'type' => 'boolean' ),
738-
'message' => array( 'type' => 'string' ),
736+
'success' => array( 'type' => 'boolean' ),
737+
'handle' => array( 'type' => 'string' ),
738+
'path' => array( 'type' => 'string' ),
739+
'branch' => array( 'type' => 'string' ),
740+
'slug' => array( 'type' => 'string' ),
741+
'created_branch' => array( 'type' => 'boolean' ),
742+
'message' => array( 'type' => 'string' ),
743+
'context_injected' => array( 'type' => 'boolean' ),
744+
'context_files' => array(
745+
'type' => 'array',
746+
'items' => array( 'type' => 'string' ),
747+
),
748+
'context_exclude_path' => array( 'type' => 'string' ),
749+
'context_skip_reason' => array( 'type' => 'string' ),
739750
),
740751
),
741752
'execute_callback' => array( self::class, 'worktreeAdd' ),
@@ -744,6 +755,43 @@ private function registerAbilities(): void {
744755
)
745756
);
746757

758+
wp_register_ability(
759+
'datamachine/workspace-worktree-refresh-context',
760+
array(
761+
'label' => 'Refresh Worktree Context',
762+
'description' => 'Re-read the originating site\'s agent memory and rewrite the injected context files (`.claude/CLAUDE.local.md`, `.opencode/AGENTS.local.md`) in an existing worktree. Must be run from the site that created the worktree — cross-machine refresh is not supported.',
763+
'category' => 'datamachine-code-workspace',
764+
'input_schema' => array(
765+
'type' => 'object',
766+
'properties' => array(
767+
'handle' => array(
768+
'type' => 'string',
769+
'description' => 'Worktree handle (`<repo>@<branch-slug>`).',
770+
),
771+
),
772+
'required' => array( 'handle' ),
773+
),
774+
'output_schema' => array(
775+
'type' => 'object',
776+
'properties' => array(
777+
'success' => array( 'type' => 'boolean' ),
778+
'handle' => array( 'type' => 'string' ),
779+
'path' => array( 'type' => 'string' ),
780+
'written' => array(
781+
'type' => 'array',
782+
'items' => array( 'type' => 'string' ),
783+
),
784+
'exclude_path' => array( 'type' => 'string' ),
785+
'metadata' => array( 'type' => 'object' ),
786+
'message' => array( 'type' => 'string' ),
787+
),
788+
),
789+
'execute_callback' => array( self::class, 'worktreeRefreshContext' ),
790+
'permission_callback' => fn() => PermissionHelper::can_manage(),
791+
'meta' => array( 'show_in_rest' => false ),
792+
)
793+
);
794+
747795
wp_register_ability(
748796
'datamachine/workspace-worktree-list',
749797
array(
@@ -1131,13 +1179,27 @@ public static function gitPush( array $input ): array|\WP_Error {
11311179
*/
11321180
public static function worktreeAdd( array $input ): array|\WP_Error {
11331181
$workspace = new Workspace();
1182+
// Default inject_context=true; only false when explicitly provided.
1183+
$inject_context = array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true;
11341184
return $workspace->worktree_add(
11351185
$input['repo'] ?? '',
11361186
$input['branch'] ?? '',
1137-
$input['from'] ?? null
1187+
$input['from'] ?? null,
1188+
$inject_context
11381189
);
11391190
}
11401191

1192+
/**
1193+
* Refresh a worktree's injected context from the originating site.
1194+
*
1195+
* @param array $input Input parameters with 'handle'.
1196+
* @return array|\WP_Error
1197+
*/
1198+
public static function worktreeRefreshContext( array $input ): array|\WP_Error {
1199+
$workspace = new Workspace();
1200+
return $workspace->worktree_refresh_context( $input['handle'] ?? '' );
1201+
}
1202+
11411203
/**
11421204
* List worktrees in the workspace.
11431205
*

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -887,17 +887,26 @@ private function renderGitOperationResult( string $operation, array $result, arr
887887
* ## OPTIONS
888888
*
889889
* <operation>
890-
* : Worktree operation: add, list, remove, prune, cleanup.
890+
* : Worktree operation: add, list, remove, prune, cleanup, refresh-context.
891891
*
892892
* [<repo>]
893-
* : Primary repo name (required for add and remove).
893+
* : Primary repo name (required for add and remove). For refresh-context,
894+
* pass the full worktree handle (`<repo>@<branch-slug>`) here instead.
894895
*
895896
* [<branch>]
896897
* : Branch name (required for add and remove).
897898
*
898899
* [--from=<ref>]
899900
* : Base ref when creating a branch on add (default origin/HEAD).
900901
*
902+
* [--skip-context-injection]
903+
* : Skip injecting the originating site's agent context into a new
904+
* worktree (applies to `add` only). Default behavior is to write
905+
* `.claude/CLAUDE.local.md` and `.opencode/AGENTS.local.md` containing
906+
* the site's MEMORY.md / USER.md / RULES.md snapshot, and add both
907+
* paths to the repository's `info/exclude`. The ability-level input
908+
* is `inject_context=false`; this flag is the CLI shorthand.
909+
*
901910
* [--force]
902911
* : Force-remove a worktree even if it is dirty (applies to `remove` and
903912
* `cleanup`). Does NOT override the unpushed-commits safety in cleanup.
@@ -948,23 +957,30 @@ private function renderGitOperationResult( string $operation, array $result, arr
948957
* # Ignore dirty working-tree safety (caution)
949958
* wp datamachine workspace worktree cleanup --force
950959
*
960+
* # Create a worktree without injecting site-agent context
961+
* wp datamachine workspace worktree add data-machine fix/foo --skip-context-injection
962+
*
963+
* # Re-read the originating site's agent memory into an existing worktree
964+
* wp datamachine workspace worktree refresh-context data-machine@fix-foo
965+
*
951966
* @subcommand worktree
952967
*/
953968
public function worktree( array $args, array $assoc_args ): void {
954969
$operation = $args[0] ?? '';
955970

956971
if ( '' === $operation ) {
957-
WP_CLI::error( 'Usage: wp datamachine workspace worktree <add|list|remove|prune|cleanup> [<repo>] [<branch>] [--flags]' );
972+
WP_CLI::error( 'Usage: wp datamachine workspace worktree <add|list|remove|prune|cleanup|refresh-context> [<repo>] [<branch>] [--flags]' );
958973
return;
959974
}
960975

961976
$ability_name = match ( $operation ) {
962-
'add' => 'datamachine/workspace-worktree-add',
963-
'list' => 'datamachine/workspace-worktree-list',
964-
'remove' => 'datamachine/workspace-worktree-remove',
965-
'prune' => 'datamachine/workspace-worktree-prune',
966-
'cleanup' => 'datamachine/workspace-worktree-cleanup',
967-
default => '',
977+
'add' => 'datamachine/workspace-worktree-add',
978+
'list' => 'datamachine/workspace-worktree-list',
979+
'remove' => 'datamachine/workspace-worktree-remove',
980+
'prune' => 'datamachine/workspace-worktree-prune',
981+
'cleanup' => 'datamachine/workspace-worktree-cleanup',
982+
'refresh-context' => 'datamachine/workspace-worktree-refresh-context',
983+
default => '',
968984
};
969985

970986
if ( '' === $ability_name ) {
@@ -983,14 +999,24 @@ public function worktree( array $args, array $assoc_args ): void {
983999
switch ( $operation ) {
9841000
case 'add':
9851001
if ( empty( $args[1] ) || empty( $args[2] ) ) {
986-
WP_CLI::error( 'Usage: worktree add <repo> <branch> [--from=<ref>]' );
1002+
WP_CLI::error( 'Usage: worktree add <repo> <branch> [--from=<ref>] [--skip-context-injection]' );
9871003
return;
9881004
}
9891005
$input['repo'] = $args[1];
9901006
$input['branch'] = $args[2];
9911007
if ( ! empty( $assoc_args['from'] ) ) {
9921008
$input['from'] = (string) $assoc_args['from'];
9931009
}
1010+
// --skip-context-injection disables the default-on injection step.
1011+
$input['inject_context'] = empty( $assoc_args['skip-context-injection'] );
1012+
break;
1013+
1014+
case 'refresh-context':
1015+
if ( empty( $args[1] ) ) {
1016+
WP_CLI::error( 'Usage: worktree refresh-context <handle>' );
1017+
return;
1018+
}
1019+
$input['handle'] = (string) $args[1];
9941020
break;
9951021

9961022
case 'list':
@@ -1118,6 +1144,36 @@ private function renderWorktreeResult( string $operation, array $result, array $
11181144
WP_CLI::log( sprintf( 'Path: %s', $result['path'] ?? '-' ) );
11191145
WP_CLI::log( sprintf( 'Branch: %s%s', $result['branch'] ?? '-', ! empty( $result['created_branch'] ) ? ' (created)' : '' ) );
11201146
}
1147+
if ( isset( $result['context_injected'] ) ) {
1148+
if ( ! empty( $result['context_injected'] ) ) {
1149+
$written = $result['context_files'] ?? array();
1150+
WP_CLI::log( sprintf( 'Context: injected (%d file%s)', count( $written ), 1 === count( $written ) ? '' : 's' ) );
1151+
foreach ( $written as $file ) {
1152+
WP_CLI::log( ' - ' . $file );
1153+
}
1154+
if ( ! empty( $result['context_exclude_path'] ) ) {
1155+
WP_CLI::log( sprintf( 'Excluded via: %s', $result['context_exclude_path'] ) );
1156+
}
1157+
} else {
1158+
$reason = $result['context_skip_reason'] ?? 'unknown';
1159+
WP_CLI::log( sprintf( 'Context: not injected (%s)', $reason ) );
1160+
}
1161+
}
1162+
return;
1163+
1164+
case 'refresh-context':
1165+
WP_CLI::success( $result['message'] ?? 'Worktree context refreshed.' );
1166+
WP_CLI::log( sprintf( 'Handle: %s', $result['handle'] ?? '-' ) );
1167+
WP_CLI::log( sprintf( 'Path: %s', $result['path'] ?? '-' ) );
1168+
foreach ( (array) ( $result['written'] ?? array() ) as $file ) {
1169+
WP_CLI::log( ' - ' . $file );
1170+
}
1171+
if ( ! empty( $result['exclude_path'] ) ) {
1172+
WP_CLI::log( sprintf( 'Exclude file: %s', $result['exclude_path'] ) );
1173+
}
1174+
if ( ! empty( $result['metadata']['site_url'] ) ) {
1175+
WP_CLI::log( sprintf( 'Originating site: %s', $result['metadata']['site_url'] ) );
1176+
}
11211177
return;
11221178

11231179
case 'remove':

0 commit comments

Comments
 (0)