diff --git a/CHANGELOG.md b/CHANGELOG.md index fe29d880..6d6542cb 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. Review follow-up: sync repairs only a project that installed the profile before (sidecar with a non-empty allow, or `.mxtk/permission-profile`; checked by `--consent-check`), otherwise it WARNs with the one-line `--hooks-only` command; `.mxtk/no-hook-repair` is the documented opt-out (sync and doctor); `--hooks-only` creates no empty allow/deny, leaves no `.bak`, keeps the file's indent and non-ASCII, and leaves a hand-formatted file untouched (exit 3, lines printed). — lab project field run (night 2026-10-16) - new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free `PBUG-NN`, project-local so it never collides with the toolkit's BUG-n; the public number is the issue; the one-line instruction sits in learned-detection-gaps.md). The script normalises Unicode (NFKC, zero-width and lookalike letters, names split by a line break), redacts every title and body (home and spaced paths, user and host names, emails, IPv4/IPv6, tokens, key=value, JSON and `--flag value` secrets, hex tokens, AWS keys, any dotted hostname, server names from config files, URLs other than the two repos matched exactly and cut to the repo prefix plus /issues/N or /pull/N, a URL scheme from a fixed list (jdbc, postgres, mysql, mongodb, redis, amqp, ssh, git and the like) stays and loses the host, any other scheme becomes ``, the project's .mpr, module, entity, attribute, page and microflow names (distinctive ones inside identifiers; ordinary-word and short names only as whole words or segments, never inside mxcli verbs, toolkit file names or other words), Authorization and cookie header values, `$LEAKGUARD_DENY` as a regex plus the project's .leakguard-deny), builds the versions line from version-shaped strings only, scans again with the same matcher and refuses a bug that still looks unsafe: a refused bug gets no duplicate search, no URL and no text in `--links`/`--json`. Default is a dry run with likely duplicates; posting needs `--file PBUG-NN` or `--all` and a y/N per bug read from /dev/tty only (no stdin test hook; a pty cannot be told from a human, so the shipped permissions deny `file-bugs.sh` and `file-bugs.py` with `--file` or `--all`, abbreviations are refused, and a human runs posting with `!`), and a deny list must exist and be valid (an invalid pattern counts as missing) or posting, `--links` and `--json` are refused, passes `--repo= --title= --body-file=` to gh, keeps the log's file mode; `--links` caps title and body under the URL limit. `tests/wave2/test-file-bugs.sh` covers each case with a stubbed gh and a pty helper — MendixMau - learn(unit-test): **new on-demand skill for writing and running a microflow unit test (`.test.mdl`, `mxcli test`) whose assertion is live: what is worth a test and what is not, equality-only `@expect` (BUG-LOCAL-30), no file-level header (BUG-LOCAL-29), `--list` count against `/` separators before any run, `test-scratch.sh` so the live app survives (#278), the BUG-157 after-startup trap, a positive control that must fail, and a four-line report.** It gathers the how that was spread over `testing-shape.md`, `rest-between-mendix-apps.md` and the bug log into one file a user can ask for outside the pipeline; whether and when unit tests run stays with the project's test strategy. Routed ondemand for lead, mdl, test and review; one row in `testing-shape.md`'s owner table. Field evidence: projects on mxcli v0.19 to v0.27, Mendix 11.13 to 11.15, 2026-08 to 2026-10 — MendixMau - process(change-intake): **a change request now updates the module brief and stays visible until it is built.** The rule "most BRD edits need no brief edit" let a CR on a closed module leave the brief's Build steps stale, so `context-pack.sh` had no row for it and nothing counted it. Now the BRD stays the source of truth, the brief gets a CR-tagged Build steps row for any CR touching build steps, screens, access or rules, every CR that adds work gets a build-plan row tagged `CR-NN` (S: the module's phase, M/L: a change round), and agreed unbuilt CRs are "waiting" and are surfaced at session start and module close. RESUME.md gains a `Waiting changes:` line. `exec.sh --cr CR-NN` WARNs (never blocks) when the brief of a module the script writes to does not mention the id, for the folder brief and the merged build-plan form. Skill word counts at or below master (baseline 75,204 to 75,203, budget 80,000). A full unplanned-changes view and change rounds come in a follow-up. Test: `tests/wave2/test-exec-cr-brief-warn.sh` (19 checks, 6 fail on master's exec.sh) — MendixMau diff --git a/bin/doctor.sh b/bin/doctor.sh index 9383f91a..2d0c8f7c 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -1177,6 +1177,19 @@ 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" ] && [ -f "$PROJECT_DIR/.mxtk/no-hook-repair" ]; then + ok "hook gap ($HOOK_GAP) accepted: .mxtk/no-hook-repair is set" + elif [ -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 bc44a38e..9bde8f06 100755 --- a/bin/install-claude-permissions.sh +++ b/bin/install-claude-permissions.sh @@ -95,6 +95,18 @@ # # 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: no allow, +# # deny or ask key created, nothing +# # pruned, no .bak, a hand-formatted +# # file is not rewritten (exit 3, +# # the lines are printed instead) +# bin/install-claude-permissions.sh --consent-check +# # exit 0 if this project installed +# # the profile before (sidecar with a +# # non-empty allow, or +# # .mxtk/permission-profile); sync +# # repairs hooks only when it is 0 # # 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 +121,19 @@ 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 +CONSENT_CHECK=0 while [ $# -gt 0 ]; do case "$1" in --check) MODE="check" ;; --uninstall) MODE="uninstall" ;; + --hooks-only) HOOKS_ONLY=1 ;; + --consent-check) CONSENT_CHECK=1 ;; --profile) PROFILE_ARG="${2:-}"; shift ;; --profile=*) PROFILE_ARG="${1#--profile=}" ;; -h|--help) echo "$USAGE"; exit 0 ;; @@ -271,11 +287,18 @@ def load_json(path, default): def write_json(path, data): - with open(path, "w") as f: - json.dump(data, f, indent=2) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") +def detect_indent(text): + for line in text.splitlines(): + if line[:1] in (" ", "\t") and line.strip().startswith('"'): + return line[:len(line) - len(line.lstrip())] + return " " + + # Sidecar: a plain list (pre-2026-09-17: allow strings only), {"allow", "deny", "hook": bool} # (pre-2026-10-02: "hook" = the SessionStart hook), or that plus "hooks": [command, ...] naming # every hook this script added. All three are read; the newest form is written, "hook" kept. @@ -361,26 +384,39 @@ if mode == "uninstall": sys.exit(0) # mode == install +HOOKS_ONLY = os.environ.get("MXTK_HOOKS_ONLY") == "1" os.makedirs(os.path.dirname(settings_path), exist_ok=True) +orig_text = None if os.path.exists(settings_path): - shutil.copy(settings_path, settings_path + ".bak-permissions-install") + with open(settings_path, encoding="utf-8") as f: + orig_text = f.read() + # --hooks-only is a pure insertion of two hook lines, so it leaves no .bak behind (the file + # is untouched otherwise, and a stray untracked backup is noise in a project's git status). + if not HOOKS_ONLY: + shutil.copy(settings_path, settings_path + ".bak-permissions-install") d = load_json(settings_path, {}) else: d = {} -perms = d.get("permissions") -if perms is None: - perms = {} - d["permissions"] = perms +if HOOKS_ONLY: + perms = d.get("permissions") or {} # read-only view: --hooks-only never creates permission keys +else: + perms = d.get("permissions") + if perms is None: + perms = {} + d["permissions"] = perms allow = perms.get("allow") if allow is None: allow = [] - perms["allow"] = allow + if not HOOKS_ONLY: + perms["allow"] = allow # A smaller profile than last time: drop what THIS script added that the profile no longer # 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] @@ -394,7 +430,8 @@ for e in newly_added: deny = perms.get("deny") if deny is None: deny = [] - perms["deny"] = deny + if not HOOKS_ONLY: + perms["deny"] = deny newly_denied = [e for e in deny_entries if e not in deny] for e in newly_denied: deny.append(e) @@ -415,7 +452,25 @@ for spec in hook_specs: d.setdefault("hooks", {}).setdefault(ev, []).append(grp) new_hooks.append(spec) -write_json(settings_path, d) +if HOOKS_ONLY and not new_hooks: + pass # nothing to insert: leave the file byte for byte as it is +elif HOOKS_ONLY and orig_text is not None: + # Do not reformat a hand-edited file beyond the inserted entries. Rewrite only when the + # file is exactly what json.dump would have produced with its own indent; otherwise leave + # it alone and print the lines to add by hand. + ind = detect_indent(orig_text) + canon = json.dumps(load_json(settings_path, {}), indent=ind, ensure_ascii=False) + if canon == orig_text.rstrip(): + tail = orig_text[len(orig_text.rstrip()):] # keep the file's own trailing newline (or none) + with open(settings_path, "w", encoding="utf-8") as f: + f.write(json.dumps(d, indent=ind, ensure_ascii=False) + tail) + else: + print("%s has hand-edited formatting; not rewriting it. Add these hook(s) yourself:" % settings_path) + for h in new_hooks: + print(" " + hook_label(h)) + sys.exit(3) +else: + write_json(settings_path, d) if newly_added or newly_denied or newly_asked or new_hooks or pruned: added.update(newly_added) @@ -446,6 +501,30 @@ PY } RC=0 +if [ "$CONSENT_CHECK" -eq 1 ]; then + # A --hooks-only run writes a sidecar with allow: [], so file presence proves nothing; only a + # non-empty allow list shows the full installer ran here. + [ -f "$PROJECT_DIR/.mxtk/permission-profile" ] && exit 0 + [ -f "$SIDECAR" ] || exit 1 + "$PY" - "$SIDECAR" <<'PYC' +import json, sys +try: + d = json.load(open(sys.argv[1], encoding="utf-8")) +except Exception: + sys.exit(1) +allow = d if isinstance(d, list) else d.get("allow", []) +sys.exit(0 if allow else 1) +PYC + exit $? +fi +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 10e92bf3..8453f253 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -1572,13 +1572,42 @@ fi # unlike the exec-approval item-3 rewrite above, there is no safe way to REPAIR this from # sync-project.sh itself: writing to any of these files here would race a live agent session # reading the same file. So this only runs --check and reports what's missing, same as the -# wire-agents --check probe above; the fix is the one-line re-run this prints. +# wire-agents --check probe above; the fix is the one-line re-run this prints. ONE exception, +# below: the two Claude Code hook lines, repaired only in a project that installed the profile. if [ "$WIRED" -eq 1 ] && [ -x "$SCRIPT_DIR/install-harness-permissions.sh" ]; then if ! _PERM_OUT="$("$SCRIPT_DIR/install-harness-permissions.sh" "$PROJECT_DIR" --check 2>&1)"; then warn "$PROJECT_DIR is missing per-harness auto-run allow-list entries for the toolkit's safe wrappers (bin/exec.sh and friends) — those harnesses will prompt on every one of those calls, which defeats bin/exec-approval.sh --set auto. Re-run:" \ "$SCRIPT_DIR/install-harness-permissions.sh $PROJECT_DIR" echo "$_PERM_OUT" | sed 's/^/ /' fi + # The two hooks (SessionStart session-check, PostToolUse look-ledger seen). Without the + # look-ledger hook seen.tsv stays empty and the owed-LOOK count reads "0 seen" for pages that + # were looked at (field: a lab project, 22 of 31 pages had screenshots, ledger said 0 seen). + # + # This is the one place sync writes .claude/settings.json, so it is narrow: it repairs only a + # project that installed the permission profile before (install-claude-permissions.sh + # --consent-check); a project that never did (declined, or not asked yet) gets a WARN and the + # one-line command instead. Opt out for good: touch /.mxtk/no-hook-repair (also + # silences doctor). Racing a live session: the write is one small file written once, and only + # when a hook line is missing, i.e. at most once per project. + _HOOKS_CMD="$SCRIPT_DIR/install-claude-permissions.sh $PROJECT_DIR --hooks-only" + if [ -x "$SCRIPT_DIR/install-claude-permissions.sh" ] && [ ! -f "$PROJECT_DIR/.mxtk/no-hook-repair" ] \ + && ! "$SCRIPT_DIR/install-claude-permissions.sh" "$PROJECT_DIR" --hooks-only --check >/dev/null 2>&1; then + if ! "$SCRIPT_DIR/install-claude-permissions.sh" "$PROJECT_DIR" --consent-check >/dev/null 2>&1; then + warn "the project's .claude/settings.json has no look-ledger / session-check hook, and this project has not installed the permission profile, so sync did not add them. The look ledger reads 0 seen without the look hook. To add just the two hooks:" \ + "$_HOOKS_CMD" \ + "(to stop this warning: touch $PROJECT_DIR/.mxtk/no-hook-repair)" + elif [ "$DRY_RUN" -eq 1 ]; then + echo "Would add: Claude Code hooks (session-check, look-ledger seen) to .claude/settings.json" + CHANGES=$((CHANGES + 1)) + 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). Opt out: touch $PROJECT_DIR/.mxtk/no-hook-repair" + CHANGES=$((CHANGES + 1)) + else + warn "could not add the Claude Code hooks to $PROJECT_DIR/.claude/settings.json (hand-formatted or invalid JSON; it was not touched). Add the two hook lines yourself, or run:" \ + "$_HOOKS_CMD" + fi + 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..3a08a312 --- /dev/null +++ b/tests/wave2/test-look-ledger-hook-repair.sh @@ -0,0 +1,168 @@ +#!/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 +# H7 a project that never installed the profile (declined): sync writes nothing, warns with the +# one-line --hooks-only command; --hooks-only creates no empty allow/deny, no .bak; a +# 4-space, non-ASCII, hand-edited file keeps its indent and characters; a hand-formatted +# (compact) file is not rewritten +# H8 .mxtk/no-hook-repair: sync does not repair or warn, doctor accepts the gap +# 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)" +. "$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 +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" +# 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. +"$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"}]}]}, + "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 +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)" + +# the profile was installed here before (sidecar with a non-empty allow): sync may repair hooks +printf '{"allow":["Bash(./mxcli:*)"],"deny":[]}\n' > "$P/.claude/.mxtk-permissions-added.json" +# 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" + +# H7: declined project (no sidecar, no .mxtk/permission-profile): sync must not install anything +D="$WORK/declined"; cp -R "$P" "$D"; rm -f "$D/.claude/.mxtk-permissions-added.json"; rm -rf "$D/.mxtk/permission-profile" +dbak="$(ls "$D/.claude" | grep -c bak)" +out="$("$TK/bin/sync-project.sh" "$D" 2>&1)" +# (the scaffold already left one .bak; sync must not add another, nor a sidecar, nor the hooks) +{ ! grep -q 'look-ledger' "$D/.claude/settings.json" \ + && printf '%s' "$out" | grep -q 'install-claude-permissions.sh .*--hooks-only' \ + && ! printf '%s' "$out" | grep -q 'Added: Claude Code hooks' \ + && [ ! -e "$D/.claude/.mxtk-permissions-added.json" ] && [ "$dbak" = "$(ls "$D/.claude" | grep -c bak)" ]; } \ + && ok "H7: declined project gets no hooks, no sidecar; sync warns with the --hooks-only command" \ + || bad "H7: declined project was written or no warning" "$out" +# --hooks-only on a stock file: no empty permissions keys, no .bak, indent and non-ASCII kept +E="$WORK/fmt"; mkdir -p "$E/.claude" +printf '{\n "note": "f\xc3\xbcr",\n "hooks": {}\n}\n' > "$E/.claude/settings.json" +"$TK/bin/install-claude-permissions.sh" "$E" --hooks-only >/dev/null 2>&1 +{ ! grep -q '"permissions"' "$E/.claude/settings.json" && grep -q '^ "note": "f.r"' "$E/.claude/settings.json" \ + && grep -q 'look-ledger.sh seen' "$E/.claude/settings.json" && ! ls "$E/.claude"/*.bak* >/dev/null 2>&1; } \ + && ok "H7: --hooks-only adds no empty allow/deny, no .bak, keeps 4-space indent and non-ASCII" \ + || bad "H7: --hooks-only reformatted or added keys" "$(cat "$E/.claude/settings.json")" +# a compact hand-edited file is not rewritten +F="$WORK/compact"; mkdir -p "$F/.claude" +printf '{"permissions": {"allow": ["Bash(x)"]}}\n' > "$F/.claude/settings.json" +fsum="$(cat "$F/.claude/settings.json")" +"$TK/bin/install-claude-permissions.sh" "$F" --hooks-only >/dev/null 2>&1; frc=$? +{ [ "$frc" = 3 ] && [ "$fsum" = "$(cat "$F/.claude/settings.json")" ]; } \ + && ok "H7: a hand-formatted file is left untouched (exit 3, lines printed)" || bad "H7: compact file rewritten, rc=$frc" + +# H8: opt-out +O="$WORK/optout"; cp -R "$P" "$O"; mkdir -p "$O/.mxtk"; touch "$O/.mxtk/no-hook-repair" +printf '{"allow":["Bash(./mxcli:*)"],"deny":[]}\n' > "$O/.claude/.mxtk-permissions-added.json" +osum="$(cat "$O/.claude/settings.json")" +out="$("$TK/bin/sync-project.sh" "$O" 2>&1)"; dout="$(bash "$TK/bin/doctor.sh" "$O" 2>&1)" +{ ! grep -q 'look-ledger' "$O/.claude/settings.json" && ! printf '%s' "$out" | grep -q 'Added: Claude Code hooks\|no look-ledger / session-check hook' \ + && ! printf '%s' "$dout" | grep -q 'no hook for'; } \ + && ok "H8: .mxtk/no-hook-repair stops the repair, the sync warning and the doctor warning" \ + || bad "H8: opt-out ignored" "$out" + +# H4 (profile installed before: the sidecar records a non-empty allow) +printf '{"allow":["Bash(./mxcli:*)"],"deny":[]}\n' > "$P/.claude/.mxtk-permissions-added.json" +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" +"$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 +"$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="$("$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="$("$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 +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" +( 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 ]