diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 090b8f7..450ce28 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -254,14 +254,11 @@ jobs: - detect-php-version - detect-site-domain - dmc-migration-inventory - - dmc-managed-release - external-wordpress-runtime - - dm-workspace-discovery - homeboy-codebox-canary - homeboy-components - homeboy-project-id - homeboy-verification-guidance - - homeboy-worktree-adapter - kimaki-credential-seeding - kimaki-install-existing - kimaki-launchd-start diff --git a/README.md b/README.md index eb9b096..68bfdc8 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ The agent should know only what it can use. - **Developer orchestration layers** add guidance only when installed and verified. - **Unavailable tools** do not get stub instructions, fallback recipes, or negative constraints. -For example, a Kimaki install should know Kimaki is the Discord surface. It should not learn generic Kimaki worktree, tunnel, or session-fanout recipes when those responsibilities belong to other installed components. Likewise, Homeboy guidance appears only when Homeboy is available. Data Machine Code owns the WordPress-side repository, workspace, GitHub, and data-machine capabilities; Homeboy owns its native Rust worktree lifecycle and Cook. +For example, a Kimaki install should know Kimaki is the Discord surface. It should not learn generic Kimaki worktree, tunnel, or session-fanout recipes when those responsibilities belong to other installed components. Likewise, Homeboy guidance appears only when Homeboy is available. wp-coding-agents policy declares repository authority; the selected coding runtime uses its native file, Git, and GitHub tools; Homeboy owns its native Rust worktree lifecycle and Cook. ## What It Enables @@ -92,10 +92,11 @@ SITE_DOMAIN=example.com ./setup.sh ### Repo-Aware Developer Workflows -Use Data Machine Code for WordPress-side repository, workspace, GitHub, and data-machine capabilities, with optional Homeboy orchestration for native Rust worktree lifecycle and Cook. When both are installed, wp-coding-agents' WordPress adapter maps five DMC worktree abilities to Homeboy without making either project depend on the other. +Declare each primary Git checkout with wp-coding-agents, then use the selected coding runtime's native file, Git, and GitHub tools there. Homeboy is optional orchestration for native Rust worktree lifecycle and Cook; it attaches declared checkouts and never infers repository authority. ```bash -EXISTING_WP=~/Studio/my-site ./setup.sh --local --with-homeboy +EXISTING_WP=~/Studio/my-site ./setup.sh --local --with-homeboy \ + --workspace-repository ~/Developer/my-site ``` When an optional orchestrator is available, its own presence-gated AGENTS section explains the supported workflow. When it is absent, the prompt does not mention its commands. @@ -109,7 +110,7 @@ When an optional orchestrator is available, its own presence-gated AGENTS sectio | OpenCode | Coding runtime | Selected or auto-detected | | Claude Code | Coding runtime | Selected or auto-detected | | Codex | Coding runtime | Selected or auto-detected | -| Data Machine Code | WordPress-side repository, workspace, git, GitHub, and data-machine capabilities | Installed with the Data Machine stack | +| wp-coding-agents | Source policy, declared repository authority, runtime permissions, setup, and upgrade | Always installed | | Kimaki | Discord bridge for OpenCode sessions | Optional chat bridge | | cc-connect | Multi-platform bridge, commonly used with Claude Code | Optional chat bridge | | opencode-telegram | Telegram bridge for OpenCode | Optional chat bridge | @@ -267,6 +268,7 @@ operator-entrypoints/wp-coding-agents-setup/setup.md | --- | --- | | `--runtime ` | Coding runtime: `opencode`, `claude-code`, or `codex`. Auto-detected when omitted. | | `--source-mode ` | `workspace` (default) or `owned`. See [Source Mode](#source-mode). `--posture` is a deprecated alias. | +| `--workspace-repository ` | Primary Git checkout authority for workspace mode. Repeatable; each path must already be a Git checkout. | | `--owned-source ` | wp-content path the site owns and may edit under `--source-mode owned`. Repeatable. | | `--owned-writable ` | Denied path to re-open for editing (e.g. `wp-config.php`). Not captured. Repeatable. | | `--log-path ` | Absolute path outside the site root the agent may read. Repeatable. | @@ -309,8 +311,8 @@ read-only reference there. What it buys is git and review, not latitude. | `wp-content/mu-plugins/`, `wp-config.php` | read-only | read-only, opt-in | | `wp-content/plugins/`, `wp-content/themes/` | read-only | read-only **except declared owned paths** | | `wp-content/uploads/` | writable (agent memory) | writable (agent memory) | -| Data Machine Code | installed | not installed | -| Workspace, git, GitHub | the agent's workflow | not present | +| Repository authority | explicit primary Git checkout roots | none | +| Workspace, git, GitHub | wp-coding-agents policy plus the runtime's native tools | not present | | How changes reach version control | the agent commits and opens pull requests | captured out-of-band by the operator | | Runtimes | all | `opencode` only | | Service user | root by default | **non-root by default** (`opencode`) | @@ -408,10 +410,11 @@ about WordPress here without skills or fine-tuning. Reading is never restricted. Everything below is about *writing*. **Workspace** is the developer setup: the installed tree is reference -material, and every code change happens in the configured repository workspace -so it is tracked in git and reviewed through GitHub. Data Machine Code provides -the WordPress-side repository, workspace, GitHub, and data-machine capabilities; -when installed, Homeboy owns its native Rust worktree lifecycle and Cook. +material, and every code change happens in explicitly declared primary Git +checkouts so it is tracked in Git and reviewed through GitHub. wp-coding-agents +owns that declaration and runtime policy; the selected coding runtime uses its +native tools. When enabled, Homeboy owns its native Rust worktree lifecycle and +Cook without creating or inferring repository authority. **Owned** is for managed agentic hosting, where a non-technical owner should never have to deal with pull requests. The agent edits the site's own theme and @@ -510,7 +513,7 @@ Codex reads `AGENTS.override.md` from the WordPress site root when present, befo Keeping the Codex memory mirror in `AGENTS.override.md` avoids polluting the shared `AGENTS.md` that OpenCode also reads. On a site with both runtimes, OpenCode keeps using `AGENTS.md` plus `opencode.json` instructions, while Codex gets the same site guidance and memory through its generated override. -Setup installs the managed upgrade skill into `.agents/skills`, registers Codex thread attribution for Data Machine Code when available, and leaves global Codex config and auth state alone. +Setup installs the managed upgrade skill into `.agents/skills`, registers Codex thread attribution when available, and leaves global Codex config and auth state alone. Codex does not currently have a managed chat bridge in this repo, so setup defaults to terminal/manual operation: @@ -624,7 +627,6 @@ Run `./upgrade.sh --help` for upgrade flags. ## Related Projects - [Data Machine](https://github.com/Extra-Chill/data-machine) — WordPress-native agent memory, abilities, flows, and jobs. -- [Data Machine Code](https://github.com/Extra-Chill/data-machine-code) — Repository, workspace, git, and GitHub integration for Data Machine. - [Homeboy](https://github.com/Extra-Chill/homeboy) — Optional orchestration/lab layer for repo-aware coding workflows. - [Kimaki](https://kimaki.xyz) — Discord bridge used by OpenCode installs. diff --git a/docs/data-machine-code-migration-inventory.json b/docs/data-machine-code-migration-inventory.json index 9acac3b..18b4710 100644 --- a/docs/data-machine-code-migration-inventory.json +++ b/docs/data-machine-code-migration-inventory.json @@ -44,6 +44,13 @@ "DataMachineCode\\*", "wp datamachine-code *" ], + "lexical_reference_exclusions": [ + "docs/changelog.md", + "tests/agents-md-guidance.sh", + "tests/installation-profile.sh", + "tests/plugins-only-scope.sh", + "tests/workspace-installation.sh" + ], "rows": [ { "id": "dmc-bootstrap", @@ -365,7 +372,7 @@ "id": "wca-setup-entrypoint", "subsystem": null, "surface": "Workspace source-mode setup orchestration", - "source": { "repository": "wp-coding-agents", "path": "setup.sh", "symbol_or_command": "--source-mode workspace; install_data_machine; discover_dm_workspace_dir; configure_homeboy_worktree_ownership" }, + "source": { "repository": "wp-coding-agents", "path": "setup.sh", "symbol_or_command": "--source-mode workspace; source_policy_resolve_workspace_dir; configure_homeboy_worktree_ownership" }, "evidence_kind": "entrypoint", "contracts": ["data-machine-code"], "current_owner": "wp-coding-agents", @@ -380,7 +387,7 @@ "id": "wca-upgrade-entrypoint", "subsystem": null, "surface": "Plugin and integration upgrade orchestration", - "source": { "repository": "wp-coding-agents", "path": "upgrade.sh", "symbol_or_command": "update_data_machine_plugins; reconcile_provider_and_service_state; plugin get data-machine-code" }, + "source": { "repository": "wp-coding-agents", "path": "upgrade.sh", "symbol_or_command": "update_data_machine_plugins; reconcile_provider_and_service_state; plugin get data-machine" }, "evidence_kind": "upgrade", "contracts": ["data-machine-code"], "current_owner": "wp-coding-agents", @@ -394,13 +401,13 @@ { "id": "wca-installer-library", "subsystem": null, - "surface": "DMC install, update, Homeboy availability, workspace discovery, and component sync", - "source": { "repository": "wp-coding-agents", "path": "lib/data-machine.sh", "symbol_or_command": "install_data_machine; upgrade_data_machine_plugins; sync_homeboy_availability; discover_dm_workspace_dir; sync_homeboy_project_components" }, + "surface": "Data Machine install and update without DMC workspace discovery", + "source": { "repository": "wp-coding-agents", "path": "lib/data-machine.sh", "symbol_or_command": "install_data_machine; upgrade_data_machine_plugins" }, "evidence_kind": "installer", "contracts": ["data-machine-code", "datamachine_code_homeboy_available", "wp datamachine-code *"], "current_owner": "wp-coding-agents", "current_consumers": ["setup.sh and upgrade.sh"], - "persistence": "Plugin checkout, DATAMACHINE_WORKSPACE_PATH, availability option, Homeboy component records", + "persistence": "Data Machine plugin checkout and composition constant", "target_owner": "wp-coding-agents", "disposition": "replace", "migration_issue": "#455, #526, #528", @@ -455,7 +462,7 @@ "id": "wca-source-reconcile", "subsystem": null, "surface": "Installer-owned plugin source discovery and generated reconciliation list", - "source": { "repository": "wp-coding-agents", "path": "lib/owned-source-discovery.sh", "symbol_or_command": "owned_discovery_carried_plugins: data-machine-code" }, + "source": { "repository": "wp-coding-agents", "path": "lib/owned-source-discovery.sh", "symbol_or_command": "owned_discovery_carried_plugins excludes DMC" }, "evidence_kind": "installer", "contracts": ["data-machine-code"], "current_owner": "wp-coding-agents", @@ -470,7 +477,7 @@ "id": "wca-source-reconcile-template", "subsystem": null, "surface": "Generated carried plugin slug list", - "source": { "repository": "wp-coding-agents", "path": "templates/wp-coding-agents-source-reconcile.php", "symbol_or_command": "wp_coding_agents_carried_slugs(): data-machine-code" }, + "source": { "repository": "wp-coding-agents", "path": "templates/wp-coding-agents-source-reconcile.php", "symbol_or_command": "wp_coding_agents_carried_slugs(): data-machine, wp-codebox" }, "evidence_kind": "runtime", "contracts": ["data-machine-code"], "current_owner": "wp-coding-agents", @@ -485,7 +492,7 @@ "id": "wca-source-policy", "subsystem": null, "surface": "Workspace mode definition and DMC installation predicate", - "source": { "repository": "wp-coding-agents", "path": "lib/source-policy.sh", "symbol_or_command": "source_policy_workspace_enabled" }, + "source": { "repository": "wp-coding-agents", "path": "lib/source-policy.sh", "symbol_or_command": "source_policy_workspace_repositories; source_policy_resolve_workspace_dir" }, "evidence_kind": "runtime", "contracts": ["datamachine_code_homeboy_available", "data-machine-code"], "current_owner": "wp-coding-agents", @@ -1252,12 +1259,12 @@ "reference_file_classification": { "runtime": [ "lib/integration-adapters.sh", - "templates/wp-coding-agents-homeboy-worktrees.php", + "lib/runtime-signature.sh", "templates/wp-coding-agents-cli-transport.php" ], "tests": [ - "tests/homeboy-worktree-adapter.sh", "tests/integration-adapters.sh", + "tests/runtime-signature.sh", "tests/smoke-cli-transport.php", "tests/worktree-context-projections.sh" ], diff --git a/lib/agents-md-guidance.sh b/lib/agents-md-guidance.sh index e21130b..c2338b5 100644 --- a/lib/agents-md-guidance.sh +++ b/lib/agents-md-guidance.sh @@ -2,10 +2,10 @@ # lib/agents-md-guidance.sh — AGENTS.md SectionRegistry guidance MECHANISM. # # wp-coding-agents owns integration-specific runtime guidance for the tools it -# installs and wires together. Data Machine Code owns the generic AGENTS.md +# installs and wires together. Data Machine owns the generic AGENTS.md # composition substrate; this helper publishes wp-coding-agents' integration # guidance into that substrate through Data Machine's SectionRegistry without -# making DMC know about Homeboy, Kimaki, OpenCode, or WP Codebox conventions. +# coupling Data Machine to Homeboy, Kimaki, OpenCode, or WP Codebox conventions. # # This file is the mechanism ONLY: scaffold the mu-plugin, render a # marker-delimited PHP block, rewrite it idempotently, remove it on request. It diff --git a/lib/data-machine.sh b/lib/data-machine.sh index 3a6d70a..0c09539 100644 --- a/lib/data-machine.sh +++ b/lib/data-machine.sh @@ -10,32 +10,6 @@ install_data_machine() { log " $(wp_cli_transport_display) plugin activate data-machine --url=subsite.$SITE_DOMAIN $WP_ROOT_FLAG" fi - # Data Machine Code is the workspace/git/GitHub tool surface. A managed - # install has no workspace by design — the agent edits live source and - # changes are captured out-of-band — so installing DMC there contributes - # ~90 abilities the agent cannot use plus an AGENTS.md section instructing - # it to route work through a git workflow it has no access to. AGENTS.md - # composition is unaffected: data-machine core registers the composable file - # itself behind the same DATAMACHINE_COMPOSE_AGENTS_MD gate. - if source_policy_workspace_enabled; then - log "Installing Data Machine Code (developer tools)..." - install_plugin data-machine-code https://github.com/Extra-Chill/data-machine-code.git - - # Set workspace path in wp-config.php if not already defined - if [ "$DRY_RUN" = false ] && [ -f "$SITE_PATH/wp-config.php" ] && [ "$IS_STUDIO" = false ]; then - if ! grep -q 'DATAMACHINE_WORKSPACE_PATH' "$SITE_PATH/wp-config.php"; then - wp_cmd config set DATAMACHINE_WORKSPACE_PATH "$DM_WORKSPACE_DIR" --type=constant - log "Set DATAMACHINE_WORKSPACE_PATH to $DM_WORKSPACE_DIR" - else - log "DATAMACHINE_WORKSPACE_PATH already defined in wp-config.php" - fi - elif [ "$DRY_RUN" = true ]; then - echo -e "${BLUE}[dry-run]${NC} $(wp_cli_transport_display) config set DATAMACHINE_WORKSPACE_PATH $DM_WORKSPACE_DIR --type=constant" - fi - else - log "Skipping Data Machine Code (source mode: ${SOURCE_MODE:-owned} — no workspace on this install)" - fi - set_compose_agents_md_constant } @@ -46,8 +20,8 @@ install_data_machine() { # because its presence is the signal that an external coding agent lives here — # installs without one stay default-OFF and emit zero AGENTS.md noise. # -# Mirrors the DATAMACHINE_WORKSPACE_PATH block above: idempotent grep-guard, -# respects DRY_RUN / IS_STUDIO / wp-config.php existence. Written as a raw +# Uses an idempotent grep-guard and respects DRY_RUN / IS_STUDIO / wp-config.php +# existence. Written as a raw # boolean (true) via --raw so the define is `define( ..., true )`, not the # string "true". Safe to (re-)run on both setup and upgrade; harmless even if # core does not yet read the constant. @@ -78,20 +52,6 @@ upgrade_data_machine_plugins() { plugin_update_execute data-machine update_plugin_to_latest_tag data-machine https://github.com/Extra-Chill/data-machine.git || status=$PLUGIN_UPDATE_EXIT_PARTIAL fi - local dmc_plugin_dir="$SITE_PATH/wp-content/plugins/data-machine-code" - if [ "${PLUGINS_ONLY:-false}" = true ]; then - if [ -d "$dmc_plugin_dir" ]; then - plugin_update_execute data-machine-code update_plugin_to_latest_tag data-machine-code https://github.com/Extra-Chill/data-machine-code.git || status=$PLUGIN_UPDATE_EXIT_PARTIAL - else - log "[data-machine-code] terminal=skipped reason=not-installed" - fi - # Managed installs deliberately have no DMC. Without this gate every full - # upgrade would silently reinstall it after an operator removed it. - elif source_policy_workspace_enabled; then - plugin_update_execute data-machine-code update_plugin_to_latest_tag data-machine-code https://github.com/Extra-Chill/data-machine-code.git || status=$PLUGIN_UPDATE_EXIT_PARTIAL - else - log " Skipping Data Machine Code (source mode: ${SOURCE_MODE:-owned})" - fi return "$status" } @@ -131,353 +91,6 @@ create_dm_agent() { fi } -discover_dm_workspace_dir() { - if [ -n "${DATAMACHINE_WORKSPACE_PATH:-}" ]; then - DM_WORKSPACE_DIR="$DATAMACHINE_WORKSPACE_PATH" - return 0 - fi - - if [ "${DRY_RUN:-false}" = true ] || [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then - return 0 - fi - - local timeout_seconds="${DM_WORKSPACE_DISCOVERY_TIMEOUT_SECONDS:-30}" - case "$timeout_seconds" in - ''|*[!0-9]*) timeout_seconds=30 ;; - esac - [ "$timeout_seconds" -gt 0 ] || timeout_seconds=30 - - local temp_dir workspace_file stderr_file pid ticks max_ticks status replay_path replay_command - temp_dir="$(mktemp -d)" || return 1 - workspace_file="$temp_dir/workspace" - stderr_file="$temp_dir/stderr" - printf -v replay_path '%q' "$SITE_PATH" - replay_command="$(wp_cli_transport_display) datamachine-code workspace path ${WP_ROOT_FLAG:-} --path=$replay_path" - - # Once authoritative discovery starts, a stale default must not survive a - # failure and masquerade as the discovered workspace. - DM_WORKSPACE_DIR="" - log "Discovering authoritative DMC workspace (timeout: ${timeout_seconds}s, elapsed: 0s)..." - - local restore_monitor=false - case "$-" in - *m*) ;; - *) set -m; restore_monitor=true ;; - esac - wp_cmd datamachine-code workspace path >"$workspace_file" 2>"$stderr_file" & - pid=$! - [ "$restore_monitor" = false ] || set +m - - ticks=0 - max_ticks=$((timeout_seconds * 10)) - while kill -0 "$pid" 2>/dev/null; do - if [ "$ticks" -ge "$max_ticks" ]; then - kill -TERM -- "-$pid" 2>/dev/null || true - sleep 0.1 - kill -KILL -- "-$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - [ ! -s "$stderr_file" ] || cat "$stderr_file" >&2 - rm -f "$workspace_file" "$stderr_file" - rmdir "$temp_dir" 2>/dev/null || true - warn "DMC workspace discovery timed out after ${timeout_seconds}s. Replay: $replay_command" - return 124 - fi - sleep 0.1 - ticks=$((ticks + 1)) - done - - if wait "$pid"; then status=0; else status=$?; fi - [ ! -s "$stderr_file" ] || cat "$stderr_file" >&2 - if [ "$status" -ne 0 ]; then - rm -f "$workspace_file" "$stderr_file" - rmdir "$temp_dir" 2>/dev/null || true - warn "DMC workspace discovery failed with exit status $status. Replay: $replay_command" - return "$status" - fi - - if ! DM_WORKSPACE_DIR="$(python3 - "$workspace_file" <<'PY' -import re -import sys - -lines = open(sys.argv[1], encoding="utf-8").read().splitlines() -while lines: - line = lines.pop(0) - if not line.strip(): - continue - if re.match(r"^(?:PHP )?(?:Deprecated|Warning|Notice):\s", line.lstrip()): - continue - lines.insert(0, line) - break - -values = [line for line in lines if line.strip()] -if len(values) != 1: - raise SystemExit(1) -print(values[0]) -PY - )"; then - DM_WORKSPACE_DIR="" - rm -f "$workspace_file" "$stderr_file" - rmdir "$temp_dir" 2>/dev/null || true - warn "DMC workspace discovery returned invalid output. Replay: $replay_command" - return 1 - fi - rm -f "$workspace_file" "$stderr_file" - rmdir "$temp_dir" 2>/dev/null || true - if [ -z "$DM_WORKSPACE_DIR" ]; then - warn "DMC workspace discovery returned an empty path. Replay: $replay_command" - return 1 - fi -} - # NOTE: homeboy_project_id() is defined once, in lib/homeboy.sh, which is # sourced after this file. A duplicate definition used to live here and was # silently shadowed by the homeboy.sh copy (see #170) — keep it single-sourced. - -sync_homeboy_project_components() { - if ! command -v homeboy >/dev/null 2>&1; then - return 0 - fi - - discover_dm_workspace_dir - - local project_id - if ! project_id=$(homeboy_project_id); then - warn "Homeboy project config not found at site root — skipping DMC component attachment" - return 0 - fi - - if [ -z "$project_id" ]; then - warn "Homeboy project config returned empty id — skipping DMC component attachment" - return 0 - fi - - if [ -z "${DM_WORKSPACE_DIR:-}" ]; then - warn "DMC workspace path not configured — skipping Homeboy component attachment" - return 0 - fi - - if [ ! -d "$DM_WORKSPACE_DIR" ]; then - warn "DMC workspace path does not exist ($DM_WORKSPACE_DIR) — skipping Homeboy component attachment" - return 0 - fi - - log "Attaching Homeboy components from DMC workspace: $DM_WORKSPACE_DIR" - - prune_homeboy_project_components "$project_id" - - local attached=0 - local skipped=0 - local failed=0 - local repo_path repo_name - - shopt -s nullglob - for repo_path in "$DM_WORKSPACE_DIR"/*; do - [ -d "$repo_path" ] || continue - repo_name=$(basename "$repo_path") - - if [ -n "${SITE_PATH:-}" ] && [ "$repo_path" = "$SITE_PATH" ]; then - log " skipped $repo_name: site project root" - skipped=$((skipped + 1)) - continue - fi - - if [[ "$repo_name" == *"@"* ]]; then - log " skipped $repo_name: worktree skipped" - skipped=$((skipped + 1)) - continue - fi - - if [ ! -f "$repo_path/homeboy.json" ]; then - log " skipped $repo_name: no homeboy.json" - skipped=$((skipped + 1)) - continue - fi - - if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE}[dry-run]${NC} homeboy project components attach-path $project_id $repo_path" - attached=$((attached + 1)) - continue - fi - - local attach_output attach_status - set +e - attach_output="$(homeboy_run project components attach-path "$project_id" "$repo_path" 2>&1)" - attach_status=$? - set -e - - # Trust Homeboy's JSON `.success` field over the raw exit code: the CLI - # can print a success payload while still returning a non-zero status (or - # vice versa). Fall back to the exit code only when the output is not - # parseable JSON. - if homeboy_attach_succeeded "$attach_output" "$attach_status"; then - log " attached $repo_name" - attached=$((attached + 1)) - else - warn " failed $repo_name: homeboy attach-path failed" - if [ -n "$attach_output" ]; then - warn " $(printf '%s' "$attach_output" | head -n 3 | tr '\n' ' ')" - fi - failed=$((failed + 1)) - fi - done - shopt -u nullglob - - log "Homeboy component sync complete: $attached attached, $skipped skipped, $failed failed" -} - -prune_homeboy_project_components() { - local project_id="$1" - - [ -n "$project_id" ] || return 0 - [ -n "${DM_WORKSPACE_DIR:-}" ] || return 0 - - local project_json - project_json="$(homeboy_run project show "$project_id" 2>/dev/null || true)" - [ -n "$project_json" ] || return 0 - - local component_ids - component_ids="$(HOMEBOY_PROJECT_JSON="$project_json" HOMEBOY_DM_WORKSPACE_DIR="$DM_WORKSPACE_DIR" python3 <<'PY' -import json -import os -from pathlib import Path - -try: - payload = json.loads(os.environ.get("HOMEBOY_PROJECT_JSON", "")) -except Exception: - raise SystemExit(0) - -workspace = Path(os.environ.get("HOMEBOY_DM_WORKSPACE_DIR", "")).resolve() -components = payload.get("data", {}).get("entity", {}).get("components", []) -for component in components: - component_id = component.get("id") or "" - local_path = component.get("local_path") or "" - if not component_id or not local_path: - continue - - path = Path(local_path).expanduser() - try: - resolved = path.resolve() - except Exception: - resolved = path.absolute() - - try: - resolved.relative_to(workspace) - except ValueError: - continue - - if "@" in path.name or not (resolved / "homeboy.json").is_file(): - print(component_id) -PY -)" - - [ -n "$component_ids" ] || return 0 - - local remove_ids=() - local component_id - while IFS= read -r component_id; do - [ -n "$component_id" ] || continue - remove_ids+=("$component_id") - done <<< "$component_ids" - - [ ${#remove_ids[@]} -gt 0 ] || return 0 - - if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE}[dry-run]${NC} homeboy project components remove $project_id ${remove_ids[*]}" - return 0 - fi - - local remove_output remove_status - set +e - remove_output="$(homeboy_run project components remove "$project_id" "${remove_ids[@]}" 2>&1)" - remove_status=$? - set -e - - if homeboy_attach_succeeded "$remove_output" "$remove_status" || homeboy_components_absent "$project_id" "${remove_ids[@]}"; then - log " pruned stale Homeboy component(s): ${remove_ids[*]}" - else - warn " failed to prune stale Homeboy component(s): ${remove_ids[*]}" - if [ -n "$remove_output" ]; then - warn " $(printf '%s' "$remove_output" | head -n 3 | tr '\n' ' ')" - fi - fi -} - -homeboy_components_absent() { - local project_id="$1" - shift - [ -n "$project_id" ] || return 1 - [ "$#" -gt 0 ] || return 0 - - local project_json - project_json="$(homeboy_run project show "$project_id" 2>/dev/null || true)" - [ -n "$project_json" ] || return 1 - - local remaining - remaining="$(HOMEBOY_PROJECT_JSON="$project_json" python3 - "$@" <<'PY' -import json -import os -import sys - -try: - payload = json.loads(os.environ.get("HOMEBOY_PROJECT_JSON", "")) -except Exception: - raise SystemExit(1) - -expected_absent = set(sys.argv[1:]) -components = payload.get("data", {}).get("entity", {}).get("components", []) -present = { - component.get("id") - for component in components - if component.get("id") -} -for component_id in sorted(expected_absent & present): - print(component_id) -PY -)" - - [ -z "$remaining" ] -} - -# Decide whether a `homeboy ... attach-path` invocation succeeded. Prefers the -# JSON `.success` field in the command output; falls back to the process exit -# status when the output is not JSON (e.g. an early crash before any payload). -homeboy_attach_succeeded() { - local output="$1" - local status="$2" - - local verdict - # Pass the command output via env var (not stdin) so it does not collide - # with the heredoc that supplies the python source on stdin. - verdict="$(HOMEBOY_ATTACH_OUTPUT="$output" python3 <<'PY' 2>/dev/null -import json -import os - -raw = os.environ.get("HOMEBOY_ATTACH_OUTPUT", "").strip() -if not raw: - print("nojson") - raise SystemExit(0) - -# Output may include leading log lines before the JSON object; isolate the -# outermost JSON object if present. -start = raw.find("{") -end = raw.rfind("}") -if start == -1 or end == -1 or end < start: - print("nojson") - raise SystemExit(0) - -try: - payload = json.loads(raw[start : end + 1]) -except Exception: - print("nojson") - raise SystemExit(0) - -print("ok" if payload.get("success") is True else "fail") -PY -)" - - case "$verdict" in - ok) return 0 ;; - fail) return 1 ;; - *) [ "$status" -eq 0 ] ;; # nojson / unknown -> trust exit code - esac -} diff --git a/lib/desired-state-reconciler.sh b/lib/desired-state-reconciler.sh index 7f7cd6b..29c5d86 100644 --- a/lib/desired-state-reconciler.sh +++ b/lib/desired-state-reconciler.sh @@ -63,7 +63,7 @@ installation_profile_normalize() { fi INSTALLATION_PROFILE_HOMEBOY_MODE="${HOMEBOY_MODE:-auto}" INSTALLATION_PROFILE_WORKSPACE_REPOSITORIES="${WORKSPACE_REPOSITORIES:-}" - INSTALLATION_PROFILE_PLUGIN_CANDIDATES=(data-machine data-machine-code wp-codebox) + INSTALLATION_PROFILE_PLUGIN_CANDIDATES=(data-machine wp-codebox) INSTALLATION_PROFILE_CARRIED_PLUGINS=() if [ "$INSTALLATION_PROFILE_EXTERNAL_WORDPRESS" != true ]; then INSTALLATION_PROFILE_CARRIED_PLUGINS+=(wp-coding-agents-integration) diff --git a/lib/detect.sh b/lib/detect.sh index 654bee2..709e223 100644 --- a/lib/detect.sh +++ b/lib/detect.sh @@ -228,8 +228,9 @@ detect_plugins_only_environment() { log "Plugin-only scope: installed Data Machine plugins only; runtime, bridge, workspace, and service synchronization disabled" } -# Derive SERVICE_USER / SERVICE_HOME / KIMAKI_DATA_DIR / DM_WORKSPACE_DIR from -# LOCAL_MODE and RUN_AS_ROOT. +# Derive SERVICE_USER / SERVICE_HOME / KIMAKI_DATA_DIR from LOCAL_MODE and +# RUN_AS_ROOT. DM_WORKSPACE_DIR is a compatibility value derived from the +# explicit source policy after it resolves. # # Split out of detect_environment so it can be re-derived. setup.sh resolves the # source mode AFTER detection — the mode is read from the site, which detection @@ -246,17 +247,14 @@ detect_service_identity() { SERVICE_USER="$(whoami)" SERVICE_HOME="$HOME" _detect_default_kimaki_data_dir "$HOME/.kimaki" - DM_WORKSPACE_DIR="${DATAMACHINE_WORKSPACE_PATH:-$HOME/.datamachine/workspace}" elif [ "$RUN_AS_ROOT" = true ]; then SERVICE_USER="root" SERVICE_HOME="/root" _detect_default_kimaki_data_dir "/root/.kimaki" - DM_WORKSPACE_DIR="${DATAMACHINE_WORKSPACE_PATH:-/var/lib/datamachine/workspace}" else SERVICE_USER="opencode" SERVICE_HOME="/home/opencode" _detect_default_kimaki_data_dir "/home/opencode/.kimaki" - DM_WORKSPACE_DIR="${DATAMACHINE_WORKSPACE_PATH:-/var/lib/datamachine/workspace}" fi } diff --git a/lib/homeboy.sh b/lib/homeboy.sh index b66d6a4..51c048e 100644 --- a/lib/homeboy.sh +++ b/lib/homeboy.sh @@ -396,47 +396,60 @@ setup_homeboy_project() { fi } -homeboy_worktree_adapter_file() { - printf '%s' "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" +# Attach only repositories explicitly declared by the source policy. This keeps +# Homeboy component discovery independent of WordPress plugins and DMC state. +sync_homeboy_project_components() { + command -v homeboy >/dev/null 2>&1 || return 0 + + local project_id + project_id="$(homeboy_project_id)" || return 0 + [ -n "$project_id" ] || return 0 + + local repository name + while IFS= read -r repository; do + [ -n "$repository" ] || continue + name="$(basename "$repository")" + if [ ! -d "$repository" ]; then + warn " skipped $name: declared repository does not exist" + continue + fi + if [ "$repository" = "${SITE_PATH:-}" ]; then + warn " skipped $name: site project root" + continue + fi + if [[ "$name" == *"@"* ]]; then + warn " skipped $name: worktree skipped" + continue + fi + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} homeboy project components attach-path $project_id $repository" + continue + fi + if homeboy_run project components attach-path "$project_id" "$repository" >/dev/null; then + log " attached $name" + else + warn " failed $name: homeboy attach-path failed" + fi + done < <(source_policy_workspace_repositories) } -homeboy_worktree_adapter_sync() { - local file template binary rendered - file="$(homeboy_worktree_adapter_file)" - template="$SCRIPT_DIR/templates/wp-coding-agents-homeboy-worktrees.php" - binary="$(command -v homeboy 2>/dev/null || true)" - [ -f "$template" ] || { homeboy_handle_failure "Missing Homeboy worktree adapter template."; return 0; } - [ -n "$binary" ] || { homeboy_handle_failure "Homeboy is not callable; worktree ability ownership cannot be installed."; return 0; } - - if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE}[dry-run]${NC} Would sync Homeboy worktree ability adapter to $file" - return 0 - fi - - mkdir -p "${file%/*}" - rendered="$(WP_CODING_AGENTS_HOMEBOY_BINARY="$binary" python3 - "$template" <<'PY' -import os -import pathlib -import sys - -value = os.environ['WP_CODING_AGENTS_HOMEBOY_BINARY'].replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n').replace('\r', '\\r') -print(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8').replace('@HOMEBOY_BINARY@', value), end='') -PY -)" - if [ ! -f "$file" ] || ! printf '%s\n' "$rendered" | cmp -s - "$file"; then - printf '%s\n' "$rendered" > "$file" - UPDATED_ITEMS+=("Homeboy worktree ability adapter") - fi - service_file_normalize_perms "$file" +homeboy_retired_worktree_adapter_file() { + printf '%s' "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" } -homeboy_worktree_adapter_remove() { +homeboy_retired_worktree_adapter_remove() { local file - file="$(homeboy_worktree_adapter_file)" + file="$(homeboy_retired_worktree_adapter_file)" [ -e "$file" ] || return 0 + if ! grep -q "Route DMC's canonical worktree abilities through Homeboy's native lifecycle" "$file" || \ + ! grep -q 'Generated by wp-coding-agents. Local edits will be overwritten.' "$file"; then + if [ "${DRY_RUN:-false}" = true ]; then warn "Would preserve unknown Homeboy worktree adapter at $file (installer provenance is not confirmed)"; else warn "Preserving unknown Homeboy worktree adapter at $file (installer provenance is not confirmed)"; fi + return 1 + fi + if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE}[dry-run]${NC} Would remove stale Homeboy worktree ability adapter at $file" + echo -e "${BLUE}[dry-run]${NC} Would remove installer-owned retired Homeboy worktree adapter at $file" return 0 fi @@ -448,24 +461,17 @@ homeboy_worktree_adapter_remove() { } configure_homeboy_worktree_ownership() { - if [ "${HOMEBOY_MODE:-auto}" = "disabled" ]; then - log "Skipping Homeboy worktree ownership setup (--no-homeboy)" - return 0 - fi + local status=0 + # This retired adapter projected DMC ability callbacks into Homeboy. Homeboy + # now owns its native lifecycle directly, so only bounded stale-file cleanup remains. + homeboy_retired_worktree_adapter_remove || return $? if ! command -v homeboy >/dev/null 2>&1; then + [ "${HOMEBOY_MODE:-auto}" = "disabled" ] && return 0 homeboy_handle_failure "Homeboy is not callable from this setup/runtime PATH; worktree ownership cannot be reconciled." return 0 fi - # The adapter is solely an integration for an installed DMC ability surface. - # Homeboy's generic lifecycle ownership does not require a WordPress callback. - if [ -d "$SITE_PATH/wp-content/plugins/data-machine-code" ]; then - homeboy_worktree_adapter_sync - else - homeboy_worktree_adapter_remove - fi - if [ "${DRY_RUN:-false}" = true ]; then echo -e "${BLUE}[dry-run]${NC} homeboy config remove /worktree_providers/dmc" echo -e "${BLUE}[dry-run]${NC} homeboy config remove /settings/worktree_provider_lifecycle/dmc" @@ -478,6 +484,7 @@ configure_homeboy_worktree_ownership() { [ -z "${UPDATED_ITEMS+x}" ] || UPDATED_ITEMS+=("removed retired worktree provider") else homeboy_handle_failure "Could not remove the retired worktree provider." + status=1 fi fi if homeboy_run config show /settings/worktree_provider_lifecycle/dmc >/dev/null 2>&1; then @@ -485,11 +492,15 @@ configure_homeboy_worktree_ownership() { [ -z "${UPDATED_ITEMS+x}" ] || UPDATED_ITEMS+=("removed retired worktree finalizer") else homeboy_handle_failure "Could not remove the retired worktree finalizer." + status=1 fi fi - if homeboy_run config show /worktree_providers/dmc >/dev/null 2>&1; then - homeboy_handle_failure "Circular /worktree_providers/dmc configuration remains authoritative." + if homeboy_run config show /worktree_providers/dmc >/dev/null 2>&1; then homeboy_handle_failure "Circular /worktree_providers/dmc configuration remains authoritative."; status=1; fi + if homeboy_run config show /settings/worktree_provider_lifecycle/dmc >/dev/null 2>&1; then homeboy_handle_failure "Retired /settings/worktree_provider_lifecycle/dmc configuration remains authoritative."; status=1; fi + if [ "${HOMEBOY_MODE:-auto}" = "disabled" ]; then + log "Removed retired Homeboy worktree state (--no-homeboy)" fi + return "$status" } configure_homeboy_wordpress_extension() { @@ -591,7 +602,7 @@ print_homeboy_verification_commands() { echo " homeboy --version" echo " homeboy extension list" echo " homeboy extension show wordpress" - echo " homeboy config show /worktree_providers/dmc # expected: not found" + echo " homeboy config show --format=json | jq -e '.data.config.worktree_providers.dmc == null and .data.config.settings.worktree_provider_lifecycle.dmc == null'" echo " homeboy project show " echo " homeboy project components list " echo " $(wp_cli_transport_display) datamachine memory compose AGENTS.md$verification_wp_flags" diff --git a/lib/integration-adapters.sh b/lib/integration-adapters.sh index b42ff12..423abbf 100644 --- a/lib/integration-adapters.sh +++ b/lib/integration-adapters.sh @@ -81,8 +81,12 @@ integration_adapters_verify() { _integration_adapter_cleanup_managed_release() { local file="$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" + if ! grep -q 'datamachine_code_managed_release_channel' "$file"; then + if [ "${DRY_RUN:-false}" = true ]; then warn "Would preserve unknown managed-release MU-plugin at $file (installer provenance is not confirmed)"; else warn "Preserving unknown managed-release MU-plugin at $file (installer provenance is not confirmed)"; fi + return 1 + fi if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE:-}[dry-run]${NC:-} rm -f $file" + echo -e "${BLUE:-}[dry-run]${NC:-} Would remove installer-owned retired managed-release MU-plugin at $file" return 0 fi rm -f "$file" @@ -90,7 +94,8 @@ _integration_adapter_cleanup_managed_release() { } _integration_adapter_verify_managed_release() { - [ ! -e "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" ] + local file="$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" + [ ! -e "$file" ] || ! grep -q 'datamachine_code_managed_release_channel' "$file" } _integration_adapter_cleanup_retired_homeboy_option() { @@ -169,7 +174,7 @@ _integration_adapter_verify_homeboy() { return 0 } config="$(homeboy_run config show)" || return 1 - python3 -c 'import json,sys; result=json.load(sys.stdin); data=result.get("data", {}).get("config", result); providers=data.get("worktree_providers") or {}; raise SystemExit(not isinstance(providers, dict) or "dmc" in providers)' <<< "$config" || return 1 + python3 -c 'import json,sys; result=json.load(sys.stdin); data=result.get("data", {}).get("config", result); providers=data.get("worktree_providers") or {}; lifecycle=(data.get("settings") or {}).get("worktree_provider_lifecycle") or {}; raise SystemExit(not isinstance(providers, dict) or not isinstance(lifecycle, dict) or "dmc" in providers or "dmc" in lifecycle)' <<< "$config" || return 1 homeboy_required && ! homeboy_wordpress_extension_ready && return 1 homeboy_wordpress_extension_ready || ! homeboy_required || return 1 return 0 diff --git a/lib/owned-source-discovery.sh b/lib/owned-source-discovery.sh index c9b0ebc..db9ad55 100644 --- a/lib/owned-source-discovery.sh +++ b/lib/owned-source-discovery.sh @@ -80,7 +80,6 @@ OWNED_DISCOVERY_MAX_SIGNAL_AGE="${OWNED_DISCOVERY_MAX_SIGNAL_AGE:-172800}" owned_discovery_carried_plugins() { cat <<'EOF' data-machine -data-machine-code wp-codebox wp-coding-agents-integration ai-provider-for-claude-code diff --git a/lib/plugin-upgrade.sh b/lib/plugin-upgrade.sh index 063b7ef..3a01f36 100644 --- a/lib/plugin-upgrade.sh +++ b/lib/plugin-upgrade.sh @@ -135,7 +135,6 @@ plugin_update_local_version() { local slug="$1" file case "$slug" in data-machine) file="$SITE_PATH/wp-content/plugins/$slug/data-machine.php" ;; - data-machine-code) file="$SITE_PATH/wp-content/plugins/$slug/data-machine-code.php" ;; wp-codebox) file="$SITE_PATH/wp-content/plugins/$slug/wp-codebox.php" ;; *) return 1 ;; esac diff --git a/lib/repair-opencode-json.py b/lib/repair-opencode-json.py index 3fbf28f..35baee7 100755 --- a/lib/repair-opencode-json.py +++ b/lib/repair-opencode-json.py @@ -625,7 +625,7 @@ def main() -> int: parser.add_argument( "--workspace-dir", default="", - help="DMC workspace root to grant via external_directory (engineering only).", + help="Declared workspace checkout root to grant via external_directory (workspace mode only).", ) parser.add_argument( "--log-path", diff --git a/lib/runtime-signature.sh b/lib/runtime-signature.sh index f9d1956..7c57191 100644 --- a/lib/runtime-signature.sh +++ b/lib/runtime-signature.sh @@ -16,8 +16,13 @@ runtime_signature_cleanup_retired_mu_plugin() { file="$(runtime_signature_mu_plugin_path)" || return 0 [ -e "$file" ] || return 0 + if ! grep -q 'datamachine_code_worktree_runtime_signatures' "$file"; then + if [ "${DRY_RUN:-false}" = true ]; then warn "Would preserve unknown runtime registry at $file (missing wp-coding-agents DMC marker)"; else warn "Preserving unknown runtime registry at $file (missing wp-coding-agents DMC marker)"; fi + return 1 + fi + if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE:-}[dry-run]${NC:-} rm -f $file" + echo -e "${BLUE:-}[dry-run]${NC:-} Would remove installer-owned retired runtime registry at $file" return 0 fi diff --git a/lib/source-policy.sh b/lib/source-policy.sh index eff6bc7..2b42468 100644 --- a/lib/source-policy.sh +++ b/lib/source-policy.sh @@ -726,16 +726,64 @@ source_policy_workspace_enabled() { esac } +# A repository declaration is meaningful only in workspace mode. Rejecting the +# contradiction before any install state is persisted keeps an owned profile +# from carrying dormant mutable repository authority into a later upgrade. +source_policy_validate_workspace_repositories() { + if source_policy_is_owned && [ -n "${WORKSPACE_REPOSITORIES:-}" ]; then + error "--workspace-repository cannot be used with --source-mode owned; owned mode has no repository workspace authority." + return 1 + fi +} + # Explicit primary checkout roots for workspace mode, colon-separated so paths -# remain literal when they contain spaces. These are the repository authority; -# when unset, older installs retain their existing runtime workspace. +# remain literal when they contain spaces. These are the repository authority. source_policy_workspace_repositories() { source_policy_workspace_enabled || return 0 printf '%s\n' "${WORKSPACE_REPOSITORIES:-}" | tr ':' '\n' | while IFS= read -r path; do - [ -n "$path" ] && printf '%s\n' "${path%/}" + path="${path%/}" + case "$path" in + /*) ;; + *) continue ;; + esac + git -C "$path" rev-parse --is-inside-work-tree >/dev/null 2>&1 || continue + printf '%s\n' "$path" done } +source_policy_add_workspace_repository() { + local repository="${1%/}" root canonical existing + case "$repository" in + /*) ;; + *) error "--workspace-repository must be an absolute Git checkout: $1"; return 1 ;; + esac + if [ ! -d "$repository" ]; then + error "--workspace-repository must be an existing directory: $1" + return 1 + fi + if [ "$(git -C "$repository" rev-parse --is-inside-work-tree 2>/dev/null || true)" != true ]; then + error "--workspace-repository must be a Git checkout: $1" + return 1 + fi + root="$(git -C "$repository" rev-parse --show-toplevel 2>/dev/null)" || { + error "--workspace-repository must resolve to a Git checkout root: $1" + return 1 + } + canonical="$(cd "$root" && pwd -P)" || return 1 + while IFS= read -r existing; do + [ "$existing" = "$canonical" ] && return 0 + done < <(printf '%s\n' "${WORKSPACE_REPOSITORIES:-}" | tr ':' '\n') + WORKSPACE_REPOSITORIES="${WORKSPACE_REPOSITORIES:+$WORKSPACE_REPOSITORIES:}$canonical" +} + +# Compatibility for runtime helpers that still accept one workspace directory. +# Repository authority remains the explicit profile list above. +source_policy_resolve_workspace_dir() { + DM_WORKSPACE_DIR="" + source_policy_workspace_enabled || return 0 + DM_WORKSPACE_DIR="$(source_policy_workspace_repositories | awk 'NR == 1 { print; exit }')" +} + # The ordered edit ruleset for the active source mode, as tab-separated # `\t` lines. # diff --git a/lib/summary.sh b/lib/summary.sh index c977124..a77c4d5 100644 --- a/lib/summary.sh +++ b/lib/summary.sh @@ -46,8 +46,8 @@ print_summary() { echo " Discover: $(wp_cli_transport_display) datamachine memory paths${AGENT_SLUG:+ --agent=$AGENT_SLUG} $WP_ROOT_FLAG" echo " Source mode: ${SOURCE_MODE:-workspace}" if source_policy_workspace_enabled; then - echo " Code tools: data-machine-code (workspace, GitHub, git)" - echo " Workspace: $DM_WORKSPACE_DIR (created on first use)" + echo " Code tools: declared repositories, GitHub, git" + echo " Repositories: ${WORKSPACE_REPOSITORIES:-not declared}" else echo " Code tools: none — the agent edits live theme and plugin source in place" echo " Capture: changes are harvested into version control out-of-band" diff --git a/lib/wordpress.sh b/lib/wordpress.sh index daf8f4b..4246ae0 100644 --- a/lib/wordpress.sh +++ b/lib/wordpress.sh @@ -17,7 +17,7 @@ wp_cmd() { # # Almost nothing needs this. AGENTS.md composition does, because the generated # text encodes the euid of the process that generated it: both -# data-machine's datamachine_agents_md_wp_cli_cmd() and data-machine-code's +# data-machine's datamachine_agents_md_wp_cli_cmd() and generated guidance # resolve_wp_cli_cmd() append `--allow-root` when posix_geteuid() === 0. Since # upgrade.sh runs under sudo, composing there writes an AGENTS.md that tells the # agent to run `wp --allow-root` — describing an identity a non-root agent does diff --git a/operator-entrypoints/wp-coding-agents-setup/interview.md b/operator-entrypoints/wp-coding-agents-setup/interview.md index f17665f..eef19b6 100644 --- a/operator-entrypoints/wp-coding-agents-setup/interview.md +++ b/operator-entrypoints/wp-coding-agents-setup/interview.md @@ -50,7 +50,18 @@ Collect the facts needed to install wp-coding-agents. Do not build commands, run - `external-openai-compatible-endpoint` — optional WP AI Gateway path for external OpenCode/Kimaki clients. - `not-applicable` -6. **Optional overlays** +6. **Source mode and repository authority** + Ask where code changes land: + - `workspace` — collect one or more absolute paths to existing primary Git + checkouts accessible to the runtime. Preserve the operator's order; each + checkout is independent authority. + - `owned` — collect no repository paths. The runtime edits only the site's + declared owned source paths. + + Homeboy may attach the same declared primary checkouts when enabled. It does + not create or infer repository authority. + +7. **Optional overlays** Collect booleans or values for: - Homeboy developer layer. - WordPress Studio WP-CLI wrapper. @@ -60,10 +71,10 @@ Collect the facts needed to install wp-coding-agents. Do not build commands, run - Dependency install skip. - Skills install skip. -7. **Agent identity** +8. **Agent identity** Ask for the agent slug and display name only when the user wants to override defaults. Defaults come from the site domain or blog name. -8. **Systems capabilities** +9. **Systems capabilities** For a dedicated VPS whose agent is expected to perform bounded host maintenance, ask whether to provision the `managed-vps` systems capability profile. Record `none` for local, external-runtime, and operator-managed hosts. ## Output Shape @@ -94,6 +105,10 @@ Return the profile as JSON in this shape so the compiler script can map it deter "telegram_allowed_user_id_available": false }, "codex_path": "not-applicable | codebox-minions | external-openai-compatible-endpoint", + "source": { + "mode": "workspace | owned", + "workspace_repositories": ["/absolute/path/to/primary-checkout"] + }, "overlays": { "homeboy": false, "wordpress_studio": false, @@ -121,5 +136,7 @@ Use empty strings or `false` for unknown optional values. Do not invent defaults - The profile identifies the install target. - The profile separates runtime and bridge selections. +- Workspace profiles name one or more absolute primary Git checkout paths; + owned profiles name none. - Optional overlays are independent booleans or explicit values. - No setup command has been built or run. diff --git a/operator-entrypoints/wp-coding-agents-setup/setup.md b/operator-entrypoints/wp-coding-agents-setup/setup.md index 1166af5..3731a55 100644 --- a/operator-entrypoints/wp-coding-agents-setup/setup.md +++ b/operator-entrypoints/wp-coding-agents-setup/setup.md @@ -50,7 +50,7 @@ Do not duplicate script internals in this guide. Compile commands from the setup If the compiler rejects the profile, fix the profile or the setup script. Do not hand-compile around the failure. 4. **Summarize the compiled plan before execution.** - Include target, WordPress path or domain, runtime axis, bridge axis, optional overlays, and the exact dry-run command. Ask for explicit confirmation before running setup for real. + Include target, WordPress path or domain, runtime axis, bridge axis, source mode and declared repository roots, optional overlays, and the exact dry-run command. Ask for explicit confirmation before running setup for real. 5. **Dry-run first.** Always run the compiled command with `--dry-run` before making changes. Read the output and stop if target paths, runtime, bridge, Homeboy behavior, or WP-CLI command selection look wrong. @@ -69,6 +69,8 @@ Do not duplicate script internals in this guide. Compile commands from the setup - Keep Homeboy external to wp-coding-agents. The WordPress site root is a Homeboy project, not a component. - Use `WP_CLI_TRANSPORT_JSON='["studio","wp"]'` when the compiler explicitly selects the WordPress Studio transport. - Use `--with-homeboy` only when the operator wants the optional developer layer. +- Workspace mode requires explicitly declared absolute Git checkout roots. Homeboy + can attach those roots but never discovers or creates repository authority. - Use `--no-chat` when the operator wants terminal/SSH-only operation. - Use `--no-skills` only when the operator explicitly wants to skip installing the upgrade skill on the target runtime. diff --git a/operator-entrypoints/wp-coding-agents-setup/verify.md b/operator-entrypoints/wp-coding-agents-setup/verify.md index 26f1196..0a199a4 100644 --- a/operator-entrypoints/wp-coding-agents-setup/verify.md +++ b/operator-entrypoints/wp-coding-agents-setup/verify.md @@ -158,7 +158,7 @@ This overlay proves Homeboy is installed and linked, its native worktree lifecyc homeboy --version homeboy extension list homeboy extension show wordpress -homeboy config show /worktree_providers/dmc # expected: not found +homeboy config show --format=json | jq -e '.data.config.worktree_providers.dmc == null and .data.config.settings.worktree_provider_lifecycle.dmc == null' homeboy project show homeboy project components list wp datamachine memory compose AGENTS.md --path=/path/to/site @@ -170,11 +170,11 @@ For WordPress Studio: studio wp datamachine memory compose AGENTS.md ``` -`homeboy config show /worktree_providers/dmc` should report that the path is absent. +The JSON assertion verifies both retired provider and lifecycle settings are absent. Attribute failures before retrying: -- A present `/worktree_providers/dmc` entry is stale configuration. Rerun wp-coding-agents reconciliation to remove it. +- A present retired provider or lifecycle entry is stale configuration. Rerun wp-coding-agents reconciliation to remove it. - Extension readiness and project/component lookup failures are owned by Homeboy. Use the failed command's diagnostics and repair that Homeboy configuration. Expected model: diff --git a/runtimes/claude-code.sh b/runtimes/claude-code.sh index 4f653d3..e180500 100644 --- a/runtimes/claude-code.sh +++ b/runtimes/claude-code.sh @@ -184,9 +184,8 @@ runtime_install_hooks() { # Merge SessionStart hook, workspace permissions, and disable auto-memory in settings.json. # additionalDirectories alone is not enough: the Bash tool is gated by explicit - # allow rules, so workspace shell ops (ls/git/studio wp datamachine-code …) would - # still prompt. Expand permissions.allow with Read/Edit/Write globs on the - # workspace plus the datamachine-code Bash surface. + # allow rules, so workspace shell operations would still prompt. Expand + # permissions.allow with Read/Edit/Write globs on the workspace. local hook_cmd="\"\$CLAUDE_PROJECT_DIR\"/.claude/hooks/dm-agent-sync.sh" local hook_entry hook_entry=$(jq -n --arg cmd "$hook_cmd" '{matcher: "", hooks: [{type: "command", command: $cmd}]}') @@ -199,20 +198,12 @@ runtime_install_hooks() { # Workspace access only exists in modes that have a workspace. On a # managed install these rules would grant the agent an empty directory while # advertising a git workflow it has no part in. - local workspace_allow_rules='[]' - if source_policy_workspace_enabled; then - workspace_allow_rules=$(jq -n \ - --arg ws "$DM_WORKSPACE_DIR" \ - --arg wp "$wp_prefix" \ - '[ - "Read(\($ws)/**)", - "Edit(\($ws)/**)", - "Write(\($ws)/**)", - "Bash(\($wp) datamachine-code workspace:*)", - "Bash(\($wp) datamachine-code github:*)", - "Bash(\($wp) datamachine-code gitsync:*)" - ]') - fi + local workspace_allow_rules='[]' workspace_directories='[]' workspace_repository + while IFS= read -r workspace_repository; do + [ -n "$workspace_repository" ] || continue + workspace_directories=$(jq -n --argjson directories "$workspace_directories" --arg repository "$workspace_repository" '$directories + [$repository]') + workspace_allow_rules=$(jq -n --argjson rules "$workspace_allow_rules" --arg repository "$workspace_repository" '$rules + ["Read(\($repository)/**)", "Edit(\($repository)/**)", "Write(\($repository)/**)"]') + done < <(source_policy_workspace_repositories) # Every installed root is denied. Claude Code treats deny as absolute — an # allow never overrides it — so this runtime cannot express "deny the @@ -243,19 +234,18 @@ runtime_install_hooks() { fi settings=$(echo "$settings" | jq \ - --arg workspace "$DM_WORKSPACE_DIR" \ + --argjson workspaces "$workspace_directories" \ --argjson hook "$hook_entry" \ --arg cmd "$hook_cmd" \ --argjson allow_rules "$workspace_allow_rules" \ --argjson deny_rules "$wordpress_deny_rules" \ --argjson legacy_deny_rules "$legacy_wordpress_deny_rules" \ - --argjson workspace_enabled "$(source_policy_workspace_enabled && echo true || echo false)" \ ' .autoMemoryEnabled = false | .permissions.additionalDirectories = ( (.permissions.additionalDirectories // []) - | if $workspace_enabled and (any(. == $workspace) | not) then . + [$workspace] else . end + | . + $workspaces | unique ) | .permissions.allow = ( diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index 30a5574..a1b85ed 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -232,7 +232,6 @@ PY )" _ext_rules="${_ext_rules}${_ext_rules:+,}\n ${workspace_pattern}: \"allow\"" done < <(source_policy_workspace_repositories) - [ -n "$_ext_rules" ] || _ext_rules="\n \"${DM_WORKSPACE_DIR}/**\": \"allow\"" fi local _log_path while IFS= read -r _log_path; do @@ -317,8 +316,10 @@ _runtime_repair_opencode_json_additive() { [ -n "$_owned_path" ] || continue _managed_source_args+=(--log-path "$_owned_path") done < <(source_policy_log_paths) - if source_policy_workspace_enabled; then - _managed_source_args+=(--workspace-dir "$DM_WORKSPACE_DIR") + local workspace_repository + workspace_repository="$(source_policy_workspace_repositories | awk 'NR == 1 { print; exit }')" + if [ -n "$workspace_repository" ]; then + _managed_source_args+=(--workspace-dir "$workspace_repository") fi if [ ! -f "$HELPER" ]; then log "opencode.json exists but repair helper not found ($HELPER) — leaving as-is" diff --git a/scripts/compile-setup-profile.mjs b/scripts/compile-setup-profile.mjs index d9b772c..624f496 100755 --- a/scripts/compile-setup-profile.mjs +++ b/scripts/compile-setup-profile.mjs @@ -101,6 +101,37 @@ function normalizeBridge(profile, availableBridges) { return selection } +function normalizeSource(profile) { + const source = profile.source + if (source === undefined) { + return { mode: "workspace", repositories: [], legacy: true } + } + if (!source || typeof source !== "object") { + throw new Error("source must be an object") + } + + const mode = source.mode + if (!["workspace", "owned"].includes(mode)) { + throw new Error("source.mode must be workspace or owned") + } + + const repositories = source.workspace_repositories + if (!Array.isArray(repositories) || repositories.some((repository) => typeof repository !== "string" || !repository || !path.isAbsolute(repository))) { + throw new Error("source.workspace_repositories must be an array of absolute paths") + } + if (new Set(repositories).size !== repositories.length) { + throw new Error("source.workspace_repositories must not contain duplicate paths") + } + if (mode === "workspace" && !repositories.length) { + throw new Error("workspace source mode requires one or more source.workspace_repositories") + } + if (mode === "owned" && repositories.length) { + throw new Error("owned source mode must not declare source.workspace_repositories") + } + + return { mode, repositories, legacy: false } +} + function compile(profile) { const availableRuntimes = discoveredNames("runtimes") const availableBridges = discoveredNames("bridges", ".sh", new Set(["_dispatch"])) @@ -114,6 +145,10 @@ function compile(profile) { const overlays = profile.overlays ?? {} const systemsCapabilities = profile.systems_capabilities ?? {} const agent = profile.agent ?? {} + const source = normalizeSource(profile) + if (source.legacy) { + warnings.push("Legacy profile has no source declaration: no new mutable repository authority is declared until source.workspace_repositories is added.") + } if (!installTarget) { throw new Error("Missing required profile field: install_target") @@ -169,6 +204,13 @@ function compile(profile) { addFlag(command, "--runtime", runtime.flag) } + if (!source.legacy) { + addFlag(command, "--source-mode", source.mode) + for (const repository of source.repositories) { + addFlag(command, "--workspace-repository", repository) + } + } + const bridge = normalizeBridge(profile, availableBridges) if (bridge === "none") { addFlag(command, "--no-chat") @@ -242,6 +284,8 @@ function compile(profile) { .filter(([, value]) => value === true) .map(([key]) => key), systems_capabilities: systemsCapabilities.profile || "none", + source_mode: source.mode, + workspace_repositories: source.repositories, }, commands: { dry_run: formatCommand(env, command, true), diff --git a/scripts/homeboy-dmc-resolve.sh b/scripts/homeboy-dmc-resolve.sh deleted file mode 100644 index 1d94e7f..0000000 --- a/scripts/homeboy-dmc-resolve.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# Convert only DMC's typed missing-worktree result into Homeboy's configured -# absence status; preserve every other provider failure unchanged. -set -u - -NOT_FOUND_EXIT=42 -stdout_file="$(mktemp)" -stderr_file="$(mktemp)" -trap 'rm -f "$stdout_file" "$stderr_file"' EXIT - -"$@" >"$stdout_file" 2>"$stderr_file" -status=$? - -if [ "$status" -eq 0 ]; then - cat "$stdout_file" - cat "$stderr_file" >&2 - exit 0 -fi - -cat "$stdout_file" -cat "$stderr_file" >&2 - -if python3 - "$stdout_file" <<'PY' -import json -import sys - -try: - payload = json.load(open(sys.argv[1], encoding="utf-8")) -except (OSError, json.JSONDecodeError): - raise SystemExit(1) - -raise SystemExit(0 if payload.get("error", {}).get("code") == "worktree_not_found" else 1) -PY -then - exit "$NOT_FOUND_EXIT" -fi - -exit "$status" diff --git a/scripts/validate-dmc-migration-inventory.mjs b/scripts/validate-dmc-migration-inventory.mjs index f10c492..2815fb1 100644 --- a/scripts/validate-dmc-migration-inventory.mjs +++ b/scripts/validate-dmc-migration-inventory.mjs @@ -65,8 +65,9 @@ for (const path of classifications.keys()) { const extensions = new Set(['.js', '.json', '.md', '.mjs', '.php', '.py', '.sh', '.ts', '.yaml', '.yml']); const ignoredDirectories = new Set(['.git', 'node_modules']); -const referencePattern = /DataMachineCode\\|datamachine_code_|datamachine-code\//; -const exactContractPattern = /datamachine_code_[a-z0-9_]+|datamachine-code\/[a-z0-9_/-]+|DataMachineCode\\[A-Za-z0-9_\\]+/g; +const referencePattern = /\bdata-machine-code\b|\bwp\s+datamachine-code\b|DataMachineCode\\|datamachine_code_|datamachine-code\//; +const exactContractPattern = /\bdata-machine-code\b|\bwp\s+datamachine-code(?:\s+[a-z0-9_/-]+)?|datamachine_code_[a-z0-9_]+|datamachine-code\/[a-z0-9_/-]+|DataMachineCode\\[A-Za-z0-9_\\]+/g; +const lexicalExclusions = new Set(inventory.lexical_reference_exclusions ?? []); const allContracts = inventory.rows.flatMap((row) => row.contracts); function contractCovered(contract) { @@ -99,6 +100,7 @@ function walk(directory) { ) continue; const content = readFileSync(path, 'utf8'); if (!referencePattern.test(content)) continue; + if (lexicalExclusions.has(repositoryPath)) continue; if (!classifications.has(repositoryPath)) { failures.push(`DMC reference file is not classified: ${repositoryPath}`); } @@ -120,6 +122,12 @@ for (const path of classifications.keys()) { } } +for (const path of lexicalExclusions) { + if (!referencePattern.test(readFileSync(join(root, path), 'utf8'))) { + failures.push(`lexical DMC exclusion no longer needs an explicit entry: ${path}`); + } +} + if (failures.length > 0) { for (const failure of failures) console.error(`FAIL: ${failure}`); process.exit(1); diff --git a/scripts/verify-homeboy-codebox-canary.sh b/scripts/verify-homeboy-codebox-canary.sh index 78e2b56..da04749 100755 --- a/scripts/verify-homeboy-codebox-canary.sh +++ b/scripts/verify-homeboy-codebox-canary.sh @@ -51,9 +51,9 @@ Options: --model Optional model override. --max-turns Low turn cap for the read-only canary. Default: 4. --secret-env Provider secret env var name to hydrate. Repeatable. - --agents-api Bundled Agents API path from Data Machine. - --agent-runtime Data Machine plugin path. - --agent-runtime-tools Data Machine Code plugin path. + --agents-api Bundled Agents API path. + --agent-runtime Agent runtime component path. + --agent-runtime-tools Runtime tools component path. --provider-plugin-path Provider plugin path. Repeatable. --homeboy-extensions Homeboy WordPress extension path. --channel Optional Discord channel id for provider routing. diff --git a/setup.sh b/setup.sh index 2180634..dcf9143 100755 --- a/setup.sh +++ b/setup.sh @@ -14,7 +14,8 @@ # Data Machine is the substrate wp-coding-agents composes on top of — memory # files (SOUL/MEMORY/USER/RULES/SITE), auto-composed AGENTS.md, # wp-coding-agents upgrade skill, -# workspace primitive, MCP surface. It is not optional. Uninstall the plugin +# MCP surface. Workspace policy and repository authority belong to +# wp-coding-agents. It is not optional. Uninstall the plugin # later if you don't want it. # @@ -265,6 +266,10 @@ while [[ $# -gt 0 ]]; do SOURCE_MODE_EXPLICIT=true shift 2 ;; + --workspace-repository) + source_policy_add_workspace_repository "$2" + shift 2 + ;; --not-owned) owned_discovery_add_exclusion "$2" shift 2 @@ -355,20 +360,23 @@ OPTIONS: WordPress install (Studio, MAMP, manual, etc.) --runtime Coding agent runtime (auto-detected if omitted) Available: ${AVAILABLE_RUNTIMES[*]} - --source-mode + --source-mode Where the agent's code changes land. These are two shapes, not two levels — neither is "more access" than the other. - workspace (default): installed source is read-only - reference and every change goes through a Data Machine - Code workspace, git, and GitHub. Recorded by review. + workspace (default): installed source is read-only + reference and every change goes through declared + repositories, git, and GitHub. Recorded by review. owned: the agent edits the site's own declared components in place; no workspace, no git, no GitHub, and - data-machine-code is not installed. Recorded by the + repository workspace is not used. Recorded by the operator's out-of-band capture. For managed agentic hosting. Recorded on the install so upgrades converge without repeating the flag. (--posture is accepted as a - deprecated alias; engineering=workspace, managed=owned.) + deprecated alias; engineering=workspace, managed=owned.) + --workspace-repository + Declares a repository authority for workspace mode. + Repeatable; no repository path is inferred. --not-owned Plugin or theme slug that is NOT the site's despite classifying as owned — a premium or vendor plugin, typically. Repeatable. Recorded on the install. @@ -581,9 +589,11 @@ external_wordpress_validate # The source mode must resolve BEFORE anything that enforces it: the plugin set, the # runtime permission surfaces, and the AGENTS.md guidance all derive from it. source_policy_resolve_mode +source_policy_validate_workspace_repositories source_policy_resolve_owned_sources source_policy_resolve_writable_paths source_policy_resolve_log_paths +source_policy_resolve_workspace_dir source_policy_assert_runtime_supports_mode # Owned mode defaults to a non-root service user (#327). Must run after the mode @@ -636,7 +646,6 @@ if [ "$RUNTIME_ONLY" != true ] && [ "$EXTERNAL_WORDPRESS" != true ]; then setup_ai_gateway fi -discover_dm_workspace_dir [ "$RUNTIME_ONLY" != true ] && systems_capabilities_apply CONVERGENCE_ENTRYPOINT="$SCRIPT_DIR/setup.sh" CONVERGENCE_REPLAY_ARGUMENTS="--wp-path $(printf '%q' "${SITE_PATH:-${EXISTING_WP:-}}")" diff --git a/skills/upgrade-wp-coding-agents/SKILL.md b/skills/upgrade-wp-coding-agents/SKILL.md index 8b6c097..f62291d 100644 --- a/skills/upgrade-wp-coding-agents/SKILL.md +++ b/skills/upgrade-wp-coding-agents/SKILL.md @@ -8,9 +8,9 @@ compatibility: "Requires a wp-coding-agents repo clone and an existing setup. Wo `upgrade.sh` already auto-detects the environment, picks the chat bridge, applies the managed sync, and emits the right verify + restart commands in its summary block. This skill exists for the **policy boundary** the script can't enforce on its own. -By default it also updates the setup-installed Data Machine plugins (`data-machine`, `data-machine-code`) to their latest version tags when those plugins are git checkouts. Use `--skip-plugins` to preserve the previous no-plugin-update behavior. +By default it updates the setup-installed Data Machine plugin (`data-machine`) and WP Codebox when their managed sources are present. Use `--skip-plugins` to preserve the previous no-plugin-update behavior. -If the install was created with the optional Homeboy layer, upgrade should preserve that model: the WordPress site root is the Homeboy **project**, primary Data Machine Code workspace checkouts are attached **components**, and `repo@branch` worktrees remain skipped by default. Homeboy is external to wp-coding-agents; do not vendor it or treat the site root as a component during upgrade guidance. +If the install was created with the optional Homeboy layer, upgrade should preserve that model: the WordPress site root is the Homeboy **project**, declared repository checkouts are attached **components**, and `repo@branch` worktrees remain skipped by default. Homeboy is external to wp-coding-agents; do not vendor it or treat the site root as a component during upgrade guidance. Setup uses the one-shot guide at `operator-entrypoints/wp-coding-agents-setup/setup.md`, plus `operator-entrypoints/wp-coding-agents-setup/interview.md` and `scripts/compile-setup-profile.mjs`, to map a new install profile into commands. Upgrade intentionally does **not** duplicate that compiler: `upgrade.sh` owns detection, bridge selection, apply-time sync, and summary commands for an already-installed environment. @@ -52,9 +52,9 @@ The user says something like: ```bash homeboy --version homeboy extension list - homeboy extension show wordpress - homeboy config show /worktree_providers/dmc # expected: not found - homeboy project show + homeboy extension show wordpress + homeboy config show --format=json | jq -e '.data.config.worktree_providers.dmc == null and .data.config.settings.worktree_provider_lifecycle.dmc == null' + homeboy project show homeboy project components list wp config get DATAMACHINE_COMPOSE_AGENTS_MD --path=/path/to/site wp datamachine memory compose AGENTS.md --path=/path/to/site @@ -63,9 +63,7 @@ The user says something like: ```bash studio wp datamachine memory compose AGENTS.md ``` - Homeboy config must not contain a retired `dmc` command provider. Homeboy is the sole lifecycle owner. - - Attribute failures to the owning layer. A present `/worktree_providers/dmc` entry is stale configuration and must be removed by rerunning reconciliation. Extension readiness and project/component lookup failures belong to Homeboy. Do not create `homeboy.json` in the site root to "fix" a missing project; that confuses a Homeboy project with a component. + Homeboy is the sole lifecycle owner. Attribute extension readiness and project/component lookup failures to Homeboy. Do not create `homeboy.json` in the site root to "fix" a missing project; that confuses a Homeboy project with a component. Setup and upgrade write `define( 'DATAMACHINE_COMPOSE_AGENTS_MD', true )` to wp-config.php (idempotent grep-guard; skipped on Studio and dry-run). This is the gate that turns on core-owned AGENTS.md composition — `wp config get DATAMACHINE_COMPOSE_AGENTS_MD` should return `true` after either run. diff --git a/templates/wp-coding-agents-homeboy-worktrees.php b/templates/wp-coding-agents-homeboy-worktrees.php deleted file mode 100644 index e1ac6bc..0000000 --- a/templates/wp-coding-agents-homeboy-worktrees.php +++ /dev/null @@ -1,686 +0,0 @@ - $args @return array */ - public static function filter_ability(array $args, string $slug): array { - if (!in_array($slug, self::DELEGATED_ABILITIES, true)) { - return $args; - } - - $native_callback = $args['execute_callback'] ?? null; - $args['execute_callback'] = static function (array $input) use ($slug, $native_callback): array|WP_Error { - if ('datamachine-code/workspace-worktree-add' === $slug && !self::can_delegate_add($input)) { - if (!is_callable($native_callback)) { - return self::contract_error('DMC native worktree creation is unavailable for this request.', $input); - } - return call_user_func($native_callback, $input); - } - return self::execute($slug, $input); - }; - $args['meta'] = array_merge( - is_array($args['meta'] ?? null) ? $args['meta'] : array(), - array( - 'worktree_lifecycle_owner' => 'homeboy', - 'worktree_adapter_schema' => 'wp-coding-agents/homeboy-worktree-adapter/v1', - ) - ); - return $args; - } - - /** @param array $input */ - private static function can_delegate_add(array $input): bool { - if (($input['inject_context'] ?? true) || ($input['bootstrap'] ?? true)) { - return false; - } - foreach (array('allow_stale', 'rebase_base', 'force', 'allow_percentage_byte_floor_exception', 'remediate_capacity', 'remediate_capacity_dry_run', 'verbose') as $unsupported) { - if (!empty($input[$unsupported])) { - return false; - } - } - if (null !== self::optional_string($input, 'task_ref')) { - return false; - } - return !isset($input['reuse_policy']) || in_array($input['reuse_policy'], array('', 'isolated'), true); - } - - /** @param array $input @return array|WP_Error */ - public static function execute(string $slug, array $input): array|WP_Error { - return match ($slug) { - 'datamachine-code/workspace-worktree-add' => self::add($input), - 'datamachine-code/workspace-worktree-list' => self::listing($input), - 'datamachine-code/workspace-worktree-finalize' => self::finalize($input), - 'datamachine-code/workspace-worktree-remove' => self::remove($input), - 'datamachine-code/workspace-worktree-cleanup' => self::cleanup($input), - default => self::refusal($slug), - }; - } - - /** - * Compose DMC's workspace result with Homeboy's component registry. Neither - * upstream contract needs to know that the other exists. - * - * @param array $workspace @return array|WP_Error - */ - public static function cook_destination(array $workspace): array|WP_Error { - $repository = self::required_string($workspace, 'repository_prefix'); - $path = self::required_string($workspace, 'workspace_path'); - if (is_wp_error($repository) || is_wp_error($path)) { - return is_wp_error($repository) ? $repository : $path; - } - $root = realpath($path); - if (false === $root || !is_dir($root)) { - return self::contract_error('DMC workspace_path is not an accessible directory.', $workspace); - } - $remote = self::git_remote($root); - if (is_wp_error($remote)) { - return $remote; - } - $data = self::homeboy(array(self::HOMEBOY, 'component', 'list')); - if (is_wp_error($data)) { - return $data; - } - $components = is_array($data['entities'] ?? null) ? $data['entities'] : array(); - $candidates = array(); - foreach ($components as $component) { - if (!is_array($component) || !is_string($component['id'] ?? null) || '' === trim($component['id']) || !is_string($component['local_path'] ?? null)) { - continue; - } - $registered = realpath($component['local_path']); - if (false === $registered || !is_dir($registered)) { - continue; - } - $registered_root = self::git_root($registered); - if (is_wp_error($registered_root)) { - continue; - } - $registered_remote = self::git_remote($registered_root); - if (is_wp_error($registered_remote) || $remote !== $registered_remote) { - continue; - } - $relative = self::registered_component_relative_path($registered_root, $registered); - if (null === $relative || '' === $relative) { - continue; - } - $candidates[] = array('id' => trim($component['id']), 'relative' => $relative, 'registered_path' => $registered, 'registered_root' => $registered_root); - } - if (count($candidates) > 1) { - return self::contract_error('Homeboy has multiple registered execution components for this DMC repository.', array('repository' => $repository, 'candidates' => $candidates)); - } - - $component = null; - $cwd = $root; - if (1 === count($candidates)) { - $candidate = $candidates[0]; - $cwd = realpath($root . DIRECTORY_SEPARATOR . $candidate['relative']); - if (false === $cwd || !is_dir($cwd)) { - return self::contract_error('The registered Homeboy component is absent from the DMC workspace.', array('workspace_path' => $root, 'component' => $candidate)); - } - if (null === self::registered_component_relative_path($root, $cwd)) { - return self::contract_error('The registered Homeboy component escapes the DMC workspace.', array('workspace_path' => $root, 'component' => $candidate, 'cwd' => $cwd)); - } - $component_remote = self::git_remote($cwd); - if (is_wp_error($component_remote)) { - return $component_remote; - } - if ($remote !== $component_remote) { - return self::contract_error('DMC workspace and Homeboy execution component have different canonical remotes.', array('workspace_remote' => $remote, 'component_remote' => $component_remote, 'component' => $candidate)); - } - $component = $candidate['id']; - } - - $argv = array(self::HOMEBOY, 'agent-task', 'cook', '--repo', $repository); - if (null !== $component) { - $argv = array_merge($argv, array('--component', $component)); - } - $argv = array_merge($argv, array('--cwd', $cwd)); - return array( - 'schema' => 'wp-coding-agents/dmc-cook-destination/v1', - 'repository' => $repository, - 'component' => $component, - 'workspace_path' => $root, - 'cwd' => $cwd, - 'canonical_remote' => $remote, - 'cook' => array('argv' => $argv, 'fields' => array('repo' => $repository, 'component' => $component, 'cwd' => $cwd)), - ); - } - - private static function registered_component_relative_path(string $root, string $path): ?string { - $prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : null; - } - - /** @return string|WP_Error */ - private static function git_root(string $path): string|WP_Error { - $descriptors = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')); - $process = proc_open(array('git', '-C', $path, 'rev-parse', '--show-toplevel'), $descriptors, $pipes, null, null, array('bypass_shell' => true)); - if (!is_resource($process)) { - return self::contract_error('Git root probe could not be started.', array('path' => $path)); - } - $stdout = trim((string) stream_get_contents($pipes[1])); - $stderr = trim((string) stream_get_contents($pipes[2])); - fclose($pipes[1]); - fclose($pipes[2]); - if (0 !== proc_close($process) || '' === $stdout || false === ($root = realpath($stdout)) || !is_dir($root)) { - return self::contract_error('Git repository root is unavailable for a registered Homeboy component.', array('path' => $path, 'stderr' => $stderr)); - } - return $root; - } - - /** @return string|WP_Error */ - private static function git_remote(string $path): string|WP_Error { - $descriptors = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')); - $process = proc_open(array('git', '-C', $path, 'remote', 'get-url', 'origin'), $descriptors, $pipes, null, null, array('bypass_shell' => true)); - if (!is_resource($process)) { - return self::contract_error('Git remote probe could not be started.', array('path' => $path)); - } - $stdout = trim((string) stream_get_contents($pipes[1])); - $stderr = trim((string) stream_get_contents($pipes[2])); - fclose($pipes[1]); - fclose($pipes[2]); - if (0 !== proc_close($process) || '' === $stdout) { - return self::contract_error('Git origin remote is unavailable for a Cook destination.', array('path' => $path, 'stderr' => $stderr)); - } - if (!str_contains($stdout, '://') && preg_match('#^(?:[^@/:]+@)?([^/:]+):/?(.+)$#', $stdout, $matches)) { - $host = $matches[1]; - $path = $matches[2]; - } else { - $parts = parse_url($stdout); - $host = is_array($parts) && is_string($parts['host'] ?? null) ? $parts['host'] : ''; - $path = is_array($parts) && is_string($parts['path'] ?? null) ? $parts['path'] : ''; - } - $path = preg_replace('#^/+|\.git/?$#', '', $path); - if ('' === $host || '' === $path) { - return self::contract_error('Git origin remote is not a canonical repository URL.', array('path' => $path, 'remote' => $stdout)); - } - return strtolower($host . '/' . $path); - } - - /** @param array $input @return array|WP_Error */ - private static function add(array $input): array|WP_Error { - $repo = self::required_string($input, 'repo'); - $branch = self::required_string($input, 'branch'); - $owner = self::required_string($input, 'owner_run_ref'); - if (is_wp_error($repo) || is_wp_error($branch) || is_wp_error($owner)) { - return is_wp_error($repo) ? $repo : (is_wp_error($branch) ? $branch : $owner); - } - foreach (array('inject_context', 'bootstrap', 'allow_stale', 'rebase_base', 'force', 'allow_percentage_byte_floor_exception', 'remediate_capacity', 'remediate_capacity_dry_run', 'verbose') as $unsupported) { - if (!empty($input[$unsupported])) { - return self::unsupported_input($unsupported, 'Homeboy create cannot preserve this DMC-specific behavior.'); - } - } - if (null !== self::optional_string($input, 'task_ref')) { - return self::unsupported_input('task_ref', 'Homeboy create accepts a canonical task URL, not a DMC task reference.'); - } - if (!empty($input['require_task_tracker']) && null === self::optional_string($input, 'task_url')) { - return self::unsupported_input('require_task_tracker', 'A canonical task_url is required by this request.'); - } - if (isset($input['reuse_policy']) && !in_array($input['reuse_policy'], array('', 'isolated'), true)) { - return self::unsupported_input('reuse_policy', 'Homeboy create allocates one exact isolated worktree identity.'); - } - if (isset($input['cleanup_policy']) && null !== self::optional_string($input, 'cleanup_policy') && null === self::cleanup_policy($input['cleanup_policy'])) { - return self::unsupported_input('cleanup_policy', 'Homeboy cannot represent this DMC cleanup policy.'); - } - - $command = array(self::HOMEBOY, 'worktree', 'create', self::repository_target($repo), '--branch', $branch); - if (empty($input['allow_unverified_freshness'])) { - $command[] = '--require-handoff-freshness'; - } - $from = self::optional_string($input, 'from') ?? 'origin/HEAD'; - $command = array_merge($command, array('--from', $from)); - if (null !== ($task_url = self::optional_string($input, 'task_url'))) { - $command = array_merge($command, array('--task-url', $task_url)); - } - $command = array_merge($command, array('--run-id', $owner)); - if (null !== ($policy = self::cleanup_policy($input['cleanup_policy'] ?? null))) { - $command = array_merge($command, array('--cleanup-policy', $policy)); - } - - $data = self::homeboy($command); - if (is_wp_error($data)) { - return $data; - } - $record = $data['record'] ?? null; - if (!is_array($record)) { - return self::contract_error('Homeboy create result omitted its native worktree record.', $data); - } - if ($owner !== self::optional_string($record, 'run_id')) { - return self::contract_error('Homeboy create result omitted the exact requested owner-run reference.', $data); - } - $freshness = self::handoff_freshness($data, $record, !empty($input['allow_unverified_freshness'])); - if (is_wp_error($freshness)) { - return $freshness; - } - - return array( - 'success' => true, - 'dry_run' => false, - 'created' => true, - 'handle' => (string) ($record['id'] ?? ''), - 'path' => (string) ($record['worktree_path'] ?? ''), - 'branch' => (string) ($record['branch'] ?? $branch), - 'base' => (string) ($record['base_ref'] ?? $from), - 'slug' => self::branch_slug($branch), - 'created_branch' => true, - 'reused' => false, - 'recycled' => false, - 'context_injected' => false, - 'context_files' => array(), - 'bootstrap' => array('outcome' => 'not_requested', 'owner' => 'homeboy'), - 'handoff_freshness' => $freshness, - 'message' => 'Homeboy owns this worktree lifecycle.', - 'metadata' => array('homeboy' => $record, 'owner_run_ref' => $owner), - ); - } - - private static function repository_target(string $repo): string { - if (!defined('DATAMACHINE_WORKSPACE_PATH')) { - return $repo; - } - $root = realpath((string) DATAMACHINE_WORKSPACE_PATH); - $path = false === $root ? false : realpath($root . DIRECTORY_SEPARATOR . $repo); - if (false === $root || false === $path || dirname($path) !== $root || !is_dir($path) || !file_exists($path . DIRECTORY_SEPARATOR . '.git')) { - return $repo; - } - return $path; - } - - /** @param array $data @param array $record @return array|WP_Error */ - private static function handoff_freshness(array $data, array $record, bool $allow_unverified): array|WP_Error { - $source = $data['handoff_freshness']['proof'] ?? null; - if (!is_array($source) || 'verified' !== ($data['handoff_freshness']['status'] ?? null)) { - if ($allow_unverified) { - return array('status' => 'unverified', 'reason' => 'remote_freshness_probe_unsupported'); - } - return self::contract_error('Homeboy create result omitted required handoff freshness proof.', $data); - } - $proof = array( - 'version' => 3, - 'proof_id' => (string) ($source['proof_id'] ?? ''), - 'handle' => (string) ($record['id'] ?? ''), - 'worktree_sha' => (string) ($source['worktree_sha'] ?? ''), - 'resolved_base_ref' => (string) ($source['resolved_base_ref'] ?? ''), - 'resolved_base_sha' => (string) ($source['resolved_base_sha'] ?? ''), - 'remote_default_ref' => (string) ($source['remote_default_ref'] ?? ''), - 'remote_default_sha' => (string) ($source['remote_default_sha'] ?? ''), - 'remote_default_advertised_sha' => (string) ($source['remote_default_advertised_sha'] ?? ''), - 'verified_at' => (string) ($source['verified_at'] ?? ''), - ); - if (in_array('', array_values($proof), true)) { - return self::contract_error('Homeboy handoff freshness proof is incomplete.', $data); - } - $proof['digest'] = hash('sha256', (string) wp_json_encode(self::canonicalize($proof))); - return array('status' => 'verified', 'proof' => $proof); - } - - private static function canonicalize(mixed $value): mixed { - if (!is_array($value)) { - return $value; - } - foreach ($value as $key => $item) { - $value[$key] = self::canonicalize($item); - } - if (array_keys($value) !== range(0, count($value) - 1)) { - ksort($value, SORT_STRING); - } - return $value; - } - - /** @param array $input @return array|WP_Error */ - private static function listing(array $input): array|WP_Error { - if (!empty($input['include_disk'])) { - return self::unsupported_input('include_disk', 'Homeboy does not expose worktree disk probes through this contract.'); - } - - $data = self::homeboy(array(self::HOMEBOY, 'worktree', 'list')); - if (is_wp_error($data)) { - return $data; - } - $records = is_array($data['worktrees'] ?? null) ? $data['worktrees'] : array(); - $rows = array(); - foreach ($records as $record) { - if (!is_array($record) || !self::record_matches($record, $input)) { - continue; - } - $rows[] = self::record_row($record, !empty($input['include_status'])); - } - foreach ($rows as $row) { - if (is_wp_error($row)) { - return $row; - } - } - - usort($rows, static fn(array $left, array $right): int => strcmp($left['handle'], $right['handle'])); - $limit = !empty($input['all']) ? max(1, count($rows)) : max(1, min(200, (int) ($input['limit'] ?? 50))); - $matching_total = count($rows); - $cursor = self::optional_string($input, 'cursor'); - if (null !== $cursor) { - $decoded = base64_decode($cursor, true); - if (false === $decoded) { - return self::unsupported_input('cursor', 'The Homeboy worktree cursor is malformed.'); - } - $rows = array_values(array_filter($rows, static fn(array $row): bool => strcmp($row['handle'], $decoded) > 0)); - } - $remaining = count($rows); - $page = array_slice($rows, 0, $limit); - $next_cursor = $remaining > count($page) ? base64_encode((string) $page[count($page) - 1]['handle']) : null; - - return array( - 'success' => true, - 'total' => $matching_total, - 'returned' => count($page), - 'next_cursor' => $next_cursor, - 'status_requested' => !empty($input['include_status']), - 'disk_requested' => false, - 'partial' => null !== $next_cursor, - 'fields_skipped' => !empty($input['include_status']) ? array('disk') : array('status', 'disk'), - 'summary' => array('owner' => 'homeboy', 'matching' => $matching_total), - 'worktrees' => $page, - 'duplicates' => array(), - ); - } - - /** @param array $record @return array|WP_Error */ - private static function record_row(array $record, bool $include_status): array|WP_Error { - $handle = (string) ($record['id'] ?? ''); - $safety = null; - if ($include_status) { - $status = self::homeboy(array(self::HOMEBOY, 'worktree', 'status', $handle)); - if (is_wp_error($status)) { - return $status; - } - $safety = is_array($status['safety'] ?? null) ? $status['safety'] : array(); - } - - $task_url = self::optional_string($record, 'task_url'); - return array( - 'handle' => $handle, - 'repo' => (string) ($record['component_id'] ?? ''), - 'is_worktree' => true, - 'is_primary' => false, - 'external' => false, - 'branch_slug' => self::branch_slug((string) ($record['branch'] ?? '')), - 'branch' => $record['branch'] ?? null, - 'head' => '', - 'path' => (string) ($record['worktree_path'] ?? ''), - 'dirty' => null === $safety ? null : (!empty($safety['dirty']) ? 1 : 0), - 'created_at' => $record['created_at'] ?? null, - 'lifecycle_state' => self::dmc_state($record), - 'liveness' => empty($record['terminal_disposition']) ? 'unknown' : 'stopped', - 'liveness_reason' => empty($record['terminal_disposition']) ? 'homeboy_owner_state' : 'homeboy_terminal_disposition', - 'owner' => array('site' => 'unknown', 'site_url' => null, 'agent' => 'homeboy', 'user' => 'unknown'), - 'session' => array('primary_id' => $record['run_id'] ?? null, 'ids' => array()), - 'task' => null === $task_url ? null : array('task_url' => $task_url), - 'metadata' => array('homeboy' => $record, 'homeboy_safety' => $safety), - 'fields_skipped' => null === $safety ? array('status', 'disk') : array('disk'), - ); - } - - /** @param array $input @return array|WP_Error */ - private static function finalize(array $input): array|WP_Error { - $handle = self::required_string($input, 'handle'); - $state = self::required_string($input, 'state'); - if (is_wp_error($handle) || is_wp_error($state)) { - return is_wp_error($handle) ? $handle : $state; - } - if (null !== self::optional_string($input, 'pr')) { - return self::unsupported_input('pr', 'Homeboy terminal finalization does not persist DMC PR metadata.'); - } - $disposition = match ($state) { - 'merged', 'cleanup_eligible' => 'succeeded', - 'closed' => 'cancelled', - 'abandoned' => 'failed', - default => null, - }; - if (null === $disposition) { - return self::unsupported_input('state', 'Homeboy finalization accepts terminal DMC lifecycle states only.'); - } - - $status = self::homeboy(array(self::HOMEBOY, 'worktree', 'status', $handle)); - if (is_wp_error($status)) { - return $status; - } - $record = is_array($status['record'] ?? null) ? $status['record'] : array(); - $owner = self::optional_string($record, 'run_id'); - if (null === $owner) { - return self::unsupported_input('owner_run_ref', 'The Homeboy worktree has no exact owner-run reference for finalization.'); - } - - $data = self::homeboy(array(self::HOMEBOY, 'worktree', 'finalize', $handle, '--owner-run-ref', $owner, '--disposition', $disposition)); - if (is_wp_error($data)) { - return $data; - } - return array( - 'success' => true, - 'handle' => $handle, - 'path' => (string) ($data['inspection_path'] ?? $record['worktree_path'] ?? ''), - 'lifecycle_state' => $state, - 'metadata' => array('homeboy' => $data, 'owner_run_ref' => $owner), - 'message' => 'Homeboy recorded the terminal disposition without cleanup.', - ); - } - - /** @param array $input @return array|WP_Error */ - private static function remove(array $input): array|WP_Error { - $repo = self::required_string($input, 'repo'); - $branch = self::required_string($input, 'branch'); - if (is_wp_error($repo) || is_wp_error($branch)) { - return is_wp_error($repo) ? $repo : $branch; - } - $handle = $repo . '@' . self::branch_slug($branch); - $command = array(self::HOMEBOY, 'worktree', 'remove', $handle); - if (!empty($input['force'])) { - $command[] = '--force'; - } - $data = self::homeboy($command); - if (is_wp_error($data)) { - return $data; - } - return array('success' => true, 'handle' => $handle, 'message' => 'Homeboy removed the worktree after native safety checks.', 'homeboy' => $data); - } - - /** @param array $input @return array|WP_Error */ - private static function cleanup(array $input): array|WP_Error { - foreach (array('skip_github', 'inventory_only', 'apply_plan', 'older_than', 'sort', 'include_repaired_metadata', 'limit', 'offset', 'until_budget') as $unsupported) { - if (array_key_exists($unsupported, $input) && !in_array($input[$unsupported], array(false, null, ''), true)) { - return self::unsupported_input($unsupported, 'Homeboy cleanup cannot preserve this DMC-specific option.'); - } - } - $dry_run = !empty($input['dry_run']); - $command = array(self::HOMEBOY, 'worktree', 'cleanup'); - if (!$dry_run) { - $command[] = '--apply'; - } - if (!empty($input['force'])) { - $command[] = '--force'; - } - $data = self::homeboy($command); - if (is_wp_error($data)) { - return $data; - } - $worktrees = is_array($data['worktrees'] ?? null) ? $data['worktrees'] : array(); - return array( - 'success' => true, - 'dry_run' => (bool) ($worktrees['dry_run'] ?? $dry_run), - 'candidates' => is_array($worktrees['candidates'] ?? null) ? $worktrees['candidates'] : array(), - 'removed' => is_array($worktrees['removed'] ?? null) ? $worktrees['removed'] : array(), - 'skipped' => is_array($worktrees['skipped'] ?? null) ? $worktrees['skipped'] : array(), - 'summary' => is_array($worktrees['counts'] ?? null) ? $worktrees['counts'] : array(), - ); - } - - /** @param array $record @param array $input */ - private static function record_matches(array $record, array $input): bool { - if ('removed' === ($record['state'] ?? null)) { - return false; - } - $matches = array( - 'repo' => $record['component_id'] ?? null, - 'handle' => $record['id'] ?? null, - 'owner_run_ref' => $record['run_id'] ?? null, - 'state' => self::dmc_state($record), - ); - foreach ($matches as $field => $value) { - if (null !== ($expected = self::optional_string($input, $field)) && $expected !== $value) { - return false; - } - } - $task = self::optional_string($input, 'task_ref'); - return null === $task || $task === ($record['task_url'] ?? null); - } - - /** @param list $command @return array|WP_Error */ - private static function homeboy(array $command): array|WP_Error { - $descriptors = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')); - $process = proc_open($command, $descriptors, $pipes, null, null, array('bypass_shell' => true)); - if (!is_resource($process)) { - return self::contract_error('Homeboy process could not be started.', array()); - } - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - $stdout = ''; - $stderr = ''; - $start = microtime(true); - $exit = null; - do { - $stdout .= (string) stream_get_contents($pipes[1]); - $stderr .= (string) stream_get_contents($pipes[2]); - if (strlen($stdout) + strlen($stderr) > self::OUTPUT_LIMIT_BYTES) { - proc_terminate($process, 9); - self::close_process($process, $pipes); - return self::contract_error('Homeboy output exceeded the bounded adapter limit.', array()); - } - $status = proc_get_status($process); - if (!$status['running']) { - $exit = (int) $status['exitcode']; - break; - } - if (microtime(true) - $start >= self::TIMEOUT_SECONDS) { - proc_terminate($process, 9); - self::close_process($process, $pipes); - return self::contract_error('Homeboy exceeded the bounded adapter timeout.', array()); - } - usleep(10000); - } while (true); - $stdout .= (string) stream_get_contents($pipes[1]); - $stderr .= (string) stream_get_contents($pipes[2]); - if (strlen($stdout) + strlen($stderr) > self::OUTPUT_LIMIT_BYTES) { - self::close_process($process, $pipes); - return self::contract_error('Homeboy output exceeded the bounded adapter limit.', array()); - } - self::close_process($process, $pipes); - - $payload = json_decode($stdout, true); - if (!is_array($payload) || 'homeboy/command-result/v3' !== ($payload['schema'] ?? null)) { - return self::contract_error('Homeboy returned malformed command-result JSON.', array('exit_code' => $exit, 'stderr' => trim($stderr))); - } - if (0 !== $exit || empty($payload['success'])) { - return new WP_Error( - 'wp_coding_agents_homeboy_worktree_failed', - (string) ($payload['diagnostics']['message'] ?? $payload['summary'] ?? 'Homeboy worktree operation failed.'), - array('status' => 409, 'owner' => 'homeboy', 'homeboy' => $payload) - ); - } - return is_array($payload['data'] ?? null) ? $payload['data'] : array(); - } - - /** @param resource $process @param array $pipes */ - private static function close_process($process, array $pipes): void { - foreach ($pipes as $pipe) { - fclose($pipe); - } - proc_close($process); - } - - /** @param array $input @return string|WP_Error */ - private static function required_string(array $input, string $field): string|WP_Error { - $value = self::optional_string($input, $field); - return null === $value ? self::unsupported_input($field, "A non-empty {$field} is required.") : $value; - } - - /** @param array $input */ - private static function optional_string(array $input, string $field): ?string { - return isset($input[$field]) && is_scalar($input[$field]) && '' !== trim((string) $input[$field]) ? trim((string) $input[$field]) : null; - } - - private static function cleanup_policy(mixed $value): ?string { - return match ($value) { - // Without an owner-bound successful finalization, preserve-on-failure - // remains ineligible for cleanup and therefore preserves DMC manual intent. - 'manual' => 'preserve-on-failure', - 'remove_on_success' => 'remove-when-safe', - 'preserve_on_failure' => 'preserve-on-failure', - default => null, - }; - } - - /** @param array $record */ - private static function dmc_state(array $record): ?string { - return match ($record['terminal_disposition'] ?? null) { - 'succeeded' => 'cleanup_eligible', - 'failed' => 'abandoned', - 'cancelled' => 'closed', - default => match ($record['state'] ?? null) { - 'completed' => 'cleanup_eligible', - default => $record['state'] ?? null, - }, - }; - } - - private static function branch_slug(string $branch): string { - return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $branch), '-'); - } - - private static function refusal(string $slug): WP_Error { - return new WP_Error( - 'wp_coding_agents_homeboy_worktree_unsupported', - 'This DMC worktree operation has no exact Homeboy lifecycle equivalent.', - array('status' => 409, 'ability' => $slug, 'owner' => 'homeboy', 'retryable' => false) - ); - } - - private static function unsupported_input(string $field, string $message): WP_Error { - return new WP_Error('wp_coding_agents_homeboy_worktree_unsupported_input', $message, array('status' => 400, 'field' => $field, 'owner' => 'homeboy', 'retryable' => false)); - } - - /** @param array $evidence */ - private static function contract_error(string $message, array $evidence): WP_Error { - return new WP_Error('wp_coding_agents_homeboy_worktree_contract_error', $message, array('status' => 500, 'owner' => 'homeboy', 'evidence' => $evidence)); - } -} - -add_filter('datamachine_code_ability_registration_args', array(WP_Coding_Agents_Homeboy_Worktrees::class, 'filter_ability'), 10, 2); - -if (defined('WP_CLI') && WP_CLI) { - WP_CLI::add_command( - 'wp-coding-agents homeboy cook-destination', - static function (array $args, array $assoc_args): void { - $input = json_decode((string) ($assoc_args['workspace-result'] ?? ''), true); - if (!is_array($input)) { - WP_CLI::error('--workspace-result must be one DMC workspace result JSON object.'); - } - $result = WP_Coding_Agents_Homeboy_Worktrees::cook_destination($input); - if (is_wp_error($result)) { - WP_CLI::error($result->message); - } - WP_CLI::log((string) wp_json_encode($result)); - } - ); -} diff --git a/templates/wp-coding-agents-source-reconcile.php b/templates/wp-coding-agents-source-reconcile.php index d01be16..faeb9b9 100644 --- a/templates/wp-coding-agents-source-reconcile.php +++ b/templates/wp-coding-agents-source-reconcile.php @@ -74,7 +74,7 @@ * @return string[] */ function wp_coding_agents_carried_slugs() { - $slugs = array( 'data-machine', 'data-machine-code', 'wp-codebox' ); + $slugs = array( 'data-machine', 'wp-codebox' ); $plugin_dir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : ABSPATH . 'wp-content/plugins'; foreach ( glob( $plugin_dir . '/*/.wp-coding-agents-carried' ) ?: array() as $marker ) { $slugs[] = basename( dirname( $marker ) ); diff --git a/tests/agents-md-composition-integration.sh b/tests/agents-md-composition-integration.sh index b3203aa..9fd844b 100644 --- a/tests/agents-md-composition-integration.sh +++ b/tests/agents-md-composition-integration.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Compose and execute DMC and Intelligence guidance with the host transport. +# Compose and execute Data Machine and Intelligence guidance with the host transport. set -eu ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -39,7 +39,7 @@ while [ "$#" -gt 0 ]; do esac done case "${1:-} ${2:-} ${3:-} ${4:-}" in - "datamachine-code workspace show fixture") exit 0 ;; + "datamachine memory paths fixture") exit 0 ;; "intelligence search fixture ") exit 0 ;; esac exit 11 @@ -82,11 +82,11 @@ class_alias( 'IntegrationSectionRegistry', 'DataMachine\\Engine\\AI\\SectionRegi // Must load before normal plugins, as WordPress loads mu-plugins. require $argv[1]; -// Reproduce normal alphabetical plugin load: Data Machine Code captures its -// prefix while registering, while Intelligence resolves its prefix at render. + // Reproduce normal plugin load: Data Machine and Intelligence resolve the + // host-selected transport while registering their guidance. add_action( 'datamachine_sections', static function () { $wp = apply_filters( 'datamachine_wp_cli_cmd', 'wp --path=/path/to/site' ); - IntegrationSectionRegistry::register( 'AGENTS.md', 'datamachine-code', 20, static fn() => "## Data Machine Code\n\n`{$wp} datamachine-code workspace show fixture`" ); + IntegrationSectionRegistry::register( 'AGENTS.md', 'datamachine', 20, static fn() => "## Data Machine\n\n`{$wp} datamachine memory paths fixture`" ); }, 10 ); add_action( 'datamachine_sections', static function () { IntegrationSectionRegistry::register( 'AGENTS.md', 'intelligence', 30, static function () { @@ -97,7 +97,7 @@ add_action( 'datamachine_sections', static function () { $content = IntegrationSectionRegistry::compose(); file_put_contents( $argv[2], $content ); -foreach ( array( 'datamachine-code workspace show fixture', 'intelligence search fixture' ) as $index => $suffix ) { +foreach ( array( 'datamachine memory paths fixture', 'intelligence search fixture' ) as $index => $suffix ) { if ( ! preg_match( '/`([^`]+ ' . preg_quote( $suffix, '/' ) . ')`/', $content, $matches ) ) { fwrite( STDERR, "Missing composed command: {$suffix}\n" ); exit( 1 ); @@ -120,11 +120,11 @@ run_case() { return 1 } - php "$TMP/compose.php" "$MU_FILE" "$TMP/$name-AGENTS.md" "$TMP/$name-dmc.cmd" "$TMP/$name-intelligence.cmd" - grep -F "\`$expected --path=/path/to/site datamachine-code workspace show fixture\`" "$TMP/$name-AGENTS.md" >/dev/null + php "$TMP/compose.php" "$MU_FILE" "$TMP/$name-AGENTS.md" "$TMP/$name-dm.cmd" "$TMP/$name-intelligence.cmd" + grep -F "\`$expected --path=/path/to/site datamachine memory paths fixture\`" "$TMP/$name-AGENTS.md" >/dev/null grep -F "\`$expected --path=/path/to/site intelligence search fixture\`" "$TMP/$name-AGENTS.md" >/dev/null - env -i PATH="$TMP/bin:/usr/bin:/bin" bash -c "$(<"$TMP/$name-dmc.cmd")" + env -i PATH="$TMP/bin:/usr/bin:/bin" bash -c "$(<"$TMP/$name-dm.cmd")" env -i PATH="$TMP/bin:/usr/bin:/bin" bash -c "$(<"$TMP/$name-intelligence.cmd")" } @@ -149,4 +149,4 @@ PY run_case studio-upgrade "studio wp" studio wp run_case generic wp wp -echo "OK: composed DMC and Intelligence guidance uses one executable transport" +echo "OK: composed Data Machine and Intelligence guidance uses one executable transport" diff --git a/tests/claude-code-permissions.sh b/tests/claude-code-permissions.sh index 5d8def6..21125bc 100755 --- a/tests/claude-code-permissions.sh +++ b/tests/claude-code-permissions.sh @@ -6,11 +6,15 @@ TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT export SITE_PATH="$TMP/site" -export DM_WORKSPACE_DIR="$TMP/workspace" export AGENT_SLUG="builder" export DRY_RUN=false export IS_STUDIO=false -mkdir -p "$SITE_PATH/.claude" "$DM_WORKSPACE_DIR" +WORKSPACE_ONE="$TMP/workspace one" +WORKSPACE_TWO="$TMP/workspace two" +mkdir -p "$SITE_PATH/.claude" "$WORKSPACE_ONE" "$WORKSPACE_TWO" +git -C "$WORKSPACE_ONE" init -q +git -C "$WORKSPACE_TWO" init -q +export WORKSPACE_REPOSITORIES="$WORKSPACE_ONE:$WORKSPACE_TWO" cat > "$SITE_PATH/.claude/settings.json" <<'JSON' { @@ -59,9 +63,33 @@ if "Read(./private/**)" not in denies: raise SystemExit(f"existing deny was not preserved: {sorted(denies)}") PY +python3 - "$SITE_PATH/.claude/settings.json" "$WORKSPACE_ONE" "$WORKSPACE_TWO" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +directories = data["permissions"].get("additionalDirectories", []) +allows = set(data["permissions"].get("allow", [])) +for workspace in sys.argv[2:]: + assert workspace in directories + for tool in ("Read", "Edit", "Write"): + assert f"{tool}({workspace}/**)" in allows +assert "Read(/**)" not in allows +PY + HASH_BEFORE=$(md5 -q "$SITE_PATH/.claude/settings.json" 2>/dev/null || md5sum "$SITE_PATH/.claude/settings.json" | cut -d' ' -f1) runtime_install_hooks HASH_AFTER=$(md5 -q "$SITE_PATH/.claude/settings.json" 2>/dev/null || md5sum "$SITE_PATH/.claude/settings.json" | cut -d' ' -f1) [ "$HASH_BEFORE" = "$HASH_AFTER" ] +EMPTY_SITE="$TMP/empty-site" +mkdir -p "$EMPTY_SITE/.claude" +SITE_PATH="$EMPTY_SITE" +WORKSPACE_REPOSITORIES="" +runtime_install_hooks +python3 - "$EMPTY_SITE/.claude/settings.json" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +assert not data["permissions"].get("additionalDirectories", []) +assert not data["permissions"].get("allow", []) +PY + echo "PASS: Claude Code WordPress edit permissions" diff --git a/tests/dm-workspace-discovery.sh b/tests/dm-workspace-discovery.sh deleted file mode 100755 index be1f809..0000000 --- a/tests/dm-workspace-discovery.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/bin/bash -# tests/dm-workspace-discovery.sh — bounded canonical DMC workspace discovery. -set -eu - -SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -SITE_PATH="$TMP/site" -mkdir -p "$SITE_PATH" -touch "$SITE_PATH/wp-config.php" - -export SITE_PATH -export DRY_RUN=false -export WP_CMD="studio wp" -export WP_ROOT_FLAG="" - -MODE=success -WP_CALLS_FILE="$TMP/wp-calls" -CHILD_PID_FILE="$TMP/child.pid" -DESCENDANT_PID_FILE="$TMP/descendant.pid" -export WP_CALLS_FILE CHILD_PID_FILE DESCENDANT_PID_FILE - -source "$SCRIPT_DIR/lib/common.sh" -log() { printf '%s\n' "$1"; } -warn() { printf '%s\n' "$1"; } - -wp_cmd() { - printf 'call\n' >> "$WP_CALLS_FILE" - if [ "$*" != "datamachine-code workspace path" ]; then - echo "unexpected wp_cmd call: $*" >&2 - return 1 - fi - case "$MODE" in - success) - sleep 0.2 - printf '%s\n' "$TMP/Developer" - ;; - diagnostic_prefix) - printf '\nDeprecated: Case statements followed by a semicolon are deprecated.\n%s\n' "$TMP/Developer" - ;; - arbitrary_prefix) - printf 'unexpected output\n%s\n' "$TMP/Developer" - ;; - ambiguous) - printf '%s\n%s\n' "$TMP/Developer" "$TMP/other-workspace" - ;; - failure) - echo "DMC_ERROR_CODE=workspace_locked: sqlite busy" >&2 - return 73 - ;; - hang) - /bin/sh -c 'printf "%s\n" "$$" > "$CHILD_PID_FILE"; sleep 30 & printf "%s\n" "$!" > "$DESCENDANT_PID_FILE"; wait' - ;; - esac -} - -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/data-machine.sh" - -DM_WORKSPACE_DIR="$TMP/.datamachine/workspace" -DATAMACHINE_WORKSPACE_PATH="" -DM_WORKSPACE_DISCOVERY_TIMEOUT_SECONDS=2 -discover_dm_workspace_dir -if [ "$DM_WORKSPACE_DIR" != "$TMP/Developer" ]; then - echo "FAIL: slow canonical DMC workspace was not discovered: $DM_WORKSPACE_DIR" - exit 1 -fi - -MODE=diagnostic_prefix -DM_WORKSPACE_DIR="" -discover_dm_workspace_dir -if [ "$DM_WORKSPACE_DIR" != "$TMP/Developer" ]; then - echo "FAIL: canonical DMC workspace with a PHP diagnostic prefix was not discovered: $DM_WORKSPACE_DIR" - exit 1 -fi - -for invalid_mode in arbitrary_prefix ambiguous; do - MODE="$invalid_mode" - DM_WORKSPACE_DIR="$TMP/guessed-workspace" - set +e - discover_dm_workspace_dir >"$TMP/$invalid_mode.out" 2>"$TMP/$invalid_mode.err" - invalid_status=$? - set -e - if [ "$invalid_status" -eq 0 ] || [ -n "$DM_WORKSPACE_DIR" ]; then - echo "FAIL: $invalid_mode workspace output was accepted: $DM_WORKSPACE_DIR" - exit 1 - fi -done - -DATAMACHINE_WORKSPACE_PATH="$TMP/explicit-workspace" -DM_WORKSPACE_DIR="$TMP/Developer" -rm -f "$WP_CALLS_FILE" -discover_dm_workspace_dir -if [ "$DM_WORKSPACE_DIR" != "$TMP/explicit-workspace" ]; then - echo "FAIL: explicit workspace path was not authoritative: $DM_WORKSPACE_DIR" - exit 1 -fi -if [ -e "$WP_CALLS_FILE" ]; then - echo "FAIL: explicit workspace path still invoked authoritative discovery" - exit 1 -fi - -DATAMACHINE_WORKSPACE_PATH="" -DM_WORKSPACE_DIR="$TMP/guessed-workspace" -MODE=failure -set +e -discover_dm_workspace_dir >"$TMP/failure.out" 2>"$TMP/failure.err" -failure_status=$? -set -e -failure_output="$(cat "$TMP/failure.out")$(cat "$TMP/failure.err")" -if [ "$failure_status" -ne 73 ] || [[ "$failure_output" != *"DMC_ERROR_CODE=workspace_locked: sqlite busy"* ]]; then - echo "FAIL: nonzero discovery did not preserve status and stderr: status=$failure_status output=$failure_output" - exit 1 -fi -if [[ "$failure_output" != *"studio wp datamachine-code workspace path"*"--path=$SITE_PATH"* ]]; then - echo "FAIL: discovery failure did not name the replay command: $failure_output" - exit 1 -fi -if [ -n "$DM_WORKSPACE_DIR" ]; then - echo "FAIL: failed authoritative discovery retained guessed workspace: $DM_WORKSPACE_DIR" - exit 1 -fi - -MODE=hang -DM_WORKSPACE_DIR="$TMP/guessed-workspace" -DM_WORKSPACE_DISCOVERY_TIMEOUT_SECONDS=1 -started="$(python3 -c 'import time; print(time.monotonic())')" -set +e -discover_dm_workspace_dir >"$TMP/timeout.out" 2>"$TMP/timeout.err" -timeout_status=$? -set -e -elapsed="$(python3 -c 'import sys, time; print(time.monotonic() - float(sys.argv[1]))' "$started")" -timeout_output="$(cat "$TMP/timeout.out")$(cat "$TMP/timeout.err")" -if [ "$timeout_status" -ne 124 ] || ! python3 -c 'import sys; raise SystemExit(0 if float(sys.argv[1]) < 2.5 else 1)' "$elapsed"; then - echo "FAIL: hanging discovery was not bounded: status=$timeout_status elapsed=${elapsed}s" - exit 1 -fi -if [[ "$timeout_output" != *"timeout: 1s, elapsed: 0s"* ]] || [[ "$timeout_output" != *"timed out after 1s"* ]]; then - echo "FAIL: timeout diagnostic omitted phase timing: $timeout_output" - exit 1 -fi -if [[ "$timeout_output" != *"studio wp datamachine-code workspace path"*"--path=$SITE_PATH"* ]]; then - echo "FAIL: timeout diagnostic did not name the replay command: $timeout_output" - exit 1 -fi - -for pid_file in "$CHILD_PID_FILE" "$DESCENDANT_PID_FILE"; do - if [ ! -s "$pid_file" ]; then - echo "FAIL: hanging fixture did not record process in $pid_file" - exit 1 - fi - pid="$(cat "$pid_file")" - attempts=0 - while kill -0 "$pid" 2>/dev/null && [ "$attempts" -lt 20 ]; do - sleep 0.1 - attempts=$((attempts + 1)) - done - if kill -0 "$pid" 2>/dev/null; then - echo "FAIL: timed out discovery left process $pid alive" - exit 1 - fi -done - -echo "PASS: tests/dm-workspace-discovery.sh" diff --git a/tests/dmc-managed-release.sh b/tests/dmc-managed-release.sh deleted file mode 100644 index d1e701a..0000000 --- a/tests/dmc-managed-release.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# Copied DMC stays Homeboy-owned. Git checkouts still update. Absent DMC can bootstrap. -set -eu - -SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/plugin-upgrade.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/wordpress.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/data-machine.sh" - -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT -SITE_PATH="$TMP/site" -TIMESTAMP="test" -DRY_RUN=false -INSTALL_DATA_MACHINE=true -BLUE=""; NC="" -declare -a UPDATED_ITEMS=() -declare -a PENDING_ITEMS=() -declare -a PLUGIN_UPDATE_FAILURES=() -declare -a BOOTSTRAPS=() -LOG="" -log() { LOG="$LOG$*"$'\n'; } -warn() { LOG="$LOG$*"$'\n'; } -fix_ownership() { :; } -activate_plugin() { :; } -install_plugin_dependencies() { :; } -install_plugin_dependencies_bounded() { :; } -source_policy_workspace_enabled() { return 0; } -fail() { echo "FAIL: $1" >&2; exit 1; } - -install_plugin() { - local slug="$1" plugin_dir="$SITE_PATH/wp-content/plugins/$slug" - mkdir -p "$plugin_dir" - printf ' "$plugin_dir/${slug}.php" - git init "$plugin_dir" >/dev/null 2>&1 - BOOTSTRAPS+=("$slug") -} - -fingerprint() { - python3 - "$1" <<'PY' -import hashlib, os, sys -root = sys.argv[1] -digest = hashlib.sha256() -for dirpath, dirs, files in os.walk(root, followlinks=False): - dirs.sort() - files.sort() - digest.update(os.path.relpath(dirpath, root).encode()) - for name in dirs + files: - path = os.path.join(dirpath, name) - rel = os.path.relpath(path, root) - digest.update(rel.encode()) - if os.path.islink(path): - digest.update(b"link:") - digest.update(os.readlink(path).encode()) - elif os.path.isfile(path): - with open(path, "rb") as handle: - digest.update(handle.read()) -print(digest.hexdigest()) -PY -} - -mkdir -p "$SITE_PATH/wp-content/plugins/data-machine" -printf ' "$SITE_PATH/wp-content/plugins/data-machine/data-machine.php" - -PLUGIN="$SITE_PATH/wp-content/plugins/data-machine-code" -mkdir -p "$PLUGIN/inc" -printf ' "$PLUGIN/data-machine-code.php" -printf 'homeboy-copied-bytes\n' > "$PLUGIN/inc/payload.txt" -printf 'direct-copy\n' > "$PLUGIN/HOMEBOY_DEPLOYED" -before="$(fingerprint "$PLUGIN")" - -PLUGINS_ONLY=true -upgrade_data_machine_plugins -after="$(fingerprint "$PLUGIN")" -[ "$before" = "$after" ] || fail "copied DMC mutated during plugin-only upgrade" -[ ! -e "$PLUGIN/.wp-coding-agents-releases" ] || fail "copied DMC was converted to .wp-coding-agents-releases" -[ ! -e "$PLUGIN/.wp-coding-agents-release-current" ] || fail "copied DMC gained a release pointer" -[ ! -L "$PLUGIN/data-machine-code.php" ] || fail "copied DMC entrypoint was replaced with a loader" -[ -f "$PLUGIN/HOMEBOY_DEPLOYED" ] || fail "copied DMC layout lost Homeboy files" -case "$LOG" in *"Plugin data-machine-code is not a git checkout"*) : ;; *) fail "copied DMC did not keep the git-checkout skip path" ;; esac -[ "${#BOOTSTRAPS[@]}" -eq 0 ] || fail "copied DMC triggered bootstrap" -[ "${#UPDATED_ITEMS[@]}" -eq 0 ] || fail "copied DMC recorded an update" - -git init "$PLUGIN" >/dev/null 2>&1 -LOG="" -upgrade_data_machine_plugins -case "$LOG" in *"Plugin data-machine-code is not a git checkout"*) fail "git checkout used the copied skip path" ;; esac -[ ! -e "$PLUGIN/.wp-coding-agents-releases" ] || fail "git checkout was converted to .wp-coding-agents-releases" - -rm -rf "$PLUGIN" -PLUGINS_ONLY=true -BOOTSTRAPS=() -LOG="" -upgrade_data_machine_plugins -[ "${#BOOTSTRAPS[@]}" -eq 0 ] || fail "plugin-only bootstrapped absent DMC" -case "$LOG" in *"not-installed"*) : ;; *) fail "plugin-only did not skip absent DMC" ;; esac - -PLUGINS_ONLY=false -BOOTSTRAPS=() -upgrade_data_machine_plugins -[ "${#BOOTSTRAPS[@]}" -eq 1 ] || fail "absent DMC was not bootstrapped" -[ "${BOOTSTRAPS[0]}" = data-machine-code ] || fail "bootstrap targeted the wrong plugin" -[ -d "$PLUGIN/.git" ] || fail "bootstrap did not create a git checkout" -[ -f "$PLUGIN/data-machine-code.php" ] || fail "bootstrap did not install a plugin entrypoint" -[ ! -e "$PLUGIN/.wp-coding-agents-releases" ] || fail "bootstrap created .wp-coding-agents-releases" - -echo "dmc-managed-release tests passed" diff --git a/tests/homeboy-codebox-canary.sh b/tests/homeboy-codebox-canary.sh index 1d1a229..cb57432 100755 --- a/tests/homeboy-codebox-canary.sh +++ b/tests/homeboy-codebox-canary.sh @@ -11,7 +11,7 @@ mkdir -p \ "$TMP/workspace" \ "$TMP/agents-api" \ "$TMP/data-machine" \ - "$TMP/data-machine-code" \ + "$TMP/wp-coding-agents-integration" \ "$TMP/homeboy-wordpress" \ "$TMP/provider-openai" @@ -112,7 +112,7 @@ with open(sys.argv[1], encoding="utf-8") as handle: assert config["agents_api"].endswith("/agents-api") assert config["runtime_component_paths"]["agent_runtime"].endswith("/data-machine") -assert config["runtime_component_paths"]["agent_runtime_tools"].endswith("/data-machine-code") +assert config["runtime_component_paths"]["agent_runtime_tools"].endswith("/wp-coding-agents-integration") assert config["homeboy_extensions"].endswith("/homeboy-wordpress") assert config["provider"] == "openai" assert config["model"] == "gpt-4.1-mini" @@ -147,7 +147,7 @@ OUTPUT="$TMP/output.log" --secret-env OPENAI_API_KEY \ --agents-api "$TMP/agents-api" \ --agent-runtime "$TMP/data-machine" \ - --agent-runtime-tools "$TMP/data-machine-code" \ + --agent-runtime-tools "$TMP/wp-coding-agents-integration" \ --homeboy-extensions "$TMP/homeboy-wordpress" \ --provider-plugin-path "$TMP/provider-openai" \ --model gpt-4.1-mini \ @@ -173,7 +173,7 @@ if "$SCRIPT_DIR/scripts/verify-homeboy-codebox-canary.sh" \ --secret-env 'OPENAI_API_KEY=secret' \ --agents-api "$TMP/agents-api" \ --agent-runtime "$TMP/data-machine" \ - --agent-runtime-tools "$TMP/data-machine-code" \ + --agent-runtime-tools "$TMP/wp-coding-agents-integration" \ --homeboy-extensions "$TMP/homeboy-wordpress" \ --provider-plugin-path "$TMP/provider-openai" \ > "$TMP/invalid.log" 2>&1; then diff --git a/tests/homeboy-components.sh b/tests/homeboy-components.sh index 5764556..d442a8b 100755 --- a/tests/homeboy-components.sh +++ b/tests/homeboy-components.sh @@ -1,243 +1,45 @@ #!/bin/bash -# tests/homeboy-components.sh — unit test for DMC workspace Homeboy component attachment. +# Homeboy components derive only from explicit workspace repositories. set -eu -SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" - -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/common.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/homeboy.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/data-machine.sh" - +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT -SITE_PATH="$TMP/site" -DM_WORKSPACE_DIR="$TMP/workspace" -WP_CMD="wp" -DRY_RUN=false -mkdir -p "$SITE_PATH" "$DM_WORKSPACE_DIR" - -cat > "$SITE_PATH/homeboy.json" <<'JSON' -{"id":"site-project"} -JSON +source "$ROOT_DIR/lib/common.sh" +source "$ROOT_DIR/lib/source-policy.sh" +source "$ROOT_DIR/lib/homeboy.sh" -mkdir -p \ - "$DM_WORKSPACE_DIR/alpha" \ - "$DM_WORKSPACE_DIR/beta" \ - "$DM_WORKSPACE_DIR/alpha@feature" \ - "$DM_WORKSPACE_DIR/no-metadata" - -cat > "$DM_WORKSPACE_DIR/alpha/homeboy.json" <<'JSON' -{"id":"alpha"} -JSON -cat > "$DM_WORKSPACE_DIR/beta/homeboy.json" <<'JSON' -{"id":"beta"} -JSON -cat > "$DM_WORKSPACE_DIR/alpha@feature/homeboy.json" <<'JSON' -{"id":"alpha-feature"} -JSON - -FAKE_BIN="$TMP/bin" -mkdir -p "$FAKE_BIN" -cat > "$FAKE_BIN/homeboy" <<'SH' -#!/bin/sh -if [ "$1 $2" = "project show" ]; then - if [ -n "${HOMEBOY_PROJECT_SHOW_AFTER_REMOVE_JSON:-}" ] && [ -f "${HOMEBOY_REMOVE_LOG:-}" ]; then - cat "$HOMEBOY_PROJECT_SHOW_AFTER_REMOVE_JSON" - exit 0 - fi - cat "$HOMEBOY_PROJECT_SHOW_JSON" - exit 0 -fi -if [ "$1 $2 $3" = "project components remove" ]; then - shift 3 - project_id="$1" - shift - printf '%s|%s\n' "$project_id" "$*" >> "$HOMEBOY_REMOVE_LOG" - if [ "${HOMEBOY_REMOVE_STATUS:-0}" = 0 ]; then - printf '{"success":true}\n' - exit 0 - fi - printf '{"success":false,"error":{"message":"validation failed after partial mutation"}}\n' - exit "$HOMEBOY_REMOVE_STATUS" -fi -if [ "$1 $2 $3" = "project components attach-path" ]; then - printf '%s|%s\n' "$4" "$5" >> "$HOMEBOY_ATTACH_LOG" - if [ "${HOMEBOY_ATTACH_STATUS:-0}" = 0 ]; then - exit 0 - fi - printf '{"success":true,"warning":"non-zero status with success payload"}\n' - exit "$HOMEBOY_ATTACH_STATUS" -fi -exit 2 -SH -chmod +x "$FAKE_BIN/homeboy" - -cat > "$FAKE_BIN/sudo" <<'SH' +SITE_PATH="$TMP/site" +PRIMARY="$TMP/primary" +WORKTREE="$TMP/primary@task" +MISSING="$TMP/missing" +mkdir -p "$SITE_PATH" "$PRIMARY" "$WORKTREE" "$TMP/bin" +git -C "$PRIMARY" init -q +printf '{"id":"site"}\n' > "$SITE_PATH/homeboy.json" + +cat > "$TMP/bin/homeboy" <<'SH' #!/bin/sh -printf '%s\n' "$*" >> "$SUDO_LOG" - -while [ "$#" -gt 0 ]; do - case "$1" in - -n|-H) - shift - ;; - -u) - shift 2 - ;; - env) - shift - while [ "$#" -gt 0 ]; do - case "$1" in - *=*) export "$1"; shift ;; - *) break ;; - esac - done - exec "$@" - ;; - *) - shift - ;; - esac -done - -exit 2 +printf '%s\n' "$*" >> "$HOMEBOY_LOG" +exit 0 SH -chmod +x "$FAKE_BIN/sudo" - -HOMEBOY_ATTACH_LOG="$TMP/attached.log" -HOMEBOY_REMOVE_LOG="$TMP/removed.log" -HOMEBOY_PROJECT_SHOW_JSON="$TMP/project-show.json" -HOMEBOY_PROJECT_SHOW_AFTER_REMOVE_JSON="$TMP/project-show-after-remove.json" -HOMEBOY_ATTACH_STATUS=0 -HOMEBOY_REMOVE_STATUS=1 -export HOMEBOY_ATTACH_LOG HOMEBOY_REMOVE_LOG HOMEBOY_PROJECT_SHOW_JSON HOMEBOY_PROJECT_SHOW_AFTER_REMOVE_JSON HOMEBOY_ATTACH_STATUS HOMEBOY_REMOVE_STATUS -PATH="$FAKE_BIN:$PATH" - -write_project_show_json() { - cat > "$HOMEBOY_PROJECT_SHOW_JSON" < "$HOMEBOY_PROJECT_SHOW_AFTER_REMOVE_JSON" < "$TMP/output.log" - -assert_contains "site-project|$DM_WORKSPACE_DIR/alpha" "$HOMEBOY_ATTACH_LOG" -assert_contains "site-project|$DM_WORKSPACE_DIR/beta" "$HOMEBOY_ATTACH_LOG" -assert_not_contains "alpha@feature" "$HOMEBOY_ATTACH_LOG" -assert_not_contains "no-metadata" "$HOMEBOY_ATTACH_LOG" -assert_contains "site-project|alpha-feature no-metadata" "$HOMEBOY_REMOVE_LOG" -assert_not_contains "external" "$HOMEBOY_REMOVE_LOG" - -assert_contains "pruned stale Homeboy component(s): alpha-feature no-metadata" "$TMP/output.log" -assert_contains "skipped alpha@feature: worktree skipped" "$TMP/output.log" -assert_contains "skipped no-metadata: no homeboy.json" "$TMP/output.log" -assert_contains "Homeboy component sync complete: 2 attached, 2 skipped, 0 failed" "$TMP/output.log" - -HOMEBOY_ATTACH_STATUS=4 -HOMEBOY_ATTACH_LOG="$TMP/nonzero-success-attached.log" -HOMEBOY_REMOVE_LOG="$TMP/nonzero-success-removed.log" -export HOMEBOY_ATTACH_STATUS HOMEBOY_ATTACH_LOG HOMEBOY_REMOVE_LOG -sync_homeboy_project_components > "$TMP/nonzero-success-output.log" - -assert_contains "site-project|$DM_WORKSPACE_DIR/alpha" "$HOMEBOY_ATTACH_LOG" -assert_contains "site-project|$DM_WORKSPACE_DIR/beta" "$HOMEBOY_ATTACH_LOG" -assert_contains "Homeboy component sync complete: 2 attached, 2 skipped, 0 failed" "$TMP/nonzero-success-output.log" - -HOMEBOY_ATTACH_STATUS=0 - -DRY_RUN=true -HOMEBOY_ATTACH_LOG="$TMP/dry-run-attached.log" -HOMEBOY_REMOVE_LOG="$TMP/dry-run-removed.log" -export HOMEBOY_ATTACH_LOG HOMEBOY_REMOVE_LOG -sync_homeboy_project_components > "$TMP/dry-run-output.log" - -if [ -f "$HOMEBOY_ATTACH_LOG" ]; then - echo "FAIL: dry-run should not call homeboy attach-path" - cat "$HOMEBOY_ATTACH_LOG" - exit 1 -fi -assert_contains "homeboy project components attach-path site-project $DM_WORKSPACE_DIR/alpha" "$TMP/dry-run-output.log" -assert_contains "homeboy project components attach-path site-project $DM_WORKSPACE_DIR/beta" "$TMP/dry-run-output.log" -assert_contains "homeboy project components remove site-project alpha-feature no-metadata" "$TMP/dry-run-output.log" -if [ -f "$HOMEBOY_REMOVE_LOG" ]; then - echo "FAIL: dry-run should not call homeboy components remove" - cat "$HOMEBOY_REMOVE_LOG" - exit 1 -fi - -cat > "$SITE_PATH/homeboy.json" <<'JSON' -{} -JSON +chmod +x "$TMP/bin/homeboy" + +PATH="$TMP/bin:$PATH" +export PATH +HOMEBOY_LOG="$TMP/homeboy.log" +export HOMEBOY_LOG +WORKSPACE_REPOSITORIES="$PRIMARY:$WORKTREE:$MISSING:$SITE_PATH" +SOURCE_MODE=workspace DRY_RUN=false -HOMEBOY_ATTACH_LOG="$TMP/empty-id-attached.log" -HOMEBOY_REMOVE_LOG="$TMP/empty-id-removed.log" -export HOMEBOY_ATTACH_LOG HOMEBOY_REMOVE_LOG -sync_homeboy_project_components > "$TMP/empty-id-output.log" +log() { :; } +warn() { :; } -if [ -f "$HOMEBOY_ATTACH_LOG" ]; then - echo "FAIL: empty project id should not call homeboy attach-path" - cat "$HOMEBOY_ATTACH_LOG" +sync_homeboy_project_components +grep -qxF "project components attach-path site $PRIMARY" "$HOMEBOY_LOG" +if grep -Fq "$WORKTREE" "$HOMEBOY_LOG" || grep -Fq "$MISSING" "$HOMEBOY_LOG" || grep -Fq "$SITE_PATH" "$HOMEBOY_LOG"; then + echo "FAIL: Homeboy attached a non-primary declared path" >&2 exit 1 fi -assert_contains "Homeboy project config not found at site root — skipping DMC component attachment" "$TMP/empty-id-output.log" - -cat > "$SITE_PATH/homeboy.json" <<'JSON' -{"id":"site-project"} -JSON -DRY_RUN=false -SERVICE_USER="opencode" -SERVICE_HOME="$TMP/opencode-home" -WP_CODING_AGENTS_TEST_ASSUME_ROOT=true -HOMEBOY_ATTACH_LOG="$TMP/service-user-attached.log" -HOMEBOY_REMOVE_LOG="$TMP/service-user-removed.log" -SUDO_LOG="$TMP/sudo.log" -export HOMEBOY_ATTACH_LOG HOMEBOY_REMOVE_LOG SUDO_LOG SERVICE_USER SERVICE_HOME WP_CODING_AGENTS_TEST_ASSUME_ROOT - -sync_homeboy_project_components > "$TMP/service-user-output.log" - -assert_contains "site-project|alpha-feature no-metadata" "$HOMEBOY_REMOVE_LOG" -assert_contains "site-project|$DM_WORKSPACE_DIR/alpha" "$HOMEBOY_ATTACH_LOG" -assert_contains "site-project|$DM_WORKSPACE_DIR/beta" "$HOMEBOY_ATTACH_LOG" -assert_contains "-n -H -u opencode env HOME=$SERVICE_HOME" "$SUDO_LOG" -assert_contains "PATH=" "$SUDO_LOG" -assert_contains "homeboy project components remove site-project alpha-feature no-metadata" "$SUDO_LOG" -assert_contains "homeboy project components attach-path site-project $DM_WORKSPACE_DIR/alpha" "$SUDO_LOG" -echo "OK: Homeboy component attachment prunes stale worktrees and skips metadata-less repos" +echo "PASS: Homeboy components use explicit repository authority" diff --git a/tests/homeboy-verification-guidance.sh b/tests/homeboy-verification-guidance.sh index 84fccb3..4ae2ac1 100644 --- a/tests/homeboy-verification-guidance.sh +++ b/tests/homeboy-verification-guidance.sh @@ -40,7 +40,7 @@ assert_contract_command() { assert_contract_command "homeboy --version" assert_contract_command "homeboy extension list" assert_contract_command "homeboy extension show wordpress" -assert_contract_command "homeboy config show /worktree_providers/dmc # expected: not found" +assert_contract_command "homeboy config show --format=json | jq -e '.data.config.worktree_providers.dmc == null and .data.config.settings.worktree_provider_lifecycle.dmc == null'" assert_contract_command "homeboy project show " assert_contract_command "homeboy project components list " assert_contract_command "wp datamachine memory compose AGENTS.md --path=/path/to/site" diff --git a/tests/homeboy-worktree-adapter.sh b/tests/homeboy-worktree-adapter.sh deleted file mode 100644 index 6ef41f8..0000000 --- a/tests/homeboy-worktree-adapter.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/bin/bash -set -eu - -SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/common.sh" -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/homeboy.sh" - -SITE_PATH="$TMP/site" -WORKSPACE="$TMP/workspace" -FAKE_BIN="$TMP/bin" -LOG="$TMP/homeboy.log" -mkdir -p "$SITE_PATH/wp-content/mu-plugins" "$SITE_PATH/wp-content/plugins/data-machine-code" "$WORKSPACE/fixture/.git" "$FAKE_BIN" -WORKSPACE_REAL="$(cd "$WORKSPACE" && pwd -P)" - -cat > "$FAKE_BIN/homeboy" <<'SH' -#!/bin/bash -printf '%s\n' "$*" >> "$HOMEBOY_ADAPTER_TEST_LOG" -case "$*" in - "config show /worktree_providers/dmc") - [ -f "$HOMEBOY_PROVIDER_STATE" ] || exit 1 - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"value":{"kind":"command"}}}' - ;; - "config show /settings/worktree_provider_lifecycle/dmc") - [ -f "$HOMEBOY_FINALIZER_STATE" ] || exit 1 - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"value":{"finalize":["legacy"]}}}' - ;; - "config remove /worktree_providers/dmc") - rm -f "$HOMEBOY_PROVIDER_STATE" - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{}}' - ;; - "config remove /settings/worktree_provider_lifecycle/dmc") - rm -f "$HOMEBOY_FINALIZER_STATE" - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{}}' - ;; - "worktree create "*"--run-id wrong-run") - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"create","record":{"id":"fixture@wrong-run","component_id":"fixture","worktree_path":"/workspace/fixture@wrong-run","branch":"fix/wrong-run","base_ref":"origin/main","run_id":"other-run","state":"active"},"handoff_freshness":{"status":"verified","proof":{"schema":"homeboy/worktree-handoff-freshness/v1","proof_id":"proof-wrong-run","handle":"fixture@wrong-run","worktree_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","resolved_base_ref":"origin/main","resolved_base_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","remote_default_ref":"refs/remotes/origin/main","remote_default_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","remote_default_advertised_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified_at":"2026-08-30T00:00:00Z"}}}}' - ;; - "worktree create "*) - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"create","record":{"id":"fixture@fix-474","component_id":"fixture","worktree_path":"/workspace/fixture@fix-474","branch":"fix/474","base_ref":"origin/main","task_url":"https://example.test/474","run_id":"run-474","cleanup_policy":"remove_when_safe","created_at":"2026-08-30T00:00:00Z","state":"active"},"handoff_freshness":{"status":"verified","proof":{"schema":"homeboy/worktree-handoff-freshness/v1","proof_id":"proof-474","handle":"fixture@fix-474","worktree_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","resolved_base_ref":"origin/main","resolved_base_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","remote_default_ref":"refs/remotes/origin/main","remote_default_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","remote_default_advertised_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verified_at":"2026-08-30T00:00:00Z"}}}}' - ;; - "worktree list") - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"list","worktrees":[{"id":"fixture@fix-474","component_id":"fixture","worktree_path":"/workspace/fixture@fix-474","branch":"fix/474","base_ref":"origin/main","task_url":"https://example.test/474","run_id":"run-474","created_at":"2026-08-30T00:00:00Z","state":"active"},{"id":"fixture@removed","component_id":"fixture","worktree_path":"/workspace/fixture@removed","branch":"removed","state":"removed"}]}}' - ;; - "worktree status "*) - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"status","record":{"id":"fixture@fix-474","component_id":"fixture","worktree_path":"/workspace/fixture@fix-474","branch":"fix/474","run_id":"run-474","state":"active"},"safety":{"dirty":false,"safe":true,"primary_checkout":false,"unpushed_commits":0}}}' - ;; - "worktree finalize "*) - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"finalize","provider_id":"builtin","handle":"fixture@fix-474","disposition":"succeeded","owner_outcome":"success","lifecycle_state":"completed","inspection_path":"/workspace/fixture@fix-474"}}' - ;; - "worktree cleanup"|"worktree cleanup --apply"|"worktree cleanup --force"|"worktree cleanup --apply --force") - dry_run=true - case "$*" in *--apply*) dry_run=false ;; esac - printf '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"cleanup","worktrees":{"dry_run":%s,"counts":{"candidates":1},"candidates":[{"id":"fixture@fix-474"}],"removed":[],"skipped":[]}}}\n' "$dry_run" - ;; - "worktree remove "*) - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":true,"data":{"action":"remove","record":{"id":"fixture@fix-474","state":"removed"}}}' - ;; - "component list") - cat "$HOMEBOY_COMPONENTS_FILE" - ;; - *) - printf '%s\n' '{"schema":"homeboy/command-result/v3","success":false,"error":{"message":"unexpected fixture command"}}' - exit 1 - ;; -esac -SH -chmod +x "$FAKE_BIN/homeboy" - -PATH="$FAKE_BIN:$PATH" -export PATH HOMEBOY_ADAPTER_TEST_LOG="$LOG" -export HOMEBOY_PROVIDER_STATE="$TMP/provider-state" HOMEBOY_FINALIZER_STATE="$TMP/finalizer-state" -UPDATED_ITEMS=() -DRY_RUN=false -BLUE="" -NC="" -service_file_normalize_perms() { chmod 0664 "$1"; } -homeboy_worktree_adapter_sync -ADAPTER="$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" -php -l "$ADAPTER" >/dev/null - -cat > "$TMP/adapter-harness.php" <<'PHP' - 'original'), 'datamachine-code/workspace-show'); -expect('original' === $untouched['execute_callback'], 'non-worktree ability remains unchanged'); -$native = $callback(array('execute_callback' => 'dmc', 'meta' => array('show_in_rest' => false)), 'datamachine-code/workspace-worktree-reconcile-metadata'); -expect('dmc' === $native['execute_callback'], 'DMC-only worktree ability retains its native callback'); -expect(!isset($native['meta']['worktree_lifecycle_owner']), 'DMC-only worktree ability does not claim Homeboy ownership'); - -$ability = static function (string $slug) use ($callback): callable { - $args = $callback(array('execute_callback' => 'dmc', 'meta' => array('show_in_rest' => false)), $slug); - expect('homeboy' === ($args['meta']['worktree_lifecycle_owner'] ?? null), "{$slug} declares Homeboy ownership"); - expect(is_callable($args['execute_callback']), "{$slug} callback replaced"); - return $args['execute_callback']; -}; - -$native_add = static fn(array $input): array => array('success' => true, 'backend' => 'dmc', 'input' => $input); -$add_args = $callback(array('execute_callback' => $native_add, 'meta' => array('show_in_rest' => false)), 'datamachine-code/workspace-worktree-add'); -$add = $add_args['execute_callback']; -$native = $add(array('repo' => 'fixture', 'branch' => 'fix/native')); -expect('dmc' === ($native['backend'] ?? null), 'default context and bootstrap behavior retains DMC native creation'); -$native_option = $add(array('repo' => 'fixture', 'branch' => 'fix/native-option', 'inject_context' => false, 'bootstrap' => false, 'allow_stale' => true)); -expect('dmc' === ($native_option['backend'] ?? null), 'DMC-only create options retain DMC native creation'); -$verified = $add(array('repo' => 'fixture', 'branch' => 'fix/474', 'owner_run_ref' => 'run-474', 'inject_context' => false, 'bootstrap' => false)); -expect(!is_wp_error($verified) && 'verified' === ($verified['handoff_freshness']['status'] ?? null), 'create maps Homeboy remote freshness into the DMC handoff contract'); -$created = $add(array('repo' => 'fixture', 'branch' => 'fix/474', 'from' => 'origin/main', 'task_url' => 'https://example.test/474', 'owner_run_ref' => 'run-474', 'cleanup_policy' => 'remove_on_success', 'allow_unverified_freshness' => true, 'inject_context' => false, 'bootstrap' => false)); -expect(!is_wp_error($created) && 'fixture@fix-474' === $created['handle'], 'create projects native Homeboy record'); -expect('run-474' === ($created['metadata']['owner_run_ref'] ?? null), 'create preserves its explicit owner-run reference for audit'); -expect('verified' === ($created['handoff_freshness']['status'] ?? null), 'create retains available Homeboy freshness when unverified fallback is allowed'); -$manual = $add(array('repo' => 'fixture', 'branch' => 'fix/474-manual', 'owner_run_ref' => 'run-474', 'cleanup_policy' => 'manual', 'allow_unverified_freshness' => true, 'inject_context' => false, 'bootstrap' => false)); -expect(!is_wp_error($manual), 'manual lifecycle intent maps to non-cleanup-eligible Homeboy policy'); -expect(is_wp_error($add(array('repo' => 'fixture', 'branch' => 'missing-owner', 'inject_context' => false, 'bootstrap' => false))), 'Homeboy create refuses a worktree without an explicit owner-run reference'); -expect(is_wp_error($add(array('repo' => 'fixture', 'branch' => 'wrong-run', 'owner_run_ref' => 'wrong-run', 'inject_context' => false, 'bootstrap' => false))), 'Homeboy create refuses a result with a different owner-run reference'); - -$list = $ability('datamachine-code/workspace-worktree-list'); -$listed = $list(array('handle' => 'fixture@fix-474', 'include_status' => true, 'limit' => 1)); -expect(!is_wp_error($listed) && 1 === $listed['returned'], 'exact list returns one Homeboy record'); -expect(0 === $listed['worktrees'][0]['dirty'], 'status safety projects into canonical dirty count'); -$all = $list(array('all' => true)); -expect(1 === $all['returned'] && 'fixture@fix-474' === $all['worktrees'][0]['handle'], 'removed Homeboy records do not reappear through DMC inventory'); - -$finalize = $ability('datamachine-code/workspace-worktree-finalize'); -$finalized = $finalize(array('handle' => 'fixture@fix-474', 'state' => 'merged')); -expect(!is_wp_error($finalized) && 'merged' === $finalized['lifecycle_state'], 'terminal finalization preserves the canonical DMC state'); -expect(is_wp_error($finalize(array('handle' => 'fixture@fix-474', 'state' => 'active'))), 'nonterminal finalization is refused'); - -$cleanup = $ability('datamachine-code/workspace-worktree-cleanup'); -expect(true === $cleanup(array('dry_run' => true))['dry_run'], 'cleanup preview remains non-mutating'); -expect(false === $cleanup(array('dry_run' => false))['dry_run'], 'cleanup apply uses Homeboy apply mode'); -expect(false === $cleanup(array())['dry_run'], 'canonical cleanup default remains apply mode'); -expect(false === $cleanup(array('force' => true))['dry_run'], 'cleanup force remains Homeboy-owned and explicit'); - -$remove = $ability('datamachine-code/workspace-worktree-remove'); -expect(true === $remove(array('repo' => 'fixture', 'branch' => 'fix/474'))['success'], 'remove uses Homeboy safety path'); - -$root = __DIR__ . '/dmc/blocks-engine@fix-543'; -mkdir($root . '/php-transformer', 0777, true); -$registered = __DIR__ . '/registered/not-blocks-engine/php-transformer'; -mkdir($registered, 0777, true); -foreach (array(dirname($root), dirname($registered)) as $repository) { - passthru('git init -q ' . escapeshellarg($repository)); - passthru('git -C ' . escapeshellarg($repository) . ' remote add origin git@github.com:Extra-Chill/blocks-engine.git'); -} -$workspace = array('repository_prefix' => 'blocks-engine', 'workspace_path' => $root, 'component' => array('registered' => false)); -putenv('HOMEBOY_COMPONENTS_FILE=' . __DIR__ . '/components.json'); -$components = static function (array $components): void { file_put_contents(__DIR__ . '/components.json', json_encode(array('schema' => 'homeboy/command-result/v3', 'success' => true, 'data' => array('entities' => $components)))); }; -passthru('git -C ' . escapeshellarg(dirname($registered)) . ' remote set-url origin https://github.com/Extra-Chill/blocks-engine.git'); -$components(array(array('id' => 'php-transformer', 'local_path' => $registered))); -$destination = WP_Coding_Agents_Homeboy_Worktrees::cook_destination($workspace); -expect(!is_wp_error($destination), 'DMC workspace result resolves to a Cook destination'); -expect('github.com/extra-chill/blocks-engine' === $destination['canonical_remote'], 'resolver canonicalizes SSH and HTTPS component remotes'); -expect('blocks-engine' === $destination['repository'] && 'php-transformer' === $destination['component'], 'resolver separates owning repository from registered execution component'); -expect($root . '/php-transformer' === $destination['cwd'], 'resolver maps the registered component path into the DMC workspace'); -expect(array('agent-task', 'cook', '--repo', 'blocks-engine', '--component', 'php-transformer', '--cwd', $root . '/php-transformer') === array_slice($destination['cook']['argv'], 1), 'resolver emits exact replayable Cook argv'); -$components(array()); -$root_only = WP_Coding_Agents_Homeboy_Worktrees::cook_destination($workspace); -expect(!is_wp_error($root_only) && null === $root_only['component'] && $root === $root_only['cwd'], 'root-only repository remains a root Cook destination'); -$components(array(array('id' => 'one', 'local_path' => $registered), array('id' => 'two', 'local_path' => $registered))); -expect(is_wp_error(WP_Coding_Agents_Homeboy_Worktrees::cook_destination($workspace)), 'ambiguous registered components fail closed'); -passthru('git init -q ' . escapeshellarg($root . '/php-transformer')); -passthru('git -C ' . escapeshellarg($root . '/php-transformer') . ' remote add origin git@github.com:Extra-Chill/other.git'); -$components(array(array('id' => 'php-transformer', 'local_path' => $registered))); -expect(is_wp_error(WP_Coding_Agents_Homeboy_Worktrees::cook_destination($workspace)), 'canonical remote mismatch fails closed'); - -PHP -php "$TMP/adapter-harness.php" "$ADAPTER" - -if grep -q 'datamachine-code' "$LOG"; then - echo "FAIL: adapter re-entered DMC worktree commands" >&2 - cat "$LOG" >&2 - exit 1 -fi -grep -q "^worktree create $WORKSPACE_REAL/fixture " "$LOG" -grep -q "^worktree create $WORKSPACE_REAL/fixture --branch fix/474 --require-handoff-freshness --from origin/HEAD --run-id run-474$" "$LOG" -grep -q "^worktree create $WORKSPACE_REAL/fixture --branch fix/474-manual --from origin/HEAD --run-id run-474 --cleanup-policy preserve-on-failure$" "$LOG" -grep -q '^worktree finalize fixture@fix-474 --owner-run-ref run-474 --disposition succeeded$' "$LOG" -grep -q '^worktree cleanup --apply$' "$LOG" - -DRY_RUN=true -configure_homeboy_worktree_ownership > "$TMP/dry-run.log" -grep -q 'homeboy config remove /worktree_providers/dmc' "$TMP/dry-run.log" -grep -q 'homeboy config remove /settings/worktree_provider_lifecycle/dmc' "$TMP/dry-run.log" - -touch "$HOMEBOY_PROVIDER_STATE" "$HOMEBOY_FINALIZER_STATE" -DRY_RUN=false -configure_homeboy_worktree_ownership > "$TMP/apply.log" -[ ! -e "$HOMEBOY_PROVIDER_STATE" ] || { echo "FAIL: circular DMC provider remained configured" >&2; exit 1; } -[ ! -e "$HOMEBOY_FINALIZER_STATE" ] || { echo "FAIL: legacy DMC finalizer remained configured" >&2; exit 1; } - -echo "OK: DMC worktree abilities use Homeboy without circular provider ownership" diff --git a/tests/installation-profile.sh b/tests/installation-profile.sh index 3aa0519..ac0342d 100644 --- a/tests/installation-profile.sh +++ b/tests/installation-profile.sh @@ -7,9 +7,13 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT source "$ROOT_DIR/lib/desired-state-reconciler.sh" +source "$ROOT_DIR/lib/source-policy.sh" SITE_PATH="$TMP/site" -mkdir -p "$SITE_PATH" +WORKSPACE="$TMP/workspace repository" +mkdir -p "$SITE_PATH" "$WORKSPACE/subdirectory" "$TMP/not-a-repository" +git -C "$WORKSPACE" init -q +WORKSPACE="$(cd "$WORKSPACE" && pwd -P)" LOCAL_MODE=true EXTERNAL_WORDPRESS=false IS_STUDIO=true @@ -17,7 +21,7 @@ SOURCE_MODE=workspace RUNTIME=opencode CHAT_BRIDGE=kimaki HOMEBOY_MODE=enabled -WORKSPACE_REPOSITORIES="/tmp/workspace-repository" +WORKSPACE_REPOSITORIES="$WORKSPACE" INSTALL_CHAT=true DRY_RUN=false KIMAKI_BOT_TOKEN='must-not-be-persisted' @@ -39,7 +43,15 @@ if grep -Eq 'TOKEN|TRANSPORT|secret|must-not-be-persisted' "$PROFILE"; then echo "FAIL: profile persisted credential or command transport material" >&2 exit 1 fi -grep -Eq '^plugin_candidates=.*data-machine-code' "$PROFILE" +grep -Eq '^plugin_candidates=data-machine wp-codebox$' "$PROFILE" +if grep -q 'data-machine-code\|DATAMACHINE_WORKSPACE_PATH' "$ROOT_DIR/lib/data-machine.sh"; then + echo "FAIL: setup Data Machine phase still owns DMC installation state" >&2 + exit 1 +fi +if grep -q 'discover_dm_workspace_dir\|wp datamachine-code' "$ROOT_DIR/setup.sh"; then + echo "FAIL: setup still discovers a DMC workspace" >&2 + exit 1 +fi SOURCE_MODE="" RUNTIME="" @@ -53,7 +65,35 @@ test "$SOURCE_MODE" = workspace test "$RUNTIME" = opencode test "$CHAT_BRIDGE" = kimaki test "$HOMEBOY_MODE" = enabled -test "$WORKSPACE_REPOSITORIES" = /tmp/workspace-repository +test "$WORKSPACE_REPOSITORIES" = "$WORKSPACE" + +WORKSPACE_REPOSITORIES="" +source_policy_add_workspace_repository "$WORKSPACE/subdirectory" +test "$WORKSPACE_REPOSITORIES" = "$WORKSPACE" +# The physical primary checkout root is canonical authority, so repeated flags +# and subdirectories cannot create duplicate runtime grants. +source_policy_add_workspace_repository "$WORKSPACE" +test "$WORKSPACE_REPOSITORIES" = "$WORKSPACE" +SOURCE_MODE=workspace +source_policy_validate_workspace_repositories +SOURCE_MODE=owned +if source_policy_validate_workspace_repositories; then + echo "FAIL: owned mode accepted declared workspace repositories" >&2 + exit 1 +fi +SOURCE_MODE=workspace +if source_policy_add_workspace_repository relative-repository; then + echo "FAIL: relative workspace repository was accepted" >&2 + exit 1 +fi +if source_policy_add_workspace_repository "$TMP/not-a-repository"; then + echo "FAIL: non-Git workspace repository was accepted" >&2 + exit 1 +fi +if source_policy_add_workspace_repository "$TMP/missing-repository"; then + echo "FAIL: missing workspace repository was accepted" >&2 + exit 1 +fi test "$INSTALL_CHAT" = true # Explicit command-line intent remains authoritative over persisted defaults. diff --git a/tests/integration-adapters.sh b/tests/integration-adapters.sh index d855ed7..24ffe9f 100644 --- a/tests/integration-adapters.sh +++ b/tests/integration-adapters.sh @@ -56,7 +56,7 @@ BLUE=""; NC="" mkdir -p "$SITE_PATH/wp-content/plugins/data-machine-code" printf 'copied\n' > "$SITE_PATH/wp-content/plugins/data-machine-code/HOMEBOY_DEPLOYED" mkdir -p "$SITE_PATH/wp-content/mu-plugins" -printf 'stale\n' > "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" +printf '%s\n' ' "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" INSTALLATION_PROFILE_OPERATION=setup integration_adapters_detect @@ -113,14 +113,22 @@ chmod +x "$TMP/homeboy" PATH="$TMP:/usr/bin:/bin" homeboy_wordpress_extension_ready() { return 1; } homeboy_run() { - printf '%s\n' '{"schema":"homeboy/command-result/v3","data":{"config":{"worktree_providers":{"dmc":{}}}}}' + printf '%s\n' '{"schema":"homeboy/command-result/v3","data":{"config":{"worktree_providers":{"dmc":{}},"settings":{"worktree_provider_lifecycle":{}}}}}' } if _integration_adapter_verify_homeboy; then fail "nested DMC provider passed Homeboy verification"; fi homeboy_run() { - printf '%s\n' '{"schema":"homeboy/command-result/v3","data":{"config":{"worktree_providers":{}}}}' + printf '%s\n' '{"schema":"homeboy/command-result/v3","data":{"config":{"worktree_providers":{},"settings":{"worktree_provider_lifecycle":{"dmc":{}}}}}}' +} +if _integration_adapter_verify_homeboy; then fail "nested DMC lifecycle passed Homeboy verification"; fi +homeboy_run() { + printf '%s\n' '{"schema":"homeboy/command-result/v3","data":{"config":{"worktree_providers":{},"settings":{"worktree_provider_lifecycle":{}}}}}' } _integration_adapter_verify_homeboy || fail "clean Homeboy envelope failed verification" +printf 'operator-owned managed release\n' > "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" +if _integration_adapter_cleanup_managed_release; then fail "unknown managed release cleanup reported success"; fi +[ -e "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-dmc-managed-release.php" ] || fail "unknown managed release was removed" + # Retired-option cleanup must fail closed when WordPress cannot confirm absence. wp_cmd() { return 1; } if _integration_adapter_verify_retired_homeboy_option; then fail "unverified retired Homeboy option cleanup passed"; fi diff --git a/tests/opencode-local-plugin-path.sh b/tests/opencode-local-plugin-path.sh index 8717f6d..908ffdc 100755 --- a/tests/opencode-local-plugin-path.sh +++ b/tests/opencode-local-plugin-path.sh @@ -8,7 +8,9 @@ trap 'rm -rf "$TMP"' EXIT SITE_PATH="$TMP/site" KIMAKI_DATA_DIR="$TMP/kimaki-data" -mkdir -p "$SITE_PATH" "$KIMAKI_DATA_DIR" +WORKSPACE_REPOSITORY="$TMP/workspace" +mkdir -p "$SITE_PATH" "$KIMAKI_DATA_DIR" "$WORKSPACE_REPOSITORY" +git -C "$WORKSPACE_REPOSITORY" init -q export SCRIPT_DIR export SITE_PATH @@ -18,7 +20,7 @@ export LOCAL_MODE=true export DRY_RUN=false export OPENCODE_MODEL="" export OPENCODE_SMALL_MODEL="" -export DM_WORKSPACE_DIR="$TMP/workspace" +export WORKSPACE_REPOSITORIES="$WORKSPACE_REPOSITORY" export DM_AGENT_FILES="wp-content/uploads/datamachine-files/shared/SITE.md" export WITH_CLAUDE_CODE_AUTH=true export RUNTIME="opencode" @@ -37,12 +39,12 @@ source "$SCRIPT_DIR/runtimes/opencode.sh" runtime_generate_config -python3 - "$SITE_PATH/opencode.json" "$KIMAKI_DATA_DIR" <<'PY' +python3 - "$SITE_PATH/opencode.json" "$KIMAKI_DATA_DIR" "$WORKSPACE_REPOSITORY" <<'PY' import json import os import sys -opencode_json, kimaki_data_dir = sys.argv[1], sys.argv[2] +opencode_json, kimaki_data_dir, workspace_repository = sys.argv[1:] with open(opencode_json, encoding="utf-8") as handle: data = json.load(handle) @@ -57,8 +59,7 @@ if actual != expected: raise SystemExit(f"unexpected local plugin paths: {actual}") external = data.get("permission", {}).get("external_directory", {}) -expected_workspace = f"{opencode_json.rsplit('/', 1)[0]}/../workspace" -expected_workspace = os.path.normpath(expected_workspace) + "/**" +expected_workspace = os.path.normpath(workspace_repository) + "/**" if external != {expected_workspace: "allow"}: raise SystemExit(f"unexpected workspace grant: {external}") diff --git a/tests/owned-source-discovery.sh b/tests/owned-source-discovery.sh index 31e8718..c397269 100755 --- a/tests/owned-source-discovery.sh +++ b/tests/owned-source-discovery.sh @@ -75,7 +75,6 @@ make_site() { plugins akismet plugins data-machine plugins data-machine-business -plugins data-machine-code plugins fluent-smtp plugins h44-core plugins h44-forms @@ -121,7 +120,7 @@ refute_line "$DERIVED" "wp-content/themes/twentytwentyfive" "excludes a wp.org b # The agent's own runtime is replaced wholesale by the next upgrade, and # capturing it would commit this project's source into the site's repository. -for p in data-machine data-machine-code wp-codebox wp-coding-agents-integration ai-provider-for-claude-code; do +for p in data-machine wp-codebox wp-coding-agents-integration ai-provider-for-claude-code; do refute_line "$DERIVED" "wp-content/plugins/$p" "excludes carried plugin $p" done diff --git a/tests/plugin-upgrade-bounds.sh b/tests/plugin-upgrade-bounds.sh index 02991d0..cfd28ca 100755 --- a/tests/plugin-upgrade-bounds.sh +++ b/tests/plugin-upgrade-bounds.sh @@ -29,9 +29,9 @@ slow="$ROOT_DIR/tests/fixtures/plugin-updates/slow.sh" hung="$ROOT_DIR/tests/fixtures/plugin-updates/hung.sh" # WordPress Studio may emit a PHP preamble on stdout before valid WP-CLI JSON. -plugin_update_state_from_json $'\nDeprecated: fixture warning\n[{"name":"data-machine-code","status":"active","version":"1.2.3"}]' data-machine-code || fail "preamble-bearing plugin JSON was refused" +plugin_update_state_from_json $'\nDeprecated: fixture warning\n[{"name":"data-machine","status":"active","version":"1.2.3"}]' data-machine || fail "preamble-bearing plugin JSON was refused" [ "$PLUGIN_STATE_TUPLE" = $'1.2.3\tactive' ] || fail "preamble-bearing plugin JSON returned the wrong state" -if plugin_update_state_from_json $'Deprecated: no JSON follows' data-machine-code; then +if plugin_update_state_from_json $'Deprecated: no JSON follows' data-machine; then fail "malformed plugin output was accepted" fi @@ -59,9 +59,9 @@ if kill -0 "$child_pid" 2>/dev/null; then fail "timed-out fixture child $child_p # A timeout during plugin_update_execute reports partial failure and leaves a # copied DMC install byte/layout unchanged. -PLUGIN="$SITE_PATH/wp-content/plugins/data-machine-code" +PLUGIN="$SITE_PATH/wp-content/plugins/data-machine" mkdir -p "$PLUGIN/inc" -printf ' "$PLUGIN/data-machine-code.php" +printf ' "$PLUGIN/data-machine.php" printf 'homeboy-copied-bytes\n' > "$PLUGIN/inc/payload.txt" before="$(python3 - "$PLUGIN" <<'PY' import hashlib, os, sys @@ -80,20 +80,20 @@ for dirpath, dirs, files in os.walk(root, followlinks=False): print(digest.hexdigest()) PY )" -wp_cmd() { printf '[{"name":"data-machine-code","status":"active","version":"1.0.0"}]\n'; } -hung_update() { plugin_update_run_phase data-machine-code copied-skip "$hung"; } +wp_cmd() { printf '[{"name":"data-machine","status":"active","version":"1.0.0"}]\n'; } +hung_update() { plugin_update_run_phase data-machine copied-skip "$hung"; } LOG="" PLUGIN_UPDATE_STARTED_AT="$(date +%s)" PLUGIN_UPDATE_FAILURES=() PENDING_ITEMS=() -if plugin_update_execute data-machine-code hung_update; then +if plugin_update_execute data-machine hung_update; then fail "hung apply completed" else status=$? fi [ "$status" -eq "$PLUGIN_UPDATE_EXIT_PARTIAL" ] || fail "partial apply did not return typed partial status" -plugin_update_verify_installed_plugins data-machine-code || true +plugin_update_verify_installed_plugins data-machine || true after="$(python3 - "$PLUGIN" <<'PY' import hashlib, os, sys root = sys.argv[1] @@ -122,11 +122,11 @@ PLUGIN_UPDATE_PHASE_TIMEOUT_SECONDS=1 PLUGIN_UPDATE_STARTED_AT="$(date +%s)" reconciler_fixture_mutating_timeout() { PLUGIN_UPDATE_MUTATED=true - plugin_update_run_phase data-machine-code reconciler-timeout "$hung" + plugin_update_run_phase data-machine reconciler-timeout "$hung" } -reconciler_fixture_apply() { plugin_update_execute data-machine-code reconciler_fixture_mutating_timeout; } +reconciler_fixture_apply() { plugin_update_execute data-machine reconciler_fixture_mutating_timeout; } reconciler_plan_reset -reconciler_plan_add plugins.data-machine-code plugins.reconcile.data-machine-code reconciler_fixture_apply +reconciler_plan_add plugins.data-machine plugins.reconcile.data-machine reconciler_fixture_apply if reconciler_apply_plan; then fail "generic reconciler completed a timed-out plan" else @@ -134,10 +134,10 @@ else fi [ "$status" -eq "$PLUGIN_UPDATE_EXIT_PARTIAL" ] || fail "generic reconciler did not return partial status" reconciler_print_partial_evidence -[ "${RECONCILER_CHANGED_RECORDS[*]}" = "plugins.data-machine-code" ] || fail "desired-state reconciler dropped or misreported the partial mutation" +[ "${RECONCILER_CHANGED_RECORDS[*]}" = "plugins.data-machine" ] || fail "desired-state reconciler dropped or misreported the partial mutation" [ "${#RECONCILER_COMPLETED_RECORDS[@]}" -eq 0 ] || fail "desired-state reconciler misclassified a partial mutation as completed" case "$LOG" in - *'record=plugins.data-machine-code operation=plugins.reconcile.data-machine-code apply=start'*'record=plugins.data-machine-code operation=plugins.reconcile.data-machine-code apply=partial changed=true'*'DESIRED_STATE_CHANGED_RECORDS=plugins.data-machine-code'*) : ;; + *'record=plugins.data-machine operation=plugins.reconcile.data-machine apply=start'*'record=plugins.data-machine operation=plugins.reconcile.data-machine apply=partial changed=true'*'DESIRED_STATE_CHANGED_RECORDS=plugins.data-machine'*) : ;; *) fail "desired-state reconciler omitted partial mutation evidence" ;; esac diff --git a/tests/plugins-only-scope.sh b/tests/plugins-only-scope.sh index 1ef29e5..f32a522 100644 --- a/tests/plugins-only-scope.sh +++ b/tests/plugins-only-scope.sh @@ -32,8 +32,11 @@ require_source 'detect_plugins_only_environment' "narrow plugin-only environment require_source 'installation_profile_normalize "$INSTALLATION_OPERATION_PLUGINS_ONLY"' "credential-free plugins-only profile normalization" require_source 'reconcile_installed_plugins() {' "plugins-only desired-state reconciliation" require_source 'plugins.reconcile.data-machine' "explicit data-machine operation name" -require_source 'plugins.reconcile.data-machine-code' "explicit data-machine-code operation name" require_source 'plugins.reconcile.wp-codebox' "explicit wp-codebox operation name" +if grep -q 'data-machine-code\|datamachine-code' "$UPGRADE" || grep -q 'data-machine-code\|datamachine-code' "$ROOT_DIR/lib/data-machine.sh"; then + echo "FAIL: upgrade or plugins-only still updates a DMC plugin" >&2 + exit 1 +fi require_file_source "$ROOT_DIR/lib/detect.sh" 'Plugin-only scope: installed Data Machine plugins only; runtime, bridge, workspace, and service synchronization disabled' "plugin-only scope evidence" require_source '--plugins-only cannot be combined with service, runtime, migration, or other --*-only operations' "plugin-only exclusivity guard" require_source 'if _run_filter_active systemd; then' "systems-capability mutation guard" @@ -63,7 +66,6 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT SITE_PATH="$TMP/site" mkdir -p "$SITE_PATH/wp-content/plugins/data-machine" \ - "$SITE_PATH/wp-content/plugins/data-machine-code" \ "$SITE_PATH/wp-content/plugins/wp-codebox" PLUGIN_UPDATE_EXIT_PARTIAL=75 SCRIPT_DIR="$ROOT_DIR" @@ -73,7 +75,6 @@ source "$ROOT_DIR/lib/desired-state-reconciler.sh" eval "$(load_upgrade_function _run_filter_active)" eval "$(load_upgrade_function update_data_machine_plugins)" eval "$(load_upgrade_function _reconcile_data_machine_plugin)" -eval "$(load_upgrade_function _reconcile_data_machine_code_plugin)" eval "$(load_upgrade_function _reconcile_wp_codebox_plugin)" eval "$(load_upgrade_function _reconcile_installed_plugins_verify)" eval "$(load_upgrade_function reconcile_installed_plugins)" @@ -118,12 +119,12 @@ update_chat_bridge_launchd LOCAL_MODE=false update_chat_bridge_systemd -test "$(cat "$TMP/plugins")" = $'data-machine\ndata-machine-code\ncodebox' || { +test "$(cat "$TMP/plugins")" = $'data-machine\ncodebox' || { echo "FAIL: plugins-only did not run exactly the plugin updaters" >&2 exit 1 } case "$LOG" in - *'profile=operation=plugins-only'*'components=data-machine data-machine-code wp-codebox'*'record=plugins.data-machine operation=plugins.reconcile.data-machine planned'*'record=plugins.data-machine operation=plugins.reconcile.data-machine apply=start'*'record=plugins.data-machine operation=plugins.reconcile.data-machine apply=complete'*) : ;; + *'profile=operation=plugins-only'*'components=data-machine wp-codebox'*'record=plugins.data-machine operation=plugins.reconcile.data-machine planned'*'record=plugins.data-machine operation=plugins.reconcile.data-machine apply=start'*'record=plugins.data-machine operation=plugins.reconcile.data-machine apply=complete'*) : ;; *) echo "FAIL: plugins-only did not emit planned-step evidence" >&2; exit 1 ;; esac diff --git a/tests/runtime-signature.sh b/tests/runtime-signature.sh index c1f988e..2a362a1 100755 --- a/tests/runtime-signature.sh +++ b/tests/runtime-signature.sh @@ -8,19 +8,26 @@ trap 'rm -rf "$TMP"' EXIT SITE_PATH="$TMP/site" mkdir -p "$SITE_PATH/wp-content/mu-plugins" MU_FILE="$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-runtimes.php" -printf '%s\n' ' "$MU_FILE" +printf '%s\n' ' "$MU_FILE" source "$SCRIPT_DIR/lib/runtime-signature.sh" +warn() { printf '%s\n' "$*"; } DRY_RUN=true BLUE="" NC="" runtime_signature_cleanup_retired_mu_plugin > "$TMP/dry-run" [ -e "$MU_FILE" ] || { echo "FAIL: dry run removed runtime registry" >&2; exit 1; } -grep -q 'rm -f' "$TMP/dry-run" +grep -q 'Would remove installer-owned retired runtime registry' "$TMP/dry-run" DRY_RUN=false UPDATED_ITEMS=() runtime_signature_cleanup_retired_mu_plugin [ ! -e "$MU_FILE" ] || { echo "FAIL: runtime registry was retained" >&2; exit 1; } [ "${UPDATED_ITEMS[*]}" = "removed retired runtime registry" ] || { echo "FAIL: cleanup was not reported" >&2; exit 1; } +printf '%s\n' ' "$MU_FILE" +if runtime_signature_cleanup_retired_mu_plugin; then echo "FAIL: cleanup removed unknown runtime registry" >&2; exit 1; fi +[ -e "$MU_FILE" ] || { echo "FAIL: unknown runtime registry was removed" >&2; exit 1; } +DRY_RUN=true +if runtime_signature_cleanup_retired_mu_plugin > "$TMP/unknown-dry-run"; then echo "FAIL: dry-run cleanup accepted unknown runtime registry" >&2; exit 1; fi +grep -q 'Would preserve unknown runtime registry' "$TMP/unknown-dry-run" echo "OK: runtime registry cleanup is idempotent and no-op after removal" diff --git a/tests/service-migration.sh b/tests/service-migration.sh index 75b4be6..93caec4 100755 --- a/tests/service-migration.sh +++ b/tests/service-migration.sh @@ -427,7 +427,7 @@ echo "" echo "service-migration: AGENTS.md is composed as the service user" # The generated text encodes the composing process's euid: data-machine and -# data-machine-code both append `--allow-root` to their WP-CLI examples when +# generated Data Machine guidance appends `--allow-root` to WP-CLI examples when # posix_geteuid() === 0. upgrade.sh runs under sudo, so composing as the caller # writes an AGENTS.md telling a non-root agent to run `wp --allow-root` — a file # that misdescribes the agent's own environment (#322). `--allow-root` is a diff --git a/tests/setup-profile-compiler.mjs b/tests/setup-profile-compiler.mjs index aba8b3e..79204a5 100644 --- a/tests/setup-profile-compiler.mjs +++ b/tests/setup-profile-compiler.mjs @@ -29,6 +29,9 @@ function compile(profile) { assert.match(plan.commands.apply, /WP_CONTROL_TRANSPORT_JSON='\["\/usr\/local\/bin\/control transport","--identity","value with spaces"\]'/) assert.match(plan.commands.apply, /--external-wordpress --wordpress-path '\/remote\/site root' --wordpress-user 'agent user'/) assert.match(plan.commands.apply, /--with-ai-gateway/) + assert.doesNotMatch(plan.commands.apply, /--source-mode/) + assert.doesNotMatch(plan.commands.apply, /--workspace-repository/) + assert.ok(plan.warnings.some((warning) => warning.includes("no new mutable repository authority"))) assert.match(plan.commands.start, /WP_CONTROL_TRANSPORT_JSON=.*\/tmp\/runtime root\/\.wp-coding-agents\/bin\/kimaki/) assert.ok(plan.verification.overlays.includes("verify-external-wordpress-transport")) } @@ -45,6 +48,7 @@ function compile(profile) { }) assert.match(plan.commands.apply, /--systems-capabilities managed-vps/) assert.equal(plan.summary.systems_capabilities, "managed-vps") + assert.doesNotMatch(plan.commands.apply, /--source-mode|--workspace-repository/) } { @@ -115,6 +119,7 @@ for (const selection of ["auto", "codex", "claude-code", "multiple"]) { assert.equal(plan.commands.dry_run, "SITE_DOMAIN=example.com ./setup.sh --dry-run") assert.ok(plan.warnings.some((warning) => warning.includes("Multiple runtimes"))) + assert.ok(plan.warnings.some((warning) => warning.includes("no new mutable repository authority"))) assert.ok(plan.verification.overlays.includes("verify-runtime-multiple")) assert.ok(plan.verification.overlays.includes("verify-runtime-claude-code")) assert.ok(plan.verification.overlays.includes("verify-runtime-opencode")) @@ -150,6 +155,68 @@ for (const selection of ["auto", "codex", "claude-code", "multiple"]) { assert.ok(plan.verification.overlays.includes("verify-bridge-none")) } +{ + const plan = compile({ + install_target: "local", + target: { wordpress_path: "/tmp/site" }, + runtime: { selection: "opencode" }, + chat_bridge: { selection: "none" }, + overlays: {}, + }) + assert.equal(plan.summary.source_mode, "workspace") + assert.deepEqual(plan.summary.workspace_repositories, []) + assert.doesNotMatch(plan.commands.apply, /--workspace-repository/) + assert.ok(plan.warnings.some((warning) => warning.includes("Legacy profile has no source declaration"))) +} + +{ + const plan = compile({ + install_target: "local", + target: { wordpress_path: "/tmp/site" }, + runtime: { selection: "opencode" }, + chat_bridge: { selection: "none" }, + source: { mode: "workspace", workspace_repositories: ["/work/primary checkout", "/work/secondary"] }, + overlays: {}, + }) + assert.match(plan.commands.apply, /--source-mode workspace --workspace-repository '[/]work[/]primary checkout' --workspace-repository [/]+work[/]secondary/) + assert.deepEqual(plan.summary.workspace_repositories, ["/work/primary checkout", "/work/secondary"]) + assert.ok(!plan.warnings.some((warning) => warning.includes("Legacy profile has no source declaration"))) +} + +for (const source of [ + null, + { mode: "workspace" }, + { mode: "workspace", workspace_repositories: [] }, + { mode: "workspace", workspace_repositories: ["relative"] }, + { mode: "workspace", workspace_repositories: ["/work/repo", "/work/repo"] }, + { mode: "owned", workspace_repositories: ["/work/repo"] }, +]) { + const result = spawnSync("node", ["scripts/compile-setup-profile.mjs"], { + input: JSON.stringify({ + install_target: "local", + target: { wordpress_path: "/tmp/site" }, + runtime: { selection: "opencode" }, + chat_bridge: { selection: "none" }, + source, + overlays: {}, + }), + encoding: "utf8", + }) + assert.notEqual(result.status, 0) +} + +{ + const plan = compile({ + install_target: "local", + target: { wordpress_path: "/tmp/site" }, + runtime: { selection: "opencode" }, + chat_bridge: { selection: "none" }, + source: { mode: "owned", workspace_repositories: [] }, + overlays: {}, + }) + assert.doesNotMatch(plan.commands.apply, /--workspace-repository/) +} + { const plan = compile({ install_target: "local", diff --git a/tests/source-mode.sh b/tests/source-mode.sh index 65b967c..c35e59a 100755 --- a/tests/source-mode.sh +++ b/tests/source-mode.sh @@ -203,11 +203,12 @@ _opencode_config_for() { trap 'rm -rf "$TMP"' EXIT SITE_PATH="$TMP/site" KIMAKI_DATA_DIR="$TMP/kimaki-data" - mkdir -p "$SITE_PATH" "$KIMAKI_DATA_DIR" + mkdir -p "$SITE_PATH" "$KIMAKI_DATA_DIR" "$TMP/workspace" + git -C "$TMP/workspace" init -q export SCRIPT_DIR SITE_PATH KIMAKI_DATA_DIR export CHAT_BRIDGE="kimaki" LOCAL_MODE=true DRY_RUN=false export OPENCODE_MODEL="" OPENCODE_SMALL_MODEL="" - export DM_WORKSPACE_DIR="$TMP/workspace" + export WORKSPACE_REPOSITORIES="$TMP/workspace" export DM_AGENT_FILES="" export WITH_CLAUDE_CODE_AUTH=false RUNTIME="opencode" UPDATED_ITEMS=() @@ -264,6 +265,26 @@ assert_contains "$MGD_EXT" '"/var/log/site/**": "allow"' \ assert_contains "$MGD_EXT" '"/var/log/site": "allow"' \ "the literal log path is granted too, so a file path actually works" +EMPTY_WORKSPACE_JSON="$(mktemp)" +( + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + SITE_PATH="$TMP/site" + KIMAKI_DATA_DIR="$TMP/kimaki" + mkdir -p "$SITE_PATH" "$KIMAKI_DATA_DIR" + CHAT_BRIDGE=kimaki LOCAL_MODE=true DRY_RUN=false \ + OPENCODE_MODEL="" OPENCODE_SMALL_MODEL="" WITH_CLAUDE_CODE_AUTH=false \ + SOURCE_MODE=workspace WORKSPACE_REPOSITORIES="" DM_AGENT_FILES="" + UPDATED_ITEMS=() + source "$SCRIPT_DIR/lib/source-policy.sh" + source "$SCRIPT_DIR/runtimes/opencode.sh" + runtime_generate_config + cp "$SITE_PATH/opencode.json" "$EMPTY_WORKSPACE_JSON" +) +assert_eq "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["permission"].get("external_directory",{}))' "$EMPTY_WORKSPACE_JSON")" \ + "{}" "workspace mode with no declared checkout grants no external mutable target" +rm -f "$EMPTY_WORKSPACE_JSON" + # =========================================================================== echo "==> claude-code denies every installed root (managed is refused upstream)" # =========================================================================== @@ -274,9 +295,10 @@ _claude_settings_for() { TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT export SITE_PATH="$TMP/site" - export DM_WORKSPACE_DIR="$TMP/workspace" + export WORKSPACE_REPOSITORIES="$TMP/workspace" export AGENT_SLUG="builder" DRY_RUN=false IS_STUDIO=false - mkdir -p "$SITE_PATH/.claude" "$DM_WORKSPACE_DIR" + mkdir -p "$SITE_PATH/.claude" "$TMP/workspace" + git -C "$TMP/workspace" init -q if [ -n "$seed" ]; then sed "s|SITE_PATH|$SITE_PATH|g" "$seed" > "$SITE_PATH/.claude/settings.json" fi @@ -296,8 +318,8 @@ assert_contains "$(cat "$CC_ENG")" '"Edit(SITE_PATH/wp-content/themes/**)"' \ "workspace mode denies theme edits" assert_contains "$(cat "$CC_ENG")" '"Edit(SITE_PATH/wp-content/plugins/**)"' \ "workspace mode denies plugin edits" -assert_contains "$(cat "$CC_ENG")" '"Bash(wp datamachine-code workspace:*)"' \ - "workspace mode allows the DMC workspace bash surface" +refute_contains "$(cat "$CC_ENG")" 'datamachine-code' \ + "workspace mode does not grant a retired DMC command surface" rm -f "$CC_ENG" # =========================================================================== diff --git a/tests/workspace-installation.sh b/tests/workspace-installation.sh index 90e59dd..a15b0f6 100644 --- a/tests/workspace-installation.sh +++ b/tests/workspace-installation.sh @@ -104,6 +104,8 @@ printf '%s\n' "$*" >> "$HOMEBOY_LOG" case "$*" in "config show /worktree_providers/dmc") test -f "$HOMEBOY_PROVIDER" ;; "config remove /worktree_providers/dmc") rm -f "$HOMEBOY_PROVIDER" ;; + "config show /settings/worktree_provider_lifecycle/dmc") test -f "$HOMEBOY_LIFECYCLE" ;; + "config remove /settings/worktree_provider_lifecycle/dmc") rm -f "$HOMEBOY_LIFECYCLE" ;; *) exit 1 ;; esac SH @@ -112,14 +114,20 @@ PATH="$BIN:$PATH" source "$ROOT/lib/homeboy.sh" HOMEBOY_LOG="$TMP/homeboy.log" HOMEBOY_PROVIDER="$TMP/provider" -touch "$HOMEBOY_PROVIDER" +HOMEBOY_LIFECYCLE="$TMP/lifecycle" +touch "$HOMEBOY_PROVIDER" "$HOMEBOY_LIFECYCLE" mkdir -p "$SITE/wp-content/mu-plugins" -printf 'stale adapter\n' > "$SITE/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" -export HOMEBOY_LOG HOMEBOY_PROVIDER PATH +printf '%s\n' "Route DMC's canonical worktree abilities through Homeboy's native lifecycle." 'Generated by wp-coding-agents. Local edits will be overwritten.' > "$SITE/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" +export HOMEBOY_LOG HOMEBOY_PROVIDER HOMEBOY_LIFECYCLE PATH UPDATED_ITEMS=() configure_homeboy_worktree_ownership test ! -e "$HOMEBOY_PROVIDER" || fail "Homeboy retained a DMC provider" +test ! -e "$HOMEBOY_LIFECYCLE" || fail "Homeboy retained a DMC lifecycle setting" test ! -e "$SITE/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" || fail "DMC-free Homeboy ownership retained an ability callback" +printf 'operator-owned adapter\n' > "$SITE/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" +if configure_homeboy_worktree_ownership; then fail "unknown adapter cleanup reported success"; fi +test -e "$SITE/wp-content/mu-plugins/wp-coding-agents-homeboy-worktrees.php" || fail "unknown adapter was removed" + test ! -d "$SITE/wp-content/plugins/data-machine-code" || fail "fixture installed data-machine-code" echo "PASS: DMC-free workspace installation fixture" diff --git a/upgrade.sh b/upgrade.sh index 12e7ce5..4bcfc9c 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -41,7 +41,7 @@ # ./upgrade.sh --local --wp-path # local install (auto on macOS) # # Safety: NEVER touches WordPress DB, nginx, SSL, ~/.kimaki/ auth state, -# the DM workspace cloned repos, agent memory files, or the running +# configured repository checkouts, agent memory files, or the running # chat-bridge service. # # opencode.json is touched by default in additive mode: managed plugin @@ -171,6 +171,7 @@ while [[ $# -gt 0 ]]; do --ai-gateway-api-model) AI_GATEWAY_API_MODEL_ID="$2"; shift 2 ;; --rotate-ai-gateway-token) ROTATE_AI_GATEWAY_TOKEN=true; shift ;; --source-mode|--posture) SOURCE_MODE="$2"; SOURCE_MODE_EXPLICIT=true; shift 2 ;; + --workspace-repository) source_policy_add_workspace_repository "$2"; shift 2 ;; --not-owned) owned_discovery_add_exclusion "$2"; shift 2 ;; --owned-source|--managed-source) OWNED_SOURCES="${OWNED_SOURCES}${OWNED_SOURCES:+ }$2"; OWNED_SOURCES_EXPLICIT=true; shift 2 ;; --owned-writable|--managed-writable) OWNED_WRITABLE="${OWNED_WRITABLE}${OWNED_WRITABLE:+ }$2"; OWNED_WRITABLE_EXPLICIT=true; shift 2 ;; @@ -215,7 +216,7 @@ USAGE: PLUGIN_UPDATE_PHASE_TIMEOUT_SECONDS and PLUGIN_UPDATE_TOTAL_TIMEOUT_SECONDS. ./upgrade.sh --reconcile-services - Only reconcile carried providers, DMC/Homeboy, + Only reconcile carried providers, Homeboy, chat-bridge configuration, and service templates ./upgrade.sh --skills-only Only sync the wp-coding-agents upgrade skill ./upgrade.sh --agents-md-only Only regenerate AGENTS.md @@ -236,8 +237,9 @@ USAGE: systems-capability profile. ./upgrade.sh --systems-capabilities managed-vps --systems-capabilities-only Repair only the managed VPS host capability - profile and its DMC provider configuration. - ./upgrade.sh --source-mode + profile and its managed capability configuration. + ./upgrade.sh --source-mode + ./upgrade.sh --workspace-repository Where code changes land: workspace | owned (default: the mode recorded at setup time). Two shapes, not two levels. --posture is @@ -331,13 +333,13 @@ NEVER TOUCHED: - CLAUDE.md runtime config - WordPress database, nginx, SSL certs - ~/.kimaki/ auth state and OAuth tokens - - DM workspace cloned repos + - Configured repository checkouts - Agent memory files (SOUL.md, MEMORY.md, USER.md, etc.) - Running chat-bridge service (never restarted automatically) DEFAULT TOUCHES: - - data-machine and data-machine-code — updates setup-installed git - checkouts to their latest version tags. Non-git plugin directories are + - data-machine and wp-codebox — updates setup-installed sources. Non-git + plugin directories are skipped. Use --skip-plugins to skip this phase. - Carried provider plugins, Homeboy provider configuration, chat-bridge configuration, and service templates are reconciled during a full upgrade @@ -455,9 +457,11 @@ detect_environment # upgrade converges a managed install instead of silently reverting it to # engineering; --posture overrides and re-records. source_policy_resolve_mode +source_policy_validate_workspace_repositories source_policy_resolve_owned_sources source_policy_resolve_writable_paths source_policy_resolve_log_paths +source_policy_resolve_workspace_dir source_policy_assert_runtime_supports_mode if [ "$PLUGINS_ONLY" != true ] && [ "$KIMAKI_ONLY" != true ] && [ "$SKILLS_ONLY" != true ] && \ [ "$AGENTS_MD_ONLY" != true ] && [ "$RECONCILE_SERVICES_ONLY" != true ]; then @@ -555,7 +559,6 @@ PLUGIN_UPDATE_FAILURES=() if [ "${SYSTEMS_CAPABILITIES_ONLY:-false}" = true ]; then [ -n "${SYSTEMS_CAPABILITIES_PROFILE:-}" ] || error "--systems-capabilities-only requires --systems-capabilities " - discover_dm_workspace_dir systems_capabilities_apply exit 0 fi @@ -636,7 +639,7 @@ update_data_machine_plugins() { local status=0 upgrade_data_machine_plugins || status=$PLUGIN_UPDATE_EXIT_PARTIAL plugin_update_execute wp-codebox update_wp_codebox_plugin_subtree || status=$PLUGIN_UPDATE_EXIT_PARTIAL - plugin_update_verify_installed_plugins data-machine data-machine-code wp-codebox || status=$PLUGIN_UPDATE_EXIT_PARTIAL + plugin_update_verify_installed_plugins data-machine wp-codebox || status=$PLUGIN_UPDATE_EXIT_PARTIAL return "$status" } @@ -644,10 +647,6 @@ _reconcile_data_machine_plugin() { plugin_update_execute data-machine update_plugin_to_latest_tag data-machine https://github.com/Extra-Chill/data-machine.git } -_reconcile_data_machine_code_plugin() { - plugin_update_execute data-machine-code update_plugin_to_latest_tag data-machine-code https://github.com/Extra-Chill/data-machine-code.git -} - _reconcile_wp_codebox_plugin() { plugin_update_execute wp-codebox update_wp_codebox_plugin_subtree } @@ -675,7 +674,6 @@ reconcile_installed_plugins() { RECONCILER_INSTALLED_PLUGIN_SLUGS+=("$plugin_dir") case "$plugin_dir" in data-machine) reconciler_plan_add "plugins.data-machine" plugins.reconcile.data-machine _reconcile_data_machine_plugin ;; - data-machine-code) reconciler_plan_add "plugins.data-machine-code" plugins.reconcile.data-machine-code _reconcile_data_machine_code_plugin ;; wp-codebox) reconciler_plan_add "plugins.wp-codebox" plugins.reconcile.wp-codebox _reconcile_wp_codebox_plugin ;; esac done @@ -1067,7 +1065,7 @@ regenerate_agents_md() { # the filename must be a registered MemoryFileRegistry entry. # # Composed AS THE SERVICE USER, not as the caller. The generated text encodes - # the composing process's euid — data-machine and data-machine-code both + # the composing process's euid — Data Machine's generated guidance follows # append `--allow-root` to their WP-CLI examples when posix_geteuid() === 0 — # and upgrade.sh runs under sudo. Composing here as root would write an # AGENTS.md instructing a non-root agent to run `wp --allow-root`, i.e. a file @@ -1425,7 +1423,6 @@ _print_verify_block() { fi log " $(wp_cli_transport_display) plugin get data-machine --field=version --path=$SITE_PATH $WP_ROOT_FLAG" - log " $(wp_cli_transport_display) plugin get data-machine-code --field=version --path=$SITE_PATH $WP_ROOT_FLAG" log " cat $SITE_PATH/AGENTS.md | head -20 # agent instructions" log " ls $(runtime_skills_dir) # installed upgrade skill" } @@ -1433,7 +1430,6 @@ _print_verify_block() { _print_plugins_only_verify_block() { log "Verify:" log " $(wp_cli_transport_display) plugin get data-machine --fields=name,status,version --format=json --skip-plugins --path=$SITE_PATH $WP_ROOT_FLAG" - log " $(wp_cli_transport_display) plugin get data-machine-code --fields=name,status,version --format=json --skip-plugins --path=$SITE_PATH $WP_ROOT_FLAG" } # ============================================================================ @@ -1444,7 +1440,6 @@ PLUGIN_ONLY_EXIT_STATUS=0 CONVERGENCE_EXIT_STATUS=0 update_data_machine_plugins || PLUGIN_ONLY_EXIT_STATUS=$? if [ "$PLUGINS_ONLY" != true ]; then - discover_dm_workspace_dir CONVERGENCE_ENTRYPOINT="$SCRIPT_DIR/upgrade.sh" CONVERGENCE_REPLAY_ARGUMENTS="--wp-path $(printf '%q' "$SITE_PATH")" [ "$DRY_RUN" = true ] && CONVERGENCE_REPLAY_ARGUMENTS="--dry-run $CONVERGENCE_REPLAY_ARGUMENTS" diff --git a/workspace/AGENTS.external.md b/workspace/AGENTS.external.md index 8385558..6b742f9 100644 --- a/workspace/AGENTS.external.md +++ b/workspace/AGENTS.external.md @@ -5,6 +5,6 @@ WordPress control: `{{WP_CLI_CMD}}` ### WordPress Site The WordPress filesystem is not mounted in this runtime. Use the control -command for live WordPress state and abilities. Make code changes in the -configured Data Machine Code workspace; do not invent local installed-source -paths. +command for live WordPress state and abilities. Make code changes only in the +explicitly configured Git checkout roots; do not invent local installed-source +paths or repository authority.