From f2a72735e9f96794b7f9fcadc20cc822926c345a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 17 May 2026 18:10:37 +0000 Subject: [PATCH 1/3] feat(lib): add runtime-signature mu-plugin writer Mirror lib/cli-channel.sh's marker-delimited mu-plugin pattern for a second filter consumer: datamachine_code_worktree_runtime_signatures (Extra-Chill/data-machine-code#416). Each runtime gets an idempotent BEGIN/END block in wp-coding-agents-runtimes.php; register/unregister mutate just that runtime's block and leave siblings intact. Includes tests/runtime-signature.sh covering scaffold, idempotency, mutation, unregister, and end-to-end filter shape. --- lib/runtime-signature.sh | 373 +++++++++++++++++++++++++++++++++++++ tests/runtime-signature.sh | 185 ++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 lib/runtime-signature.sh create mode 100755 tests/runtime-signature.sh diff --git a/lib/runtime-signature.sh b/lib/runtime-signature.sh new file mode 100644 index 0000000..a0ff15e --- /dev/null +++ b/lib/runtime-signature.sh @@ -0,0 +1,373 @@ +#!/bin/bash +# lib/runtime-signature.sh — Per-runtime worktree session-attribution signature +# writer. +# +# Data Machine Code's worktree-attribution code captures "origin session" +# metadata when an agent spawns a worktree. Historically DMC hardcoded the +# env-var → field map for each coding-agent runtime it knew about +# (KIMAKI_SESSION_ID → kimaki_session_id, OPENCODE_RUN_ID → opencode_run_id, +# etc.). Per the platform's layer-purity rule, those vendor names do not +# belong in DMC — DMC is runtime-agnostic substrate. +# +# Extra-Chill/data-machine-code#416 generalises the DMC surface to read the +# map from a filter: +# +# apply_filters( 'datamachine_code_worktree_runtime_signatures', [] ) +# +# Each entry is keyed by an opaque runtime ID (a string the integration layer +# chooses, e.g. 'kimaki', 'opencode') and maps subkeys (session_id, thread_id, +# thread_url, run_id, …) to the env var DMC should sniff for that subkey. +# +# wp-coding-agents is the integration layer that knows about kimaki and +# opencode — it installs both, writes systemd units that pass KIMAKI_* / +# OPENCODE_* into the spawned processes, and is the only honest place those +# brand names live. This module owns publishing that knowledge into the +# DMC filter via a mu-plugin file. +# +# Resolved file: $SITE_PATH/wp-content/mu-plugins/wp-coding-agents-runtimes.php +# +# Why a separate mu-plugin file (Option B), not the existing +# wp-coding-agents-channels.php (Option A) and not a wp_option write +# (Option C): +# +# * Option A bundles two unrelated concerns under a filename that says +# "channels" — runtime signatures are not channels, they're env-var +# contracts for a different filter consumer. +# +# * Option C (`wp option patch insert`) requires runtime WP-CLI execution +# at install time, which lib/cli-channel.sh explicitly rejected for the +# CLI-channel registry because DB writes there race against multisite +# + Redis caching and have been a source of intermittent install +# failures historically. The same constraint applies here. +# +# * Option B keeps each mu-plugin doing one thing and matches DMC#416's +# filter-only API (no get_option fallback required). The mu-plugins/ +# surface growth is a real-but-small paper cut that can be paid down +# later by consolidating these registries into a single file, separate +# from this PR. +# +# The file uses the same marker-delimited block pattern as +# lib/cli-channel.sh so each runtime's block can be inserted, replaced, or +# removed idempotently without re-parsing PHP, and so two runtimes (kimaki +# and opencode) can each manage their own block independently. +# +# Public surface: +# runtime_signature_mu_plugin_path — echo file path +# runtime_signature_ensure_mu_plugin_file — create stub +# runtime_signature_register +# runtime_signature_unregister +# +# `signature_json` is a JSON object mapping subkey → env-var name, e.g.: +# '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL"}' +# +# Honors DRY_RUN (logs intent, makes no changes). + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + +# runtime_signature_mu_plugin_path +# +# Print the absolute path of the mu-plugin file. Requires SITE_PATH (set by +# lib/detect.sh during setup/upgrade). Empty + return 1 if SITE_PATH unset. +runtime_signature_mu_plugin_path() { + if [ -z "${SITE_PATH:-}" ]; then + return 1 + fi + printf '%s' "$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-runtimes.php" +} + +# --------------------------------------------------------------------------- +# File scaffolding +# --------------------------------------------------------------------------- + +# runtime_signature_ensure_mu_plugin_file +# +# Create the mu-plugin file with the filter scaffold if it does not exist. +# Idempotent — does nothing if the file already exists. The scaffold contains +# a single `add_filter( 'datamachine_code_worktree_runtime_signatures', … )` +# callback whose body is the marker-delimited region that runtimes write into. +runtime_signature_ensure_mu_plugin_file() { + local file + file="$(runtime_signature_mu_plugin_path)" || { + warn " runtime_signature_ensure_mu_plugin_file: SITE_PATH not set — skipping" + return 1 + } + + if [ -f "$file" ]; then + return 0 + fi + + local dir="${file%/*}" + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would mkdir -p $dir" + echo -e "${BLUE}[dry-run]${NC} Would write runtime-signature mu-plugin to $file" + return 0 + fi + + mkdir -p "$dir" + + cat > "$file" <<'PHP' + + * $signatures[''] = [ + * 'session_id' => 'EXAMPLE_SESSION_ID', + * 'thread_id' => 'EXAMPLE_THREAD_ID', + * ]; + * // END runtime: + * + * The subkey set is open — DMC does not validate against a closed schema. + * Conventional subkeys are: session_id, thread_id, thread_url, run_id. + * Integrations may add more. + * + * Filter contract: Extra-Chill/data-machine-code#416. + * + * @package wp-coding-agents + */ + +defined( 'ABSPATH' ) || exit; + +add_filter( 'datamachine_code_worktree_runtime_signatures', function ( $signatures ) { + if ( ! is_array( $signatures ) ) { + $signatures = []; + } + + // BEGIN runtimes + // END runtimes + + return $signatures; +} ); +PHP + + log " Wrote runtime-signature mu-plugin scaffold: $file" + if [ -n "${UPDATED_ITEMS+x}" ]; then + UPDATED_ITEMS+=("created $file") + fi +} + +# --------------------------------------------------------------------------- +# Block render +# --------------------------------------------------------------------------- + +# _runtime_signature_render_block +# +# Print the marker-delimited PHP block for a single runtime, including +# surrounding BEGIN/END markers, indented to match the scaffold's filter +# body. The signature JSON is converted to a PHP array literal of +# subkey => 'ENV_VAR_NAME' pairs. +_runtime_signature_render_block() { + local runtime_id="$1" signature_json="$2" + local esc_runtime_id esc_signature + esc_runtime_id=$(_runtime_signature_php_escape "$runtime_id") + esc_signature=$(_runtime_signature_json_to_php_map "$signature_json") + + cat < +# +# Escape a string for inclusion inside a PHP single-quoted literal. +_runtime_signature_php_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\'/\\\'}" + printf '%s' "$s" +} + +# _runtime_signature_json_to_php_map +# +# Convert a JSON object like {"session_id":"KIMAKI_SESSION_ID",...} into a +# PHP associative array literal [ 'session_id' => 'KIMAKI_SESSION_ID', ... ]. +# Uses python3 (every host that runs wp-coding-agents already depends on +# python3 — see lib/repair-opencode-json.py and runtimes/opencode.sh). +# Keys are emitted in the order they appear in the JSON for stable diffs. +_runtime_signature_json_to_php_map() { + local json="$1" + python3 - "$json" <<'PY' +import json, sys +obj = json.loads(sys.argv[1]) +def esc(s): + return s.replace("\\", "\\\\").replace("'", "\\'") +if not isinstance(obj, dict): + sys.stderr.write("runtime signature must be a JSON object\n") + sys.exit(1) +if not obj: + print("[]") + sys.exit(0) +parts = [] +for k, v in obj.items(): + if not isinstance(k, str) or not isinstance(v, str): + sys.stderr.write("runtime signature keys and values must be strings\n") + sys.exit(1) + parts.append("'" + esc(k) + "' => '" + esc(v) + "'") +print("[ " + ", ".join(parts) + " ]") +PY +} + +# --------------------------------------------------------------------------- +# Register / unregister +# --------------------------------------------------------------------------- + +# runtime_signature_register +# +# Upsert 's block in the mu-plugin file. Idempotent: re-running +# with the same arguments leaves the file unchanged; re-running with a +# different signature replaces just that runtime's block. Other runtimes' +# blocks are preserved. +runtime_signature_register() { + local runtime_id="$1" signature_json="$2" + + if [ -z "$runtime_id" ] || [ -z "$signature_json" ]; then + warn " runtime_signature_register: missing required args (runtime_id=$runtime_id signature=$signature_json)" + return 1 + fi + + local file + file="$(runtime_signature_mu_plugin_path)" || { + warn " runtime_signature_register: SITE_PATH not set — skipping runtime '$runtime_id'" + return 1 + } + + runtime_signature_ensure_mu_plugin_file || return 1 + + local new_block + new_block=$(_runtime_signature_render_block "$runtime_id" "$signature_json") + + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would register runtime signature '$runtime_id' in $file" + echo -e "${BLUE}[dry-run]${NC} Block:" + echo "$new_block" | sed 's/^/ /' + return 0 + fi + + if _runtime_signature_block_matches "$file" "$runtime_id" "$new_block"; then + return 0 + fi + + local tmp + tmp=$(mktemp "${file}.XXXXXX") + _runtime_signature_rewrite "$file" "$runtime_id" "$new_block" > "$tmp" + + if cmp -s "$file" "$tmp"; then + rm -f "$tmp" + return 0 + fi + + mv "$tmp" "$file" + log " Registered runtime signature '$runtime_id' in $file" + if [ -n "${UPDATED_ITEMS+x}" ]; then + UPDATED_ITEMS+=("runtime signature: $runtime_id") + fi +} + +# runtime_signature_unregister +# +# Remove 's block from the mu-plugin file. No-op if the file +# does not exist, or if the block is not present. Other runtimes' blocks +# are preserved. +runtime_signature_unregister() { + local runtime_id="$1" + if [ -z "$runtime_id" ]; then + warn " runtime_signature_unregister: missing runtime_id" + return 1 + fi + + local file + file="$(runtime_signature_mu_plugin_path)" || { + return 0 + } + [ -f "$file" ] || return 0 + + if ! grep -q "// BEGIN runtime:${runtime_id}\$" "$file"; then + return 0 + fi + + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would unregister runtime signature '$runtime_id' from $file" + return 0 + fi + + local tmp + tmp=$(mktemp "${file}.XXXXXX") + _runtime_signature_rewrite "$file" "$runtime_id" "" > "$tmp" + mv "$tmp" "$file" + log " Unregistered runtime signature '$runtime_id' from $file" + if [ -n "${UPDATED_ITEMS+x}" ]; then + UPDATED_ITEMS+=("runtime signature removed: $runtime_id") + fi +} + +# _runtime_signature_block_matches +# +# Return 0 if the existing block for in already equals +# verbatim; otherwise 1. Used to short-circuit no-op rewrites. +_runtime_signature_block_matches() { + local file="$1" runtime_id="$2" new_block="$3" + local existing + existing=$(awk -v rid="$runtime_id" ' + $0 == " // BEGIN runtime:" rid { capturing=1 } + capturing { print } + $0 == " // END runtime:" rid { exit } + ' "$file") + [ "$existing" = "$new_block" ] +} + +# _runtime_signature_rewrite +# +# Stream to stdout, replacing the existing block for +# with , or inserting immediately before the +# `// END runtimes` marker if no block for exists yet. Empty +# removes the runtime's block entirely (used by unregister). +_runtime_signature_rewrite() { + local file="$1" runtime_id="$2" new_block="$3" + awk -v rid="$runtime_id" -v new_block="$new_block" ' + BEGIN { + begin_marker = " // BEGIN runtime:" rid + end_marker = " // END runtime:" rid + inserted = 0 + skipping = 0 + } + { + if ($0 == begin_marker) { + skipping = 1 + if (new_block != "") { + print new_block + inserted = 1 + } + next + } + if (skipping) { + if ($0 == end_marker) { + skipping = 0 + } + next + } + if (!inserted && $0 == " // END runtimes") { + if (new_block != "") { + print new_block + inserted = 1 + } + } + print + } + ' "$file" +} diff --git a/tests/runtime-signature.sh b/tests/runtime-signature.sh new file mode 100755 index 0000000..db7d6bd --- /dev/null +++ b/tests/runtime-signature.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# tests/runtime-signature.sh — Regression coverage for lib/runtime-signature.sh. +# +# Asserts: +# 1. Fresh scaffold writes a syntactically-valid PHP mu-plugin that wires +# add_filter( 'datamachine_code_worktree_runtime_signatures', … ). +# 2. Register is idempotent — re-running with the same signature does not +# mutate the file. +# 3. Re-registering with a different signature replaces just that runtime's +# block, leaving other runtimes' blocks intact. +# 4. Unregister removes a runtime's block without touching siblings. +# 5. After register × 2 + unregister × 1 the file still parses with `php -l` +# and applying the filter returns the surviving signature. +# +# The PHP-execution assertion (step 5) uses a tiny shim that stubs the +# WordPress filter primitives so the mu-plugin can run outside a real WP +# install. It validates the *shape* of the data DMC#416 will consume, not +# the WP runtime integration itself. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$SCRIPT_DIR" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/wp-content/mu-plugins" +export SITE_PATH="$TMP" +export DRY_RUN=false + +# Silence helper logs in test output unless --verbose is passed. +VERBOSE=false +for arg in "$@"; do + case "$arg" in + --verbose|-v) VERBOSE=true ;; + esac +done + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/runtime-signature.sh" +UPDATED_ITEMS=() + +if [ "$VERBOSE" = false ]; then + log() { :; } + warn() { echo "WARN: $1" >&2; } +fi + +MU_FILE="$TMP/wp-content/mu-plugins/wp-coding-agents-runtimes.php" +FAILED=0 + +assert_eq() { + local got="$1" want="$2" name="$3" + if [ "$got" = "$want" ]; then + echo " ok $name" + else + echo " FAIL $name" + echo " got: $got" + echo " want: $want" + FAILED=$((FAILED + 1)) + fi +} + +assert_file_exists() { + local file="$1" name="$2" + if [ -f "$file" ]; then + echo " ok $name" + else + echo " FAIL $name (missing: $file)" + FAILED=$((FAILED + 1)) + fi +} + +assert_php_lint() { + local file="$1" name="$2" + if php -l "$file" >/dev/null 2>&1; then + echo " ok $name" + else + echo " FAIL $name" + php -l "$file" 2>&1 | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi +} + +# --- 1. Fresh scaffold + register kimaki ----------------------------------- +echo "==> register kimaki (fresh scaffold)" +runtime_signature_register "kimaki" \ + '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL"}' +assert_file_exists "$MU_FILE" "mu-plugin created" +assert_php_lint "$MU_FILE" "scaffold parses with php -l" + +if grep -q "BEGIN runtime:kimaki" "$MU_FILE"; then + echo " ok kimaki block present" +else + echo " FAIL kimaki block missing" + FAILED=$((FAILED + 1)) +fi + +# --- 2. Idempotency -------------------------------------------------------- +echo "==> re-register kimaki with same signature (idempotent)" +HASH_BEFORE=$(md5sum "$MU_FILE" | cut -d' ' -f1) +runtime_signature_register "kimaki" \ + '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL"}' +HASH_AFTER=$(md5sum "$MU_FILE" | cut -d' ' -f1) +assert_eq "$HASH_AFTER" "$HASH_BEFORE" "file unchanged on re-register" + +# --- 3. Add opencode without disturbing kimaki ----------------------------- +echo "==> register opencode (sibling block)" +runtime_signature_register "opencode" \ + '{"session_id":"OPENCODE_SESSION_ID","run_id":"OPENCODE_RUN_ID"}' +assert_php_lint "$MU_FILE" "two-runtime file parses with php -l" + +if grep -q "BEGIN runtime:kimaki" "$MU_FILE" && grep -q "BEGIN runtime:opencode" "$MU_FILE"; then + echo " ok both runtime blocks present" +else + echo " FAIL kimaki and/or opencode block missing after sibling register" + FAILED=$((FAILED + 1)) +fi + +# --- 4. Mutation: replace kimaki block, opencode untouched ----------------- +echo "==> re-register kimaki with mutated signature" +runtime_signature_register "kimaki" \ + '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL","run_id":"KIMAKI_RUN_ID"}' +if grep -q "KIMAKI_RUN_ID" "$MU_FILE"; then + echo " ok kimaki block updated" +else + echo " FAIL kimaki block did not pick up new subkey" + FAILED=$((FAILED + 1)) +fi +if grep -q "OPENCODE_SESSION_ID" "$MU_FILE"; then + echo " ok opencode block preserved across kimaki mutation" +else + echo " FAIL opencode block clobbered by kimaki re-register" + FAILED=$((FAILED + 1)) +fi + +# --- 5. Unregister opencode ------------------------------------------------ +echo "==> unregister opencode" +runtime_signature_unregister "opencode" +if grep -q "BEGIN runtime:opencode" "$MU_FILE"; then + echo " FAIL opencode block still present after unregister" + FAILED=$((FAILED + 1)) +else + echo " ok opencode block removed" +fi +if grep -q "BEGIN runtime:kimaki" "$MU_FILE"; then + echo " ok kimaki block preserved across opencode unregister" +else + echo " FAIL kimaki block clobbered by opencode unregister" + FAILED=$((FAILED + 1)) +fi +assert_php_lint "$MU_FILE" "post-unregister file parses with php -l" + +# --- 6. Filter-shape end-to-end (php execution) ---------------------------- +echo "==> apply_filters returns the expected shape" +SHIM="$TMP/filter-shim.php" +cat > "$SHIM" < Date: Sun, 17 May 2026 18:10:46 +0000 Subject: [PATCH 2/3] feat(runtimes): register kimaki + opencode worktree signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire each runtime/bridge to publish its env-var signature into the new mu-plugin on install and on upgrade-time refresh: - bridges/kimaki.sh registers session_id/thread_id/thread_url against KIMAKI_SESSION_ID/KIMAKI_THREAD_ID/KIMAKI_THREAD_URL from bridge_install and bridge_sync_config, alongside the existing CLI-channel registration. - runtimes/opencode.sh registers session_id/run_id against OPENCODE_SESSION_ID/OPENCODE_RUN_ID from runtime_install. upgrade.sh refreshes it from the existing remove_legacy_opencode_wrapper_phase (the upgrade-time entry point that already sources runtimes/opencode.sh). Brand names live here intentionally — wp-coding-agents is the integration layer that knows about kimaki and opencode. DMC consumes the registered filter map generically (Extra-Chill/data-machine-code#416). --- bridges/kimaki.sh | 26 ++++++++++++++++++++++++++ runtimes/opencode.sh | 27 +++++++++++++++++++++++++++ setup.sh | 2 +- upgrade.sh | 10 +++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/bridges/kimaki.sh b/bridges/kimaki.sh index 3030df4..5d05501 100644 --- a/bridges/kimaki.sh +++ b/bridges/kimaki.sh @@ -56,6 +56,7 @@ bridge_install() { _kimaki_sync_bin_helpers _kimaki_register_cli_channel + _kimaki_register_runtime_signature } # _kimaki_register_cli_channel @@ -88,6 +89,26 @@ _kimaki_register_cli_channel() { "600" } +# _kimaki_register_runtime_signature +# +# Publish kimaki's worktree session-attribution env-var contract for the Data +# Machine Code worktree-attribution code (Extra-Chill/data-machine-code#416). +# kimaki sets KIMAKI_SESSION_ID, KIMAKI_THREAD_ID, and KIMAKI_THREAD_URL on +# the opencode-serve children it spawns (see Kimaki source). DMC reads those +# env vars at worktree-create time to record which kimaki session originated +# the worktree, what Discord thread the session lives in, and the deep link +# to that thread. +# +# The registration is data, not config: the runtime ID 'kimaki' is what +# wp-coding-agents *calls* the runtime here, and the env-var names are what +# the kimaki binary actually sets. DMC stays naive — it doesn't know kimaki +# exists; it just sniffs whatever env vars the filter map tells it to. +_kimaki_register_runtime_signature() { + runtime_signature_register \ + "kimaki" \ + '{"session_id":"KIMAKI_SESSION_ID","thread_id":"KIMAKI_THREAD_ID","thread_url":"KIMAKI_THREAD_URL"}' +} + _kimaki_sync_bin_helpers() { [ -d "$SCRIPT_DIR/bridges/kimaki/bin" ] || return 0 @@ -400,6 +421,11 @@ bridge_sync_config() { # the latest adapter path (npm-global moves between hosts). _kimaki_register_cli_channel + # Refresh the worktree runtime-signature registration. Idempotent — only + # touches disk when the env-var map drifts (e.g. a new subkey is added in + # a future kimaki release). + _kimaki_register_runtime_signature + log " Done." # Export resolved paths so print_summary can reference them diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index 0634840..3df6e55 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -11,6 +11,33 @@ runtime_install() { fi _remove_legacy_opencode_wrapper + _opencode_register_runtime_signature +} + +# _opencode_register_runtime_signature +# +# Publish opencode's worktree session-attribution env-var contract for the +# Data Machine Code worktree-attribution code (Extra-Chill/data-machine-code#416). +# opencode sets OPENCODE_SESSION_ID and OPENCODE_RUN_ID on processes it +# spawns; DMC reads those at worktree-create time to record which opencode +# session/run originated the worktree. +# +# Registered from runtime_install (setup-time) and from the legacy-wrapper- +# removal phase in upgrade.sh, which is the upgrade-time entry point that +# already sources runtimes/opencode.sh. Re-running is harmless: the writer +# is idempotent and only mutates the mu-plugin file when the env-var map +# actually differs. +_opencode_register_runtime_signature() { + if ! declare -F runtime_signature_register >/dev/null; then + # The helper lives in lib/runtime-signature.sh, sourced by setup.sh and + # upgrade.sh. When this function is invoked outside those entry points + # (e.g. by a test that sources just runtimes/opencode.sh in isolation), + # silently skip — registration is not the runtime's primary job. + return 0 + fi + runtime_signature_register \ + "opencode" \ + '{"session_id":"OPENCODE_SESSION_ID","run_id":"OPENCODE_RUN_ID"}' } # Remove any legacy wp-coding-agents-opencode-wrapper-v2 bash shim that prior diff --git a/setup.sh b/setup.sh index 5c4d08d..bf1db02 100755 --- a/setup.sh +++ b/setup.sh @@ -23,7 +23,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source shared modules -for lib in common detect wordpress infrastructure data-machine homeboy skills summary cli-channel; do +for lib in common detect wordpress infrastructure data-machine homeboy skills summary cli-channel runtime-signature; do source "$SCRIPT_DIR/lib/${lib}.sh" done diff --git a/upgrade.sh b/upgrade.sh index 1e2299c..3d20681 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -66,7 +66,7 @@ TIMESTAMP="$(date +%Y%m%d-%H%M%S)" # Source shared modules (common, detect needed for environment resolution; # wordpress is needed for wp_cmd helper used by compose and plugin updates). -for lib in common detect wordpress data-machine homeboy skills cli-channel; do +for lib in common detect wordpress data-machine homeboy skills cli-channel runtime-signature; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -663,6 +663,14 @@ remove_legacy_opencode_wrapper_phase() { fi _remove_legacy_opencode_wrapper + + # Refresh the worktree runtime-signature registration so existing installs + # pick up the opencode entry on upgrade (and any future signature drift). + # Idempotent — only mutates the mu-plugin file when the env-var map + # actually differs from what is already on disk. + if declare -F _opencode_register_runtime_signature >/dev/null; then + _opencode_register_runtime_signature + fi } # ============================================================================ From db8761a35ecd719553e32a33e90b74284ae1baf9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 17 May 2026 18:10:50 +0000 Subject: [PATCH 3/3] docs(readme): document worktree runtime signature registry New 'Worktree Session Attribution' section after Outbound Dispatch: - Explains why the brand names (kimaki, opencode, KIMAKI_*, OPENCODE_*) live in wp-coding-agents and not in DMC (layer purity per RULES.md). - Documents the mu-plugin file and marker-delimited block layout. - Lists each registered runtime, subkey, env var, and what it identifies. - Calls out that DMC shipping kimaki/opencode-specific code is a layer violation to be filed against DMC, not patched here. --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index cc4c774..50a1e9c 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,59 @@ Retirement is a one-time manual cleanup. There is no helper script: this stack o After this, the only outbound message path is `agents/dispatch-message` → DMC CLI runtime → bridge CLI. If you weren't running the legacy webhook to begin with (every install of wp-coding-agents from v0.5.x onward), skip the migration entirely — the bridge registrations land on a clean disk. +## Worktree Session Attribution (Runtime Signatures) + +When a coding-agent session asks Data Machine Code to create a worktree, DMC captures **origin-session metadata** so the worktree carries a breadcrumb back to the session that spawned it (Discord thread URL, opencode session ID, run ID, etc.). DMC reads that metadata from environment variables the runtime sets on the worktree-creating process. + +The env-var names are vendor-specific (`KIMAKI_SESSION_ID`, `OPENCODE_SESSION_ID`, `OPENCODE_RUN_ID`, …) so DMC cannot enumerate them without knowing about kimaki and opencode — a layer-purity violation per the platform's coding rules. The fix (Extra-Chill/data-machine-code#416) moves the env-var → field map out of DMC into a filter: + +```php +apply_filters( 'datamachine_code_worktree_runtime_signatures', [] ); +``` + +wp-coding-agents is the integration layer that knows about kimaki and opencode — it installs both, writes the systemd units that pass those env vars into the spawned processes, and is the only honest place those brand names live. So **wp-coding-agents owns the registration.** + +### How it wires up + +On install (and every `upgrade.sh` run), each runtime/bridge writes its signature into a mu-plugin file at: + +``` +$WP_PATH/wp-content/mu-plugins/wp-coding-agents-runtimes.php +``` + +The file is owned end-to-end by wp-coding-agents installers: created on first registration, each runtime contributes a marker-delimited block (`// BEGIN runtime:` … `// END runtime:`), and the same install path can rewrite or remove its block idempotently. The file registers entries via the filter: + +```php +$signatures['kimaki'] = [ + 'session_id' => 'KIMAKI_SESSION_ID', + 'thread_id' => 'KIMAKI_THREAD_ID', + 'thread_url' => 'KIMAKI_THREAD_URL', +]; + +$signatures['opencode'] = [ + 'session_id' => 'OPENCODE_SESSION_ID', + 'run_id' => 'OPENCODE_RUN_ID', +]; +``` + +DMC reads the map, walks each runtime's subkeys, and sniffs the named env vars at worktree-create time. The subkey set is open — DMC does not validate against a closed schema. Conventional subkeys are `session_id`, `thread_id`, `thread_url`, `run_id`; integrations may add more. + +### Registered signatures + +| Runtime ID | Subkey | Env var | What it identifies | +|-------------|--------------|------------------------|-----------------------------------------------------| +| `kimaki` | `session_id` | `KIMAKI_SESSION_ID` | Kimaki session (1:1 with a Discord thread) | +| `kimaki` | `thread_id` | `KIMAKI_THREAD_ID` | Discord thread the session lives in | +| `kimaki` | `thread_url` | `KIMAKI_THREAD_URL` | Deep link to that Discord thread | +| `opencode` | `session_id` | `OPENCODE_SESSION_ID` | opencode session inside the kimaki/opencode runtime | +| `opencode` | `run_id` | `OPENCODE_RUN_ID` | Specific opencode run within that session | + +Adding a new runtime is a one-liner: register a new block in the relevant `runtimes/.sh` or `bridges/.sh` via `runtime_signature_register ` (see `lib/runtime-signature.sh`). + +### Layer purity + +**The brand names live here on purpose.** wp-coding-agents is the integration plugin and the only place in the stack that should know about specific coding-agent runtimes. If you ever see DMC ship code that names `kimaki`, `opencode`, `KIMAKI_*`, or `OPENCODE_*` directly, **that is a layer-purity violation and should be filed against DMC, not patched here.** DMC must stay runtime-agnostic; vendor naming is wp-coding-agents' job. + ## Requirements **VPS:**