Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m

## Unreleased

- fix(wire-agents): **a sync re-wire no longer wipes `.claude/settings.json`.** `mxcli init` overwrites that file; wire-agents now snapshots it and merges after init: the project's entries win and keep their order (permission profile, user entries, the `mxcli exec` deny rule, hooks), and entries that only mxcli's new file has are appended. Merged rather than added to PRESERVE because PRESERVE restores old bytes and would also drop anything a newer mxcli adds; an unparsable snapshot is restored byte for byte. Test: `tests/wave2/test-wire-agents-settings.sh` (4 of 6 checks fail on master). — review of #414 (#419)
- fix(sync): **the exec-approval item-3 rewrite now works on BSD awk (stock macOS) and `sync-project.sh` no longer prints "Rewrote" for a rewrite that failed.** `_ea_rewrite_item3` passed the multi-line replacement through `awk -v`, which BSD awk rejects ("newline in string", exit 2); the file stayed unchanged and sync still reported the rewrite on every run. The replacement is now streamed from a temp file (as the Q9 repair does), the helper returns 2 when awk fails, and the caller prints a `WARN:` instead of "Rewrote" and does not count a change. `test-exec-approval.sh` gains 3 assertions (failing-awk shim: no "Rewrote", a WARN, file unchanged). No other multi-line `awk -v` call site exists (the rest are single-line values) ([#426](https://github.com/MendixMau/mxcli-project-toolkit/issues/426)) — MendixMau
- process(testing): **unit tests are now off by default and `affected-tests.sh` enforces it; each project picks its test strategy with the developer at CAC-5.** Fast-tracked by the maintainer on 2026-10-16 because users report tests firing on every change (the baseline-text A/B is waived; E3 follows as a check). Moments: every change = gate plus what it touched (unit only for affected modules with unit on); module done = that module's journeys (+ its unit tests if on); build complete and before every later deploy = everything once; "run everything" any time. The CAC-5 Offer records it in PROJECT.md `## Test strategy` (Status `ASSUMED` in auto mode). `affected-tests.sh` reads `Unit tests default:` (`no` or absent means unit off, one `unit off` line, never a `.test.mdl` run row); turn unit on per project with `Unit tests default: yes`, per module with `Unit modules: Shop, Admin` in PROJECT.md or `Unit tests: yes` in the module brief; the strategy row's Unit cell is a scope only and never turns unit on. A grant on a microflow or page the same script changes is part of that subject and no longer selects the owning module's tests. `module-review.md` PROVE now runs `--moment module --module <M>`. `testing-shape.md`, `checkpoint-build.md`, `iterative-build-loop.md`, `conversion-runbook.md` and the exec.sh logic lane text updated. `tests/wave2/test-test-strategy-docs.sh` and the extended `test-affected-tests.sh` pin it. Baseline words stay at or below master (75,219 vs 75,225). Split out of #409; found by the field test of the release candidate — the maintainer's decision, 2026-10-16
- learn(skills): **five lab findings land as compact rows, each paid for by trimming prose in the same files, so always-loaded words do not grow (baseline 75,225 to 75,204, budget 80,000).** Enum value `New` and `AutoCreatedDate`/`AutoOwner` (detection gaps), `[%CurrentDateTime%]` not `currentDateTime()`, expression return types (Decimal from date diffs and `div`, absolute `secondsBetween`, `formatDecimal` on pages), CE2729 from the default home page and page microflow grants, Atlas selector carriers for list views, page body and timelines, `-small` spacing suffixes do not exist, `display: contents` rows paint nothing, CSS pseudo text is invisible to `innerText` — lab field runs, Refs #276 (TK-020, TK-021, TK-022, TK-024)
Expand Down
77 changes: 77 additions & 0 deletions bin/wire-agents.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLKIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
. "$SCRIPT_DIR/lib/wiring-item3.sh"
. "$SCRIPT_DIR/lib/portable.sh"

DEFAULT_TOOLS="claude copilot cursor continue windsurf aider"
OPTIN_TOOLS="opencode vibe"
Expand Down Expand Up @@ -329,6 +330,16 @@ for rel in $PRESERVE; do
fi
done

# .claude/settings.json is NOT in PRESERVE on purpose. PRESERVE restores the old bytes, which
# would also throw away anything a newer mxcli adds to that file. It is merged instead: snapshot
# now, union after init (see the re-merge below). Issue #419.
SETTINGS_REL=".claude/settings.json"
SETTINGS_SNAP=""
if [ -z "$DRY" ] && [ -f "$PROJECT_DIR/$SETTINGS_REL" ]; then
SETTINGS_SNAP="$BACKUP_DIR/.claude_settings.json.$STAMP_TS"
cp "$PROJECT_DIR/$SETTINGS_REL" "$SETTINGS_SNAP"
fi

TOOL_ARGS=""
for t in $TOOLS; do TOOL_ARGS="$TOOL_ARGS --tool $t"; done

Expand All @@ -338,6 +349,11 @@ else
echo "Running: ./mxcli init$TOOL_ARGS"
( cd "$PROJECT_DIR" && "$MXCLI" init $TOOL_ARGS >/dev/null 2>&1 ) || {
echo "wire-agents.sh: mxcli init failed. Nothing was stamped; your backups are in .mxtk-backup/." >&2
# init may have written its stock file before failing: put the project's own copy back.
if [ -n "$SETTINGS_SNAP" ]; then
cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" \
&& echo "wire-agents.sh: restored .claude/settings.json from the snapshot." >&2
fi
exit 1
}
fi
Expand Down Expand Up @@ -385,6 +401,67 @@ if [ -z "$DRY" ]; then
done
fi

# Re-merge .claude/settings.json after `mxcli init` overwrote it (issue #419). The project's
# snapshot wins on every scalar and keeps every list entry in its own order (permission profile,
# user entries, the `mxcli exec` deny rule, hooks); entries and keys that only mxcli's new file
# has are appended or added. Nothing the project had is ever dropped. A snapshot that is not
# valid JSON is restored byte for byte instead of guessed at.
if [ -z "$DRY" ] && [ -n "$SETTINGS_SNAP" ]; then
# Never worse than before the re-wire: no Python, or a failed merge, puts the snapshot back
# byte for byte (and warns) instead of leaving mxcli's stock file. Stock entries a user had
# deleted come back on each re-wire (a union cannot see deletions).
SETTINGS_PY="$(resolve_py)" || SETTINGS_PY=""
if [ -z "$SETTINGS_PY" ]; then
cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL"
echo "WARN: wire-agents.sh: no Python 3 found; restored .claude/settings.json byte for byte, new mxcli entries NOT merged. Install python3 and re-run." >&2 # portability-ok: diagnostic
else
# `if !` keeps a non-zero merge exit reachable under set -e.
if ! "$SETTINGS_PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE'
import json, sys, shutil
snap, cur = sys.argv[1], sys.argv[2]
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
try:
old = load(snap)
except Exception:
shutil.copyfile(snap, cur)
print("Restored .claude/settings.json byte for byte (previous copy was not valid JSON).")
sys.exit(0)
try:
new = load(cur)
except Exception:
new = None
def merge(o, n):
if isinstance(o, dict) and isinstance(n, dict):
out = dict(o)
for k, v in n.items():
out[k] = merge(o[k], v) if k in o else v
return out
if isinstance(o, list) and isinstance(n, list):
out = list(o)
for x in n:
if x not in out:
out.append(x)
return out
return o
res = merge(old, new) if new is not None else old
if res != old or new is None:
with open(cur, "w", encoding="utf-8") as f:
json.dump(res, f, indent=2, ensure_ascii=False)
f.write("\n")
print("Re-merged .claude/settings.json after mxcli init (your entries kept, new mxcli entries added).")
else:
shutil.copyfile(snap, cur)
print("Kept .claude/settings.json (mxcli init added nothing new).")
PYMERGE
then
cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL"
echo "WARN: wire-agents.sh: settings merge failed; restored .claude/settings.json byte for byte." >&2
fi
fi
fi

# --- stamp ------------------------------------------------------------------------------------
for rel in $STAMP_MD; do stamp_file "$PROJECT_DIR/$rel" md; done
for rel in $STAMP_HASH; do stamp_file "$PROJECT_DIR/$rel" hash; done
Expand Down
100 changes: 100 additions & 0 deletions tests/wave2/test-wire-agents-settings.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# test-wire-agents-settings.sh - a re-wire (wire-agents.sh, run by sync-project.sh when --check
# fails) must not lose .claude/settings.json content to `mxcli init` (issue #419).
#
# Fixture: a stub mxcli whose `init` overwrites settings.json with a stock file (as the real one
# does) and adds one new entry. The project file holds a profile entry, a user entry, the
# `mxcli exec` deny rule and a hook. All must survive; the new mxcli entry must also arrive.
#
# usage: bash test-wire-agents-settings.sh [path-to-wire-agents.sh]
set -uo pipefail
TOOLKIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SUBJECT="${1:-$TOOLKIT/bin/wire-agents.sh}"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo " ok - $1"; }
bad() { FAIL=$((FAIL+1)); echo " FAIL - $1"; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/wasettings.XXXXXX")" || exit 2
trap 'rm -rf "$WORK"' EXIT
echo "== subject: $SUBJECT"

P="$WORK/proj"; mkdir -p "$P/.claude"; : > "$P/Fixture.mpr"
cat > "$P/mxcli" <<'MXCLI'
#!/bin/sh
case "$1" in
init)
mkdir -p .claude
printf '{"permissions":{"allow":["Bash(./mxcli *)","Bash(mxcli-new-thing)"]}}\n' > .claude/settings.json
for f in AGENTS.md CLAUDE.md .github/copilot-instructions.md .cursorrules .windsurfrules .aider.conf.yml; do
mkdir -p "$(dirname "$f")"; [ -f "$f" ] || printf '# stub\n' > "$f"
done ;;
esac
exit 0
MXCLI
chmod +x "$P/mxcli"
cat > "$P/.claude/settings.json" <<'JSON'
{
"permissions": {
"allow": ["Bash(./mxcli *)", "Bash(git status)", "Edit(/docs/**)"],
"deny": ["Bash(./mxcli exec*)", "Bash(rm -rf *)"]
},
"hooks": {"PostToolUse": [{"matcher": "Read", "hooks": [{"type": "command", "command": "echo für"}]}]}
}
JSON

bash "$SUBJECT" "$P" >/dev/null 2>&1
S="$P/.claude/settings.json"
has() { grep -qF -- "$1" "$S"; }
has 'Edit(/docs/**)' && ok "profile entry survives re-wire" || bad "profile entry lost"
has 'Bash(git status)' && ok "user entry survives re-wire" || bad "user entry lost"
has 'Bash(./mxcli exec*)' && ok "mxcli exec deny rule survives" || bad "mxcli exec deny rule lost"
has '"matcher": "Read"' && ok "user hook survives" || bad "user hook lost"
has 'mxcli-new-thing' && ok "entry new in mxcli init is also added" || bad "new mxcli entry not added"
n1="$(cksum < "$S")"; bash "$SUBJECT" "$P" >/dev/null 2>&1
[ "$n1" = "$(cksum < "$S")" ] && ok "second re-wire changes nothing" || bad "second re-wire changed the file"

# --- failure paths: the result must never be worse than before the re-wire --------------------
ORIG='{ "permissions": { "allow": ["Bash(git status)"] }, "keep": "für" }'
mkproj() { # $1 dir, $2 init exit code
mkdir -p "$1/.claude"; : > "$1/Fixture.mpr"
sed "s/^exit 0\$/exit $2/" "$P/mxcli" > "$1/mxcli"; chmod +x "$1/mxcli"
printf '%s\n' "$ORIG" > "$1/.claude/settings.json"; cp "$1/.claude/settings.json" "$1/orig"
}
# 1. mxcli init writes its stock file then exits 3
F="$WORK/fail"; mkproj "$F" 3
bash "$SUBJECT" "$F" >/dev/null 2>&1; rc=$?
[ "$rc" -ne 0 ] && cmp -s "$F/orig" "$F/.claude/settings.json" \
&& ok "failing mxcli init: snapshot restored byte for byte" || bad "failing mxcli init left stock file (rc=$rc)"
# 2. no python on PATH
N="$WORK/nopy"; mkproj "$N" 0
BIN="$WORK/bin"; mkdir -p "$BIN"
for c in bash sh cp cat mkdir rm mv date dirname basename grep sed awk tr sort uniq head tail wc cksum shasum md5 md5sum cmp env mktemp ls find xargs chmod touch printf tee readlink pwd id uname git diff; do
w="$(command -v "$c" 2>/dev/null)" && [ -x "$w" ] && ln -sf "$w" "$BIN/$c"
done
PATH="$BIN" bash "$SUBJECT" "$N" >"$WORK/nopy.out" 2>&1
cmp -s "$N/orig" "$N/.claude/settings.json" \
&& ok "no python: snapshot restored byte for byte" || bad "no python left a changed settings.json" # portability-ok: test name, not an interpreter call
grep -q "WARN" "$WORK/nopy.out" && ok "no python: WARN printed" || bad "no python: no WARN"

# 3/4. python3 passes the version probe (-c) but the merge fails; the second one truncates the
# target first. Both must restore byte for byte, warn, stamp, and exit 0.
REALPY="$(command -v python3)" # portability-ok: finds the real interpreter to wrap
for mode in fail truncate; do
M="$WORK/merge-$mode"; mkproj "$M" 0
FB="$WORK/fakepy-$mode"; mkdir -p "$FB"
cat > "$FB/python3" <<FAKE # portability-ok: deliberate fake interpreter simulating a broken one
#!/bin/sh
[ "\$1" = "-c" ] && exec "$REALPY" "\$@"
[ "$mode" = "truncate" ] && : > "\$3"
exit 1
FAKE
chmod +x "$FB/python3" # portability-ok: deliberate fake interpreter
PATH="$FB:$PATH" bash "$SUBJECT" "$M" >"$WORK/merge-$mode.out" 2>&1; rc=$?
cmp -s "$M/orig" "$M/.claude/settings.json" \
&& ok "failed merge ($mode): snapshot restored byte for byte" || bad "failed merge ($mode): settings.json differs"
grep -q "WARN" "$WORK/merge-$mode.out" && ok "failed merge ($mode): WARN printed" || bad "failed merge ($mode): no WARN"
grep -q "mxtk:wiring:start" "$M/AGENTS.md" && ok "failed merge ($mode): stamped" || bad "failed merge ($mode): not stamped"
[ "$rc" -eq 0 ] && ok "failed merge ($mode): rc 0" || bad "failed merge ($mode): rc $rc"
done

echo "passed $PASS, failed $FAIL"
[ "$FAIL" -eq 0 ]
Loading