diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6d5da8..6e238acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- new(mxpatch, exec): **`project-bin/mxpatch.py`, the shared patch core, and a `mxcli fix hashes` check around every `exec.sh --patch` run: the two shipped workflow patchers no longer leave a stale ContentsHash.** mxpatch finds a unit by type + name (+ module) with a byte pre-filter, finds an element by Name, gets/sets a value by key, and writes the unit and its `.mpr` ContentsHash (base64 SHA-256 of the file) in one step; dry run by default, `already applied` on a re-run, and a refusal (exit 3) when the current value is not the expected old one. `wf-set-call-captions.py` and `wf-add-path-terminators.py` now write through it, refuse drift (a caption is replaced only while it is still the stamped microflow name or the `--expect-caption` value; `--expect-paths N` for terminators), and also take the `exec.sh --patch` form (` Module.Workflow`). The index is committed before the file swap, so a kill mid-write leaves a MISMATCH that `fix hashes` reports and a re-run repairs. `mxunit_bson.py` reads BSON 0x09 (UTC datetime). `exec.sh --patch` runs `mxcli fix hashes` (v0.25.0+, probed via `fix --help`) before and after the script and restores the snapshot on any issue the baseline did not have, before mxbuild, which cannot see it. Field evidence: captured mxcli v0.25.0/v0.26.0 runs on a public model (`tests/wave2/fixtures/mxpatch/CAPTURE.txt`): master's patchers leave a MISMATCH, the ported ones verify clean; the hash routine agrees with mxcli on 67 of 67 mismatched and 302 matching units of a second model. Widget-shape copying and pluggable-widget properties wait on a captured page unit. Field run ([#390](https://github.com/MendixMau/mxcli-project-toolkit/issues/390), mxcli v0.26.0, macOS): PASS, and the patch repaired a ContentsHash MISMATCH an old-style `--patch` had left on the unit it rewrote. Fixes from it: the backup of a rewritten unit is kept outside `mprcontents/` (in `$MXPATCH_BACKUP_DIR`, which `exec.sh --patch` points into the snapshot it just took, else a 0700 `/mxpatch-bak-/`; refused when it resolves inside `mprcontents/`, through a symlink or another case spelling; named with pid and counter so two writes in one second keep both), no longer as an untracked `.mxunit.bak` that `git add mprcontents` would commit; the BUILD-LOG row carries the hash bracket (`hashes: baseline 1 mismatch (pre-existing), verify clean (1 pre-existing repaired by this patch's rewrite)`); and the baseline message says that a unit the patch rewrites gets a fresh hash, and to repair the others with `fix hashes --repair`. A baseline MISMATCH that is still reported on a unit the patch rewrote counts as new (restore, exit 1), not as pre-existing. — jopterhorst ([#294](https://github.com/MendixMau/mxcli-project-toolkit/issues/294), [#295](https://github.com/MendixMau/mxcli-project-toolkit/issues/295)) - learn(pipelines/mendix-mpr/samples): **three minimal mxcli repros from the native-module bug hunt, the files the upstream draft cites** (split from [#293](https://github.com/MendixMau/mxcli-project-toolkit/pull/293), 6/6). `fsm-template-9.24.2/native-bughunt/` holds CAPTURE.txt and D1–D3, which are written from scratch in a `Repro` module: `describe entity` printing `index ( desc);`, the native Repeater `content` slot, and no flow writable to a 9.24.2 project. They are the repros for U3, U2 and U14 in `bug-logs/pending-github-issues/2026-10-12-mxcli-0.26-round-trip-report.md`. The other 472 sample files of #293 had no citing reader, because the skills and Rehost splits state their field results inline, so none of them ships. — MendixMau, Decompose/Rehost lab runs 2026-10-12 - learn(inbox, pending-github-issues): **ten field-note drops from the Decompose/Rehost lab runs, and one not-filed draft of 53 mxcli 0.26 defects for mendixlabs/mxcli** (split from [#293](https://github.com/MendixMau/mxcli-project-toolkit/pull/293), 5/6). `contrib/inbox/2026-10-1{2,3}-*.md`: increments 1–8 of the split-a-monolith runs on a public Marketplace template (FSM 9.24.2), the native-module bug hunt, and the GenAI Showcase regeneration (0.26.0 and the 2026-10-13 nightly); each now has the template's front matter and is at most 600 words. `bug-logs/pending-github-issues/2026-10-12-mxcli-0.26-round-trip-report.md`: the U1–U53 table the inbox drops refer to, marked DRAFT — NOT FILED, to be split into one issue per defect and retested before anything is posted. No BUG numbers assigned; triage promotes. — MendixMau, Decompose/Rehost lab runs 2026-10-12/13 - learn(decompose): **three on-demand skills for working from an existing Mendix app (split from #293, 4/6).** `decompose-app.md` covers splitting a monolith into several apps: choosing the cut on business fit and coupling, a gated target architecture, cut order by dependency direction, moving each slice by MPK, ledgering every crossing edge, and reporting the level the slice reached (build-green, seam-live or strangled). `rehost-app.md` covers regenerating a whole `.mpr` into a fresh app with `pipelines/mendix-mpr/` and reading the fidelity ledger; done means build-green with named hand-offs plus journey parity. `mendix-service-seam.md` collects OData/REST patterns between two Mendix apps: keys, machine-role auth measured 200/401/404, error handlers, external-entity limits, stub replacement and cross-app joins. Each pattern is paired with the verbatim failure it prevents. Routing rows are written as trigger conditions; all three are `ondemand`, so the baseline stays at 75,038 words. Citations of unreviewed inbox notes and uncommitted run captures are replaced by the fact and its field run. `architecture-blueprint.md` and `learned-constants-and-secrets.md` gain one pointer each. `decompose-app.md` names `bin/cut-score.sh` and `bin/cut-ledger.sh`, which arrive in their own split PRs. Field runs on public apps: a field-service template (Mendix 9.24) in eight increments up to a strangled slice; a GenAI showcase app and a banking sample for rehost. — MendixMau diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index 17a02a98..e1c939d3 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -97,7 +97,8 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m # none; the trend view lived only as a grep in the skill. # wf-add-path-terminators.py + mxunit_bson.py are the BUG-121 repair, and they install as a # PAIR — the patcher imports the codec from its own directory, so shipping one without the -# other is an ImportError on first use. They are here rather than in bin/ because a PROJECT +# other is an ImportError on first use. Since 2026-10-14 (#294) it is a TRIO: both wf-*.py +# patchers import mxpatch.py (unit lookup, write + ContentsHash), which imports the codec. They are here rather than in bin/ because a PROJECT # runs them, against that project's own model unit, after every MDL script that rewrites a # workflow carrying a PARALLEL SPLIT. Until MDL has a branch-ending keyword there is no other # way to make a scripted split fan out, and every gate is blind to its absence. @@ -114,7 +115,7 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m # may run itself (stops only the pid run --local recorded for THIS project), and `mxcli test` on a # scratch copy under the test-port lock. exec.sh names app-restart.sh in its watch advisory, so # it has to be installed beside it. -MXTK_PROJECT_BIN="app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh" +MXTK_PROJECT_BIN="app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh" # Files in project-bin/ that are deliberately NOT installed into projects. The reverse check # below flags anything named by NEITHER list, so a new file in project-bin/ has to be either diff --git a/project-bin/exec.sh b/project-bin/exec.sh index 583d5df1..a4c85a76 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -37,6 +37,9 @@ # without spending an mxbuild on it. A gate that fails, cannot run, or reports `unverified` is # restored too, and ALLOW_UNVERIFIED does not apply. A dirty model with the same errors as # before is kept, same as for MDL (the delta gate). +# Since 2026-10-14 (#295) a --patch run also brackets the script with `mxcli fix hashes` +# (v0.25.0+, read-only): a ContentsHash the script left stale is a restore, before mxbuild, +# which cannot see it. See "ContentsHash verify" below; bin/mxpatch.py writes units with it. # What it does NOT guard: anything the script touches besides the model. `./bin/exec.sh` is on # the project's allow-list (install-claude-permissions.sh), so in auto mode Claude Code's # classifier does not review the script. Read what a patch does outside the .mpr before running it. @@ -437,7 +440,7 @@ HDR2 # The gate cell is read from GATE_STATE rather than passed in, so no caller # can omit it — a blank cell in this table reads as "fine", which is the exact # false-green the gate exists to prevent. - _detail="$2${CHANGE_CLASS:+ · change: $CHANGE_CLASS}${CR_ID:+ · cr: $CR_ID}${SCAN_NOTE:+ · $SCAN_NOTE}$_EXEC_APPROVAL_SUFFIX" + _detail="$2${CHANGE_CLASS:+ · change: $CHANGE_CLASS}${CR_ID:+ · cr: $CR_ID}${SCAN_NOTE:+ · $SCAN_NOTE}${HV_NOTE:+ · $HV_NOTE}$_EXEC_APPROVAL_SUFFIX" printf '| %s | `%s` | %s | %s | %s |\n' \ "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$LOG_NAME" \ "${GATE_STATE:-not-run}" "$1" "$_detail" >> "$BUILD_LOG" @@ -490,7 +493,7 @@ take_score() { # Found like exec-approval.sh: $MXTK_ROOT, else the "Toolkit root" row of CLAUDE.local.md. # Not found, or the scan itself errors (exit 2) → one warning, and the exec goes on: mxcli # check and the mxbuild gate still stand behind it. -SCAN_NOTE="" +SCAN_NOTE=""; HV_NOTE="" # HV_NOTE: the ContentsHash bracket's cell text, set below if [ "$PATCH_MODE" != "1" ]; then _SCAN="" for _s in "${MXTK_ROOT:-}/bin/mdl-scan.sh" \ @@ -1006,6 +1009,15 @@ fi echo "→ Snapshotting model..." ./bin/snapshot-mpr.sh +# The snapshot just taken. A --patch run hands it to the script as MXPATCH_BACKUP_DIR, so the +# .bak that bin/mxpatch.py keeps of each unit it rewrites lands beside the snapshot, OUTSIDE +# the model tree, and is pruned with it. Until 2026-10-15 it was .mxunit.bak inside +# mprcontents/: untracked in the model folder, one `git add mprcontents` from being committed +# (field run, #390). +SNAP_NOW="$(ls -dt "$PROJECT_ROOT/.mpr-snapshots"/[0-9]*/ 2>/dev/null | head -1)"; SNAP_NOW="${SNAP_NOW%/}" +if [ "$PATCH_MODE" = "1" ] && [ -n "$SNAP_NOW" ]; then + export MXPATCH_BACKUP_DIR="$SNAP_NOW/mxpatch-bak" +fi # ── Restore (one implementation) ───────────────────────────────────────────── # Rolls the model back to the snapshot just taken, through restore-mpr.sh. exec.sh used to @@ -1285,6 +1297,95 @@ if [ "${SKIP_BASELINE:-0}" != "1" ] && [ -x "$MXBUILD" ] && [ -x "$JAVA_EXE" ]; fi fi +# ── ContentsHash verify (--patch only; #295, 2026-10-14) ───────────────────── +# An MPR v2 .mpr indexes every mprcontents/**/*.mxunit by base64(SHA-256(file)). A patch that +# rewrites a unit and not its index row leaves Studio Pro deciding "what changed" from a stale +# index, and NOTHING else in this chain can see it — mxcli's own help: "'mx check' does not +# notice". Both patchers the toolkit shipped did exactly that until mxpatch.py (#294). So +# around a --patch run: `mxcli fix hashes` (read-only without --repair) once before the +# script as a baseline, once after it exits 0, before the mxbuild gate. Only an issue the +# BASELINE did not have fails the run (a stale index from an earlier `git checkout` is not +# this patch's doing — it is reported with its remedy and left alone). +# +# The contract is captured, not assumed (tests/wave2/fixtures/mxpatch/, v0.25.0 and v0.26.0 +# verbatim): exit 0 + "The ContentsHash index matches every unit file." (or "is an MPR v1 +# project") = clean; exit 1 + the "N mismatch(es), N missing file(s), N orphan file(s)." +# summary = issues, one " MISMATCH|MISSING|ORPHAN" row each; exit 1 WITHOUT the summary is an +# error ("project not found") — never read the exit code alone. Support is read from +# `mxcli fix --help` listing `hashes`, the way doctor.sh probes property-sets: on v0.24.0 +# `mxcli fix hashes --help` exits 0 with the parent's help, so it cannot be the probe. +# No mxcli, or one older than v0.25.0: a loud NOT RUN, and the patch goes on to the gate — +# blocking every patch on an old binary would block the remedy (guard rule 7). +HV_ON=0; HV_BASE=""; HV_POST=""; HV_STATE=""; HV_RC=0 +hv_supported() { # 0 listed, 1 absent, 2 the help itself failed + _hv_f=$(mktemp "${TMPDIR:-/tmp}/exec-hv.XXXXXX"); _hv_r=0 + "$PMXCLI" fix --help > "$_hv_f" 2>&1 < /dev/null || _hv_r=2 + if [ "$_hv_r" -eq 0 ]; then grep -qE '^[[:space:]]+hashes[[:space:]]' "$_hv_f" || _hv_r=1; fi + rm -f "$_hv_f"; return "$_hv_r" +} +hv_run() { # — sets HV_STATE clean|issues|error and HV_RC + HV_RC=0 + "$PMXCLI" fix hashes -p "$MPR" > "$1" 2>&1 < /dev/null || HV_RC=$? + if [ "$HV_RC" -eq 0 ] && grep -qE 'ContentsHash index matches every unit file|is an MPR v1 project' "$1"; then + HV_STATE=clean + elif [ "$HV_RC" -eq 1 ] && grep -qE '^[0-9]+ mismatch\(es\), [0-9]+ missing file\(s\), [0-9]+ orphan file\(s\)\.' "$1"; then + HV_STATE=issues + else + HV_STATE=error + fi +} +hv_keys() { grep -E '^ (MISMATCH|MISSING|ORPHAN) ' "$1" 2>/dev/null | awk '{print $1, $2}' | LC_ALL=C sort -u; } +# hv_tally — "1 mismatch, 2 orphan" from its MISMATCH/MISSING/ORPHAN rows, for the +# BUILD-LOG cell. +hv_tally() { + hv_keys "$1" | awk '{n[tolower($1)]++} END {s=""; split("mismatch missing orphan",k," "); + for (i=1;i<=3;i++) if (n[k[i]]) s=s (s?", ":"") n[k[i]] " " k[i]; print s}' +} +# hv_rewritten — 0 when the patch rewrote that unit: its file differs from the copy in the +# snapshot taken before the script ran. The path is the row's third field, relative to the +# model directory, and snapshot-mpr.sh copies mprcontents/ to the snapshot's top level, so +# the same relative path holds on a two-tree (app/) checkout too. +hv_rewritten() { + _hp="$(grep -E "^ MISMATCH +$1 " "$HV_BASE" | awk '{print $3}' | head -1)" + [ -n "$_hp" ] && [ -n "$SNAP_NOW" ] && [ -f "$SNAP_NOW/$_hp" ] && [ -f "$MODEL_DIR/$_hp" ] \ + && ! cmp -s "$SNAP_NOW/$_hp" "$MODEL_DIR/$_hp" +} +hv_first() { grep -v -e '^[[:space:]]*$' -e '^WARNING: This is a' "$1" | head -1; } +if [ "$PATCH_MODE" = "1" ]; then + echo "→ ContentsHash baseline: mxcli fix hashes (read-only)..." + _hv_s=0 + if [ -z "$PMXCLI" ]; then + echo " ⚠ NOT RUN — no project mxcli. A stale ContentsHash this patch leaves will go unseen:" + echo " mx check and mxbuild do not read the index." + HV_NOTE="hashes: NOT RUN (no project mxcli)" + else + hv_supported || _hv_s=$? + if [ "$_hv_s" -eq 1 ]; then + echo " ⚠ NOT RUN — this mxcli has no 'fix hashes' (arrived in v0.25.0). Upgrade it: a stale" + echo " ContentsHash this patch leaves is invisible to mx check and to the mxbuild gate." + HV_NOTE="hashes: NOT RUN (mxcli has no fix hashes; v0.25.0+)" + elif [ "$_hv_s" -eq 2 ]; then + echo " ⚠ NOT RUN — 'mxcli fix --help' itself failed, so support is unknown." + HV_NOTE="hashes: NOT RUN (fix --help failed)" + else + HV_BASE=$(mktemp "${TMPDIR:-/tmp}/exec-hv-base.XXXXXX") + hv_run "$HV_BASE" + case "$HV_STATE" in + clean) HV_ON=1; HV_NOTE="hashes: baseline clean"; echo " ✓ index matches every unit file" ;; + issues) HV_ON=1; HV_NOTE="hashes: baseline $(hv_tally "$HV_BASE") (pre-existing)" + echo " ⚠ the index is ALREADY out of step before this patch ($(grep -E '^[0-9]+ mismatch' "$HV_BASE"))." + echo " Not this patch's doing, and not held against it. If this patch rewrites one of these" + echo " units through bin/mxpatch.py, that write gives it a fresh hash and the verify below" + echo " says so. Repair the others on their own (rewrites the index from the files, never" + echo " the files): ./mxcli fix hashes -p $MPR_BASE --repair" ;; + *) echo " ⚠ NOT RUN — the baseline could not be classified (exit $HV_RC: $(hv_first "$HV_BASE"))." + HV_NOTE="hashes: NOT RUN (baseline unclassifiable, exit $HV_RC)" + rm -f "$HV_BASE"; HV_BASE="" ;; + esac + fi + fi +fi + # ── Execute ────────────────────────────────────────────────────────────────── # The exit status is CAPTURED, not allowed to trip `set -e`. # @@ -1316,8 +1417,98 @@ if [ "$PATCH_MODE" = "1" ]; then else log_build "❌ patch failed" "script exit $EXEC_STATUS — NOT rolled back (restore did not run)" fi + rm -f "$HV_BASE" exit "$EXEC_STATUS" fi + if [ "$HV_ON" = "1" ]; then + echo "→ ContentsHash verify: mxcli fix hashes (read-only)..." + HV_POST=$(mktemp "${TMPDIR:-/tmp}/exec-hv-post.XXXXXX") + hv_run "$HV_POST" + _hv_new="" + [ "$HV_STATE" = issues ] && _hv_new="$(hv_keys "$HV_POST" | LC_ALL=C comm -13 <(hv_keys "$HV_BASE") - )" + # A baseline MISMATCH that is STILL reported, on a unit the patch rewrote, is NEW too: the + # patch wrote that unit and left its hash stale again. Matching on KIND+ID alone read it as + # "pre-existing, none new" and kept the patch (#374 review; #390's field state exactly). + if [ "$HV_STATE" = issues ]; then + _hv_same="$(hv_keys "$HV_POST" | LC_ALL=C comm -12 <(hv_keys "$HV_BASE") - )" + while read -r _k _id; do + [ "$_k" = MISMATCH ] || continue + if hv_rewritten "$_id"; then + echo " ✗ this patch rewrote unit $_id and its ContentsHash is still stale: counted as new." + _hv_new="${_hv_new:+$_hv_new +}$_k $_id" + fi + done </dev/null 2>&1 || true; fi + rm -f "$HV_BASE" "$HV_POST" + if [ "$RESTORED" -eq 1 ]; then + log_build "❌ patch refused" "$_hv_detail — rolled back, gate not run" + else + log_build "❌ patch refused" "$_hv_detail — NOT rolled back (restore did not run)" + fi + exit 1 + fi + rm -f "$HV_BASE" "$HV_POST" + fi else "${PMXCLI:-./mxcli}" exec "$SCRIPT" -p "$MPR" || EXEC_STATUS=$? fi diff --git a/project-bin/mxpatch.py b/project-bin/mxpatch.py new file mode 100755 index 00000000..291895de --- /dev/null +++ b/project-bin/mxpatch.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +"""mxpatch.py -- the shared core of a targeted .mxunit patch (#294). + +WHY THIS EXISTS. A patch for something MDL cannot express (a widget inside a pluggable +container, a workflow activity's Caption, a node mxcli never emits) needs the same layer +every time: find the unit, find the element, read or set one value, write the unit back, +and keep the .mpr's ContentsHash index in step. Field report #294: 9 of 12 Python change +scripts on one production app re-implemented that layer, and two different helper +libraries grew in one project. The toolkit's own two patchers wrote the unit and never +touched the index -- every --apply left a stale ContentsHash for Studio Pro to trip on, +which `mx check` and mxbuild cannot see (mxcli `fix hashes --help`, v0.26.0: "'mx check' +does not notice; Studio Pro uses the index to decide what changed"). + +THE HASH. An MPR v2 .mpr indexes each mprcontents/**/.mxunit by +base64(SHA-256(file bytes)) in Unit.ContentsHash, keyed by the UnitID blob in .NET GUID +byte order. Verified, not assumed: identical to the stored value on all 27 units of the +captured fixture model (tests/wave2/fixtures/mxpatch/) and all 369 of mxcli's pedapp test +model, and on mxcli's expr-checker model it flags exactly the 67 units `mxcli fix hashes` +v0.26.0 flags as MISMATCH. write_unit() updates the row in the same transaction as the +file swap, so a patch never leaves the index behind. `bin/exec.sh --patch` then runs +`mxcli fix hashes` (v0.25.0+) as an independent check after the script exits 0. + +LIBRARY (what a patch script imports -- it lives beside this file): + + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import mxpatch + m = mxpatch.Model(sys.argv[1]) # .mpr, its directory, or a unit file in it + u = m.find_unit("Workflows$Workflow", "Approval", module="MyFirstModule") + act = mxpatch.find_element(u.doc, "ACT_Approval_Review", "Workflows$CallMicroflowActivity") + mxpatch.set_value(act, "Caption", "Review", expect="ACT_Approval_Review") + m.write_unit(u, apply=args.apply) # dry run unless apply=True + + set_value() returns "changed" or "already" (the new value is already there: a re-run is a + no-op) and raises Drift when the current value is neither the expected old one nor the new + one -- the model is not the one the patch was written against, so it refuses. + write_unit() returns "written", "dry-run" or "already" (encoded bytes identical to disk). + +CLI (dry run unless --apply; exit 0 ok/already/dry-run, 2 usage, 3 drift, 4 not found or +ambiguous, 5 hash index problem): + + mxpatch.py find --type T [--name N] [--module M] + mxpatch.py get --type T --name N [--module M] [--element E [--element-type ET]] --key K + mxpatch.py set --type T --name N [--module M] [--element E [--element-type ET]] \\ + --key K --value V [--expect OLD] [--apply] + mxpatch.py verify # every unit file against its ContentsHash (no mxcli needed) + mxpatch.py rehash ... [--apply] # re-index units another tool wrote + +Run a write under `./bin/exec.sh --patch