Skip to content
Merged
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(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 `<url>`, 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
Expand Down
13 changes: 13 additions & 0 deletions bin/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
101 changes: 90 additions & 11 deletions bin/install-claude-permissions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@
# # entry is missing; writes nothing
# bin/install-claude-permissions.sh <project-root> --uninstall # remove only entries this
# # script added; back up first
# bin/install-claude-permissions.sh <project-root> --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 <project-root> --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
Expand All @@ -109,15 +121,19 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLKIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

USAGE="Usage: $0 <project-root> [--check|--uninstall] [--profile wrappers|project|full]"
USAGE="Usage: $0 <project-root> [--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 ;;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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=$?
Expand Down
31 changes: 30 additions & 1 deletion bin/sync-project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>/.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 ──────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading