From 3e59797b9ee79cb6b3bcfdffaf36eee3341b08cc Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 10:22:50 +0800 Subject: [PATCH 1/4] fix(sync-project, doctor): restore the look-ledger seen hook when a project lacks it A lab project's look ledger read 0 of 31 pages seen while 22 had screenshots: .claude/settings.json held only what mxcli init writes. install-claude-permissions.sh (the only writer of the hooks) had never run there, sync-project.sh only printed a warning, and doctor.sh checked the allow-list but not the hooks. install-claude-permissions.sh gains --hooks-only (hooks only, no allow/deny/ask entries, no pruning, nothing in settings.local.json). sync-project.sh runs it when the hooks are missing (--dry-run reports). doctor.sh warns and names the fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + bin/doctor.sh | 11 ++ bin/install-claude-permissions.sh | 18 ++- bin/sync-project.sh | 17 +++ tests/wave2/test-look-ledger-hook-repair.sh | 119 ++++++++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100755 tests/wave2/test-look-ledger-hook-repair.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index fc077b6d..836b8aa1 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(sync-project, doctor): **a project whose `.claude/settings.json` lacks the `look-ledger.sh seen` hook now gets it back on the next sync, and `doctor.sh` warns when it is missing.** In a lab project the look ledger read "0 of 31 pages seen" while 22 of them had screenshots: `install-claude-permissions.sh` (the only writer of the hook) had never run there, `sync-project.sh` only printed a warning in a long report, and `doctor.sh` checked the allow-list but not the two hooks, so `seen.tsv` stayed empty and any gate built on the ledger would have stalled the build. `install-claude-permissions.sh` gains `--hooks-only` (add the SessionStart and PostToolUse hooks, touch no allow, deny or ask entry, prune nothing, write nothing to `settings.local.json`); `sync-project.sh` runs it when the hooks are missing (`--dry-run` says what it would add); `doctor.sh` names the missing hook and the fix. `tests/wave2/test-look-ledger-hook-repair.sh` reproduces the gap, then proves detect, repair, idempotence and that a Read of a screenshot writes a seen row. — lab project field run (night 2026-10-16) - fix(app-restart): **a secret with spaces in quotes is masked whole, and `Pwd` and `Credential` join the secret words.** Follow-up to #403: `--constant "Mod.ApiKey=two words"` and `--db-password 'two words'` printed everything after the first word (the quoted-flag case leaked before #403 too). `mask()` now masks each argument before quoting it for display, so the whole quoted value goes; the executed command is untouched, and a real-launch test proves the started app receives the raw argv; an env assignment at the start of a `--cmd` string keeps the whole command visible; the per-argument seds only run for arguments holding a secret word. The substring match still over-masks names like `Monkey` (Key) and `Turkey`; that is display only, kept, and pinned by a test. `tests/wave2/test-app-restart.sh` gains 22 cases, 8 of them failing on master. — review of #403 - fix(install-manifest): **a clean toolkit checkout passes its own `project-bin/` manifest self-check, so adopters no longer see the "named by NEITHER list" warning at the top of every sync.** `coverage-check-all.sh` (found by `review-module.sh` through its `_tool` lookup in the toolkit clone) and `ts-sync.sh` (UNPROVEN and SUPERSEDED per its header) join `MXTK_PROJECT_BIN_NOINSTALL` with reasons. New `tests/wave2/test-manifest-clean-checkout.sh` fails, rather than warns, when a `project-bin/` file is in neither list. — joshuamoesa (#381) - fix(sync-project): **the "Baseline-only skills cited" warning no longer fires on the toolkit's own wiring block, and no longer sizes a section that does not exist.** The check grepped all of `CLAUDE.md` for `skills/learned-mdl-preflight.md` / `skills/query-the-model.md`, and `wire-agents.sh`'s `mxtk:wiring` block cites the first, so every sync flagged the toolkit's own text; with no "mxcli-project-toolkit Integration" heading it then printed a whole-file word count as "heading to EOF". The wiring block is now stripped before the grep, and the warning is raised only when the heading exists. `tests/wave2/test-sync-dup-heading-exit.sh` T4 flips (no heading: no warning) and gains T7-T9. — joshuamoesa (#382) diff --git a/bin/doctor.sh b/bin/doctor.sh index ef25d8b6..05b1bedd 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -1174,6 +1174,17 @@ if [ -n "$PROJECT_DIR" ]; then else ok "settings files match the profile" fi + # The two hooks. Without the PostToolUse(Read) look-ledger hook seen.tsv is never written, so the + # ledger says "0 seen" for pages whose screenshots were opened (field: a lab project, 22 of 31). + HOOK_GAP="" + grep -qF 'bin/session-check.sh' "$PSET" 2>/dev/null || HOOK_GAP="session-check (SessionStart)" + grep -qF 'bin/look-ledger.sh seen' "$PSET" 2>/dev/null || HOOK_GAP="${HOOK_GAP:+$HOOK_GAP and }look-ledger seen (PostToolUse Read)" + if [ -n "$HOOK_GAP" ]; then + warn "the project's .claude/settings.json has no hook for $HOOK_GAP, so the look ledger will read 0 seen however many screenshots were opened" + note "fix: $TOOLKIT_ROOT/bin/install-claude-permissions.sh $PROJECT_DIR --hooks-only (or bin/sync-project.sh $PROJECT_DIR)" + else + ok "session-check and look-ledger hooks are in .claude/settings.json" + fi for _pf in "$PSET" "$PLOCAL"; do if grep -qE '"defaultMode"[[:space:]]*:[[:space:]]*"(auto|bypassPermissions)"' "$_pf" 2>/dev/null; then warn "${_pf#$PROJECT_DIR/} sets defaultMode to auto/bypassPermissions — Claude Code ignores that in a project file" diff --git a/bin/install-claude-permissions.sh b/bin/install-claude-permissions.sh index e2e543a7..1560767c 100755 --- a/bin/install-claude-permissions.sh +++ b/bin/install-claude-permissions.sh @@ -95,6 +95,10 @@ # # entry is missing; writes nothing # bin/install-claude-permissions.sh --uninstall # remove only entries this # # script added; back up first +# bin/install-claude-permissions.sh --hooks-only [--check] +# # only the two hooks, nothing +# # else added, pruned or checked +# # (bin/sync-project.sh repairs with it) # # Idempotent: re-running --install after everything is already present changes nothing (both # files are rewritten byte-for-byte the same). Called from bin/init-project.sh at scaffold time @@ -109,15 +113,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOLKIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -USAGE="Usage: $0 [--check|--uninstall] [--profile wrappers|project|full]" +USAGE="Usage: $0 [--check|--uninstall|--hooks-only] [--profile wrappers|project|full]" MODE="install" PROJECT_DIR="" PROFILE_ARG="" +HOOKS_ONLY=0 while [ $# -gt 0 ]; do case "$1" in --check) MODE="check" ;; --uninstall) MODE="uninstall" ;; + --hooks-only) HOOKS_ONLY=1 ;; --profile) PROFILE_ARG="${2:-}"; shift ;; --profile=*) PROFILE_ARG="${1#--profile=}" ;; -h|--help) echo "$USAGE"; exit 0 ;; @@ -377,6 +383,8 @@ if allow is None: # wants. Entries someone else wrote are never in the sidecar, so never touched. pruned = [] for key, want, mine in (("allow", entries, added), ("deny", deny_entries, added_deny), ("ask", ask_entries, added_ask)): + if os.environ.get("MXTK_HOOKS_ONLY") == "1": + break # --hooks-only: add the hooks, never prune what an earlier full install wrote gone = [e for e in perms.get(key, []) if e in mine and e not in want] if gone: perms[key] = [e for e in perms[key] if e not in gone] @@ -442,6 +450,14 @@ PY } RC=0 +if [ "$HOOKS_ONLY" -eq 1 ]; then + # Hooks alone: no allow, deny or ask entries, nothing in settings.local.json, no pruning. A + # project whose settings predate the hooks (or whose `mxcli init` rewrote the file) gets the + # two hook lines back without its profile or its hand-edited allow-list being touched. + export MXTK_HOOKS_ONLY=1 + _merge_group "$SETTINGS" "$SIDECAR" "$MODE" "$HOOKS" 0 0 + exit $? +fi _merge_group "$SETTINGS" "$SIDECAR" "$MODE" "$HOOKS" "${#ENTRIES_SHARED[@]}" "${#DENY[@]}" "${ENTRIES_SHARED[@]}" "${DENY[@]}" ${ASK[@]+"${ASK[@]}"} || RC=$? _merge_group "$SETTINGS_LOCAL" "$SIDECAR_LOCAL" "$MODE" "" "${#ENTRIES_LOCAL[@]}" 0 "${ENTRIES_LOCAL[@]}" || { RC2=$? diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 5a8fa567..5fb59fc3 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -1576,6 +1576,23 @@ if [ "$WIRED" -eq 1 ] && [ -x "$SCRIPT_DIR/install-harness-permissions.sh" ]; th "$SCRIPT_DIR/install-harness-permissions.sh $PROJECT_DIR" echo "$_PERM_OUT" | sed 's/^/ /' fi + # The two hooks (SessionStart session-check, PostToolUse look-ledger seen) are the exception + # to "report only": adding a hook line is additive, touches no allow-list and no profile, and + # without the look-ledger hook seen.tsv stays empty so Stage 5 and the owed-LOOK count read + # "0 seen" for pages that were looked at (field: a lab project, 22 of 31 pages had screenshots, + # ledger said 0 seen). Warning alone did not get it fixed in unattended runs, so repair it. + if [ -x "$SCRIPT_DIR/install-claude-permissions.sh" ] \ + && ! "$SCRIPT_DIR/install-claude-permissions.sh" "$PROJECT_DIR" --hooks-only --check >/dev/null 2>&1; then + if [ "$DRY_RUN" -eq 1 ]; then + echo "Would add: Claude Code hooks (session-check, look-ledger seen) to .claude/settings.json" + elif "$SCRIPT_DIR/install-claude-permissions.sh" "$PROJECT_DIR" --hooks-only >/dev/null 2>&1; then + echo "Added: Claude Code hooks (session-check, look-ledger seen) to .claude/settings.json (it was missing them; the look ledger read 0 seen without it)." + else + warn "could not add the Claude Code hooks to $PROJECT_DIR/.claude/settings.json. Re-run:" \ + "$SCRIPT_DIR/install-claude-permissions.sh $PROJECT_DIR --hooks-only" + fi + CHANGES=$((CHANGES + 1)) + fi fi # ── Starlark lint rules ────────────────────────────────────────────────────────────────── diff --git a/tests/wave2/test-look-ledger-hook-repair.sh b/tests/wave2/test-look-ledger-hook-repair.sh new file mode 100755 index 00000000..2ca7d413 --- /dev/null +++ b/tests/wave2/test-look-ledger-hook-repair.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# test-look-ledger-hook-repair.sh: a project whose .claude/settings.json has no +# `look-ledger.sh seen` hook must be DETECTED (doctor.sh) and REPAIRED (sync-project.sh), and the +# repaired hook must actually write a seen row when a screenshot is opened. +# +# Field case: a lab project's ledger read "0 of 31 pages seen" while 22 had screenshots. Its +# settings.json held only what `mxcli init` writes: install-claude-permissions.sh had never run +# there (no sidecar, no exec.sh allow-list either), the only later caller (sync-project.sh) only +# printed a warning, and doctor.sh looked at the allow-list but never at the hooks. +# +# H1 fixture reproduces the gap (hooks-only --check exits 1, names the look hook) +# H2 doctor.sh warns and names the fix +# H3 sync-project.sh --dry-run says it would add the hooks and writes nothing +# H4 sync-project.sh adds both hooks, keeps the allow-list the project had, and is idempotent +# H5 --hooks-only on a project that has a full install does not prune or add allow entries +# H6 end to end: a Read hook payload for a fresh screenshot writes a seen.tsv row +# +# Usage: bash tests/wave2/test-look-ledger-hook-repair.sh +export MXTK_LEAKGUARD_DENYFILE="${TMPDIR:-/tmp}/mxtk-fixture-denylist.$$" +export MXTK_SYNC_SKIP_CLONE_CHECK=1 +set -uo pipefail +TK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="$(mktemp -d /tmp/lookhook.XXXXXX)" +trap 'rm -f "$MXTK_LEAKGUARD_DENYFILE"; rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ FAIL-detail: /'; } + +P="$WORK/proj" +MXTK_NO_GUIDE=1 "$TK/bin/init-project.sh" "$P" >/dev/null 2>&1 +# What `mxcli init` writes and nothing else: no toolkit hooks, no wrapper allow-list, no sidecar. +mkdir -p "$P/.claude" +cat > "$P/.claude/settings.json" <<'JSON' +{ + "env": {"MXCLI_QUIET": "1"}, + "hooks": {"SessionStart": [{"hooks": [{"command": "sh .claude/bootstrap-mxcli.sh || true", "type": "command"}]}]}, + "permissions": {"allow": ["Bash(mxcli:*)", "Bash(./mxcli:*)"]} +} +JSON +rm -f "$P/.claude/.mxtk-permissions-added.json" "$P/.claude/settings.local.json" + +# H1 +out="$("$TK/bin/install-claude-permissions.sh" "$P" --hooks-only --check 2>&1)"; rc=$? +{ [ "$rc" = 1 ] && printf '%s' "$out" | grep -q 'look-ledger.sh seen'; } \ + && ok "H1: gap reproduced, hooks-only --check exits 1 and names the look-ledger hook" \ + || bad "H1: rc=$rc" "$out" + +# H2 +out="$(bash "$TK/bin/doctor.sh" "$P" 2>&1)" +{ printf '%s' "$out" | grep -q 'no hook for' && printf '%s' "$out" | grep -q 'look-ledger seen' \ + && printf '%s' "$out" | grep -q -- '--hooks-only'; } \ + && ok "H2: doctor warns about the missing hooks and names the fix" \ + || bad "H2: doctor did not flag the missing hook" "$(printf '%s' "$out" | grep -i 'hook\|permission' | head)" + +# H3 +before="$(md5 -q "$P/.claude/settings.json" 2>/dev/null || md5sum "$P/.claude/settings.json" | cut -d' ' -f1)" +out="$("$TK/bin/sync-project.sh" "$P" --dry-run 2>&1)" +after="$(md5 -q "$P/.claude/settings.json" 2>/dev/null || md5sum "$P/.claude/settings.json" | cut -d' ' -f1)" +{ printf '%s' "$out" | grep -q 'Would add: Claude Code hooks' && [ "$before" = "$after" ]; } \ + && ok "H3: --dry-run says it would add the hooks and leaves settings.json alone" \ + || bad "H3: dry-run output or write wrong" "$out" + +# H4 +out="$("$TK/bin/sync-project.sh" "$P" 2>&1)" +S="$P/.claude/settings.json" +grep -qF 'bash bin/look-ledger.sh seen || true' "$S" && grep -qF 'bash bin/session-check.sh || true' "$S" \ + && printf '%s' "$out" | grep -q 'Added: Claude Code hooks' \ + && ok "H4: sync-project.sh added both hooks" || bad "H4: hooks not added" "$out" +grep -qF 'sh .claude/bootstrap-mxcli.sh' "$S" && grep -qF '"Bash(./mxcli:*)"' "$S" \ + && ok "H4: the project's own hook and allow entries are kept" || bad "H4: existing entries lost" +sum1="$(cat "$S")" +"$TK/bin/sync-project.sh" "$P" >/dev/null 2>&1 +[ "$sum1" = "$(cat "$S")" ] && ok "H4: a second sync changes nothing" || bad "H4: second sync rewrote settings.json" +out="$(bash "$TK/bin/doctor.sh" "$P" 2>&1)" +printf '%s' "$out" | grep -q 'hooks are in .claude/settings.json' \ + && ok "H4: doctor now passes the hook check" || bad "H4: doctor still flags the hook" + +# H5: a project with a full install, then a custom allow-list edit; hooks-only must not prune it +Q="$WORK/full"; cp -R "$P" "$Q" +python3 - "$Q/.claude/settings.json" <<'PY' +import json,sys +p=sys.argv[1]; d=json.load(open(p)); d["hooks"].pop("PostToolUse",None) +d["permissions"]["allow"].append("Bash(my-own-tool:*)"); json.dump(d,open(p,"w"),indent=2) +PY +"$TK/bin/install-claude-permissions.sh" "$Q" >/dev/null 2>&1 +python3 - "$Q/.claude/settings.json" <<'PY' +import json,sys +p=sys.argv[1]; d=json.load(open(p)); d["hooks"].pop("PostToolUse",None); json.dump(d,open(p,"w"),indent=2) +PY +allow_before="$(python3 -c 'import json,sys;print(sorted(json.load(open(sys.argv[1]))["permissions"]["allow"]))' "$Q/.claude/settings.json")" +"$TK/bin/install-claude-permissions.sh" "$Q" --hooks-only >/dev/null 2>&1 +allow_after="$(python3 -c 'import json,sys;print(sorted(json.load(open(sys.argv[1]))["permissions"]["allow"]))' "$Q/.claude/settings.json")" +{ [ "$allow_before" = "$allow_after" ] && grep -qF 'look-ledger.sh seen' "$Q/.claude/settings.json"; } \ + && ok "H5: --hooks-only restored the hook and left the allow-list as it was" \ + || bad "H5: allow-list changed or hook missing" "$allow_before +$allow_after" + +# H6: the hook command as written in settings.json, fed a Read payload for a fresh screenshot +cmd="$(python3 - "$S" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])) +for g in d["hooks"]["PostToolUse"]: + if g.get("matcher")=="Read": + print(g["hooks"][0]["command"]) +PY +)" +mkdir -p "$P/mdlsource" "$P/docs/looks"; : > "$P/App.mpr" # the ledger finds its root by the model file +printf 'CREATE PAGE Orders.Order_Overview { }\n' > "$P/mdlsource/10-pages.mdl" +touch -t 202001010000 "$P/mdlsource/10-pages.mdl" +( cd "$P" && bash bin/look-ledger.sh owe "$P/mdlsource/10-pages.mdl" >/dev/null 2>&1 ) +touch -t 202001010000 "$P/mdlsource/10-pages.mdl"; sleep 1 +printf 'png' > "$P/docs/looks/order-overview.png" +printf '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"%s"},"tool_response":{}}' \ + "$P/docs/looks/order-overview.png" | ( cd "$P" && bash -c "$cmd" ) +n="$(grep -c 'order-overview.png' "$P/.claude/loop/look/seen.tsv" 2>/dev/null)" +[ "${n:-0}" -ge 1 ] && ok "H6: opening a screenshot through the installed hook wrote a seen row" \ + || bad "H6: seen.tsv has no row" "$(cat "$P/.claude/loop/look/seen.tsv" 2>&1)" + +echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] From 20daa5aa4f9d5b4c6bc596007a675b67c1e94867 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 18:33:27 +0800 Subject: [PATCH 2/4] fix(portability): resolve Python through portable.sh in the look-ledger hook repair test Source bin/lib/portable.sh, call require_py and use "$PY". The hook command extraction no longer opens a heredoc inside $(...), which bash 3.2 cannot parse. Co-Authored-By: Claude Opus 5.5 --- tests/wave2/test-look-ledger-hook-repair.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/wave2/test-look-ledger-hook-repair.sh b/tests/wave2/test-look-ledger-hook-repair.sh index 00fec29f..10158f29 100755 --- a/tests/wave2/test-look-ledger-hook-repair.sh +++ b/tests/wave2/test-look-ledger-hook-repair.sh @@ -25,6 +25,7 @@ export MXTK_LEAKGUARD_DENYFILE="${TMPDIR:-/tmp}/mxtk-fixture-denylist.$$" export MXTK_SYNC_SKIP_CLONE_CHECK=1 set -uo pipefail TK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +. "$TK/bin/lib/portable.sh"; require_py WORK="$(mktemp -d /tmp/lookhook.XXXXXX)" trap 'rm -f "$MXTK_LEAKGUARD_DENYFILE"; rm -rf "$WORK"' EXIT PASS=0; FAIL=0 @@ -122,33 +123,33 @@ printf '%s' "$out" | grep -q 'hooks are in .claude/settings.json' \ # H5: a project with a full install, then a custom allow-list edit; hooks-only must not prune it Q="$WORK/full"; cp -R "$P" "$Q" -python3 - "$Q/.claude/settings.json" <<'PY' +"$PY" - "$Q/.claude/settings.json" <<'PY' import json,sys p=sys.argv[1]; d=json.load(open(p)); d["hooks"].pop("PostToolUse",None) d["permissions"]["allow"].append("Bash(my-own-tool:*)"); json.dump(d,open(p,"w"),indent=2) PY "$TK/bin/install-claude-permissions.sh" "$Q" >/dev/null 2>&1 -python3 - "$Q/.claude/settings.json" <<'PY' +"$PY" - "$Q/.claude/settings.json" <<'PY' import json,sys p=sys.argv[1]; d=json.load(open(p)); d["hooks"].pop("PostToolUse",None); json.dump(d,open(p,"w"),indent=2) PY -allow_before="$(python3 -c 'import json,sys;print(sorted(json.load(open(sys.argv[1]))["permissions"]["allow"]))' "$Q/.claude/settings.json")" +allow_before="$("$PY" -c 'import json,sys;print(sorted(json.load(open(sys.argv[1]))["permissions"]["allow"]))' "$Q/.claude/settings.json")" "$TK/bin/install-claude-permissions.sh" "$Q" --hooks-only >/dev/null 2>&1 -allow_after="$(python3 -c 'import json,sys;print(sorted(json.load(open(sys.argv[1]))["permissions"]["allow"]))' "$Q/.claude/settings.json")" +allow_after="$("$PY" -c 'import json,sys;print(sorted(json.load(open(sys.argv[1]))["permissions"]["allow"]))' "$Q/.claude/settings.json")" { [ "$allow_before" = "$allow_after" ] && grep -qF 'look-ledger.sh seen' "$Q/.claude/settings.json"; } \ && ok "H5: --hooks-only restored the hook and left the allow-list as it was" \ || bad "H5: allow-list changed or hook missing" "$allow_before $allow_after" # H6: the hook command as written in settings.json, fed a Read payload for a fresh screenshot -cmd="$(python3 - "$S" <<'PY' +IFS= read -r -d '' hookpy <<'PY' import json,sys d=json.load(open(sys.argv[1])) for g in d["hooks"]["PostToolUse"]: if g.get("matcher")=="Read": print(g["hooks"][0]["command"]) PY -)" +cmd="$("$PY" -c "$hookpy" "$S")" mkdir -p "$P/mdlsource" "$P/docs/looks"; : > "$P/App.mpr" # the ledger finds its root by the model file printf 'CREATE PAGE Orders.Order_Overview { }\n' > "$P/mdlsource/10-pages.mdl" touch -t 202001010000 "$P/mdlsource/10-pages.mdl" From d0c88649b56e4d9adc1eb14fae58db8e08aeba62 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 20:26:19 +0800 Subject: [PATCH 3/4] test(look-ledger-hook): write the settings.json fixture in canonical layout --hooks-only refuses to rewrite a hand-formatted file (exit 3). The one-line-hook fixture only passed on hosts where mxcli on PATH had normalised the file first, and failed on the CI runner (H4/H6, run 38897076571). Co-Authored-By: Claude Opus 5.5 --- tests/wave2/test-look-ledger-hook-repair.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/wave2/test-look-ledger-hook-repair.sh b/tests/wave2/test-look-ledger-hook-repair.sh index 10158f29..9387d910 100755 --- a/tests/wave2/test-look-ledger-hook-repair.sh +++ b/tests/wave2/test-look-ledger-hook-repair.sh @@ -36,13 +36,16 @@ P="$WORK/proj" MXTK_NO_GUIDE=1 "$TK/bin/init-project.sh" "$P" >/dev/null 2>&1 # What `mxcli init` writes and nothing else: no toolkit hooks, no wrapper allow-list, no sidecar. mkdir -p "$P/.claude" -cat > "$P/.claude/settings.json" <<'JSON' -{ - "env": {"MXCLI_QUIET": "1"}, - "hooks": {"SessionStart": [{"hooks": [{"command": "sh .claude/bootstrap-mxcli.sh || true", "type": "command"}]}]}, - "permissions": {"allow": ["Bash(mxcli:*)", "Bash(./mxcli:*)"]} -} -JSON +# Canonical json.dumps(indent=2) layout, like a file a tool wrote: --hooks-only refuses to rewrite +# a hand-formatted file (exit 3), so a one-line-per-hook fixture only passed where something else +# had already normalised it (a host with mxcli on PATH), and failed on the CI runner. +python3 - "$P/.claude/settings.json" <<'PY' +import json, sys +d = {"env": {"MXCLI_QUIET": "1"}, + "hooks": {"SessionStart": [{"hooks": [{"command": "sh .claude/bootstrap-mxcli.sh || true", "type": "command"}]}]}, + "permissions": {"allow": ["Bash(mxcli:*)", "Bash(./mxcli:*)"]}} +open(sys.argv[1], "w").write(json.dumps(d, indent=2) + "\n") +PY rm -f "$P/.claude/.mxtk-permissions-added.json" "$P/.claude/settings.local.json" # H1 From ad4bc8f506bcebf234b5a2eaa03f10e5ecb2ff63 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 20:30:56 +0800 Subject: [PATCH 4/4] test(look-ledger-hook): fixture uses the resolved $PY, not python3 Co-Authored-By: Claude Opus 5.5 --- tests/wave2/test-look-ledger-hook-repair.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/wave2/test-look-ledger-hook-repair.sh b/tests/wave2/test-look-ledger-hook-repair.sh index 9387d910..3a08a312 100755 --- a/tests/wave2/test-look-ledger-hook-repair.sh +++ b/tests/wave2/test-look-ledger-hook-repair.sh @@ -39,7 +39,7 @@ mkdir -p "$P/.claude" # Canonical json.dumps(indent=2) layout, like a file a tool wrote: --hooks-only refuses to rewrite # a hand-formatted file (exit 3), so a one-line-per-hook fixture only passed where something else # had already normalised it (a host with mxcli on PATH), and failed on the CI runner. -python3 - "$P/.claude/settings.json" <<'PY' +"$PY" - "$P/.claude/settings.json" <<'PY' import json, sys d = {"env": {"MXCLI_QUIET": "1"}, "hooks": {"SessionStart": [{"hooks": [{"command": "sh .claude/bootstrap-mxcli.sh || true", "type": "command"}]}]},