diff --git a/CHANGELOG.md b/CHANGELOG.md index ee102a06..95bed483 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 +- 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 `. `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) diff --git a/bin/wire-agents.sh b/bin/wire-agents.sh index 359efdae..5bae695e 100755 --- a/bin/wire-agents.sh +++ b/bin/wire-agents.sh @@ -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" @@ -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 @@ -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 @@ -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 diff --git a/tests/wave2/test-wire-agents-settings.sh b/tests/wave2/test-wire-agents-settings.sh new file mode 100755 index 00000000..ea24346c --- /dev/null +++ b/tests/wave2/test-wire-agents-settings.sh @@ -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" < "\$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 ]