Skip to content
Draft
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(lint-gate, conformance-check, gate-check): **a ratchet baseline that loosens now moves only on the user's own command; `--approved-in-chat` is no longer evidence** ([#348](https://github.com/MendixMau/mxcli-project-toolkit/issues/348)). The agent whose script caused a rise could type that flag itself, so "re-baselining is the user's call" ran on trust. A rise (lint) or an accepted regression (conformance) is now written only against a row that the new `bin/baseline-approve.sh` writes to `.claude/baseline-approvals.tsv`. The user runs that command in a terminal, and the row is the sha256 of exactly the content they reviewed. Claude Code denies the command and the file to the agent (`install-claude-permissions.sh`), Copilot must ask before running it, and the command refuses to run without a TTY. Every instrument write is recorded with its content in `.claude/baseline-writes.tsv` and `.claude/baseline-history/`. `gate-check.sh` prints `Baselines (human-approved?)`, replaying each record against its content, so running `baseline-lock.sh record … tighten` after a hand edit is caught too. It reports FAIL for a baseline or record looser than the write before it with no approval, or when committed records have gone missing. It reports MANUAL for a baseline that predates the lock or a loosening a git merge produced; a plain one-parent commit of a loosening stays FAIL. Both block Stages 5 and 6, and the instruments refuse to write until the user approves. An approval covers only the state it saw, and the user's `approve --current` re-anchors the replay, so it clears any FAIL or MANUAL. Line endings are ignored, so a CRLF checkout compares equal. The deny rules name the two commands and redirection into the logs, so `git add` and `git log -p` on the logs still work. Bench (`tests/wave2/bench-baseline-lock.sh`, 11 unapproved and 4 legitimate scripted attempts per baseline): unapproved moves landing 11/11 → 3/11 (the 3 are direct file edits, one with a forged record; all now flagged, unflagged 11/11 → 0/11), legitimate re-baselines 4/4 → 4/4, at the cost of one human command each. Field evidence: none yet. Not covered: the mxbuild verification stamp, which records a machine check and has no human judgement to lock, and page-fidelity and token-burn, which keep no baseline. — external reviewer feedback
- process(build): **Stage 5 drafts the ready set in parallel and applies serially.** The runbook dispatch row said "sequential within a module", which serialised drafting even for independent scripts; it now points to `iterative-build-loop.md` → Gate cost, the single statement of the rule. The unit is the build-plan row: every `BUILD` row whose "Depends on" rows are all applied, minus overlapping writers (two rows that replace, alter or drop+create the same document never share a ready set, and no row is drafted while it overlaps a queued unapplied one; `brd-to-build-plan.md` has Produces name every document a row writes), runs at once, its scripts in order, one `mdl-agent` per script. Applies stay one at a time through `bin/exec.sh`, and the ready set is recomputed after each. A queued draft made before a sibling apply landed is re-checked with `mxcli diff --format struct` (preflight STOP rule 23); any removal line (checked against `describe` per BUG-146) or STOP rule 21 target means re-draft, never patch, because a stale restatement of a shared document is exactly rule 21's silent seed-call loss and passes `check --references`. Report `ready set N, drafted N, applied k`; the `mdl-agent` template says a drafter never applies and never depends on an unapplied draft. Field evidence: none yet. — MendixMau
- fix(platform-link): **CLI commits now show their changed documents in Studio Pro History.** Studio Pro's History → Model changes pane reads a `ModelChanges` array from the commit's `refs/notes/mx_metadata` note. Trap 6c's hand-written template set it to `[]`, so every mxcli/agent commit showed "Model changes 0" (field project: 270 of 270 notes empty). New `project-bin/mx-metadata-note.py` (stdlib only) computes the array from the commit's changed `.mxunit` files, in Studio Pro's entry shape: Status, UnitID, UnitType, UnitName and Module (walked up the `.mpr` Unit tree). It has `changes` / `write` / `backfill` / `sync <remote>` subcommands. `install-project-hooks.sh` now also installs post-commit and post-rewrite hooks that write the note and never block, and `--check` covers them, so `sync-project.sh` rolls them out to wired projects. Notes are published with `mx-metadata-note.py sync <remote>` after the branch push. Push config is never touched. With mxcli v0.26+ in the project root the hooks call `mxcli git note --write` instead (mendixlabs/mxcli#1337); the Python script is the fallback for older pins, and on 160 field commits both wrote the same 3,594 entries. Trap 6c rewritten. Measured against Studio Pro's own notes, 51 commits in 5 apps: 3387/3389 entries exact on all five fields. Field run on a 357-commit Mendix 11.15.0 app: 158 notes backfilled and pushed, and Studio Pro History lists the documents per revision. Review fixes: an earlier revision added `HEAD` + the notes ref to `remote.<r>.push`. It was dropped because any push refspec replaces `push.default`. A branch tracking a differently named upstream got a new same-named branch on Team Server where `simple` refuses, a missing local notes ref failed the whole push, and nobody knows whether Studio Pro's own push reads that config. The installer now only fetches the remote's notes (non-forced). It removes that exact `HEAD` + notes pair if present, and leaves and reports any other entry. `sync` handles a remote with no notes ref (it used to exit 1) and retries a rejected push once after re-merging. A note that already lists changes (Studio Pro's) is never overwritten by `write`/`backfill` without `--force`, and `sync` keeps a colleague's non-empty note over ours. The mxcli probe greps `git note --help` for `--write`. Hooks and docs call the script through `resolve_py`. Fixture `tests/wave2/test-mx-metadata-note.sh` (40 cases, over real captured template units; single- and two-tree layouts, amend, backfill and `--force`, push exit codes identical to a no-hooks clone, leftover-refspec cleanup, sync to an empty remote and its retry, notes fetch at install, a diverged notes merge; green under `init.defaultBranch` master and main). — field project (Mendix 11.15 app), MendixMau
- learn(fixture-seeding): **explore the data structure first, choose rows by use case, scan actions for resets before any run.** Two seeding runs on public template apps: seeding raised journey passes 33 -> 58 and 20 -> 51 of the same sets (FAIL 93 -> 68, 163 -> 132; INVALID unchanged), and a "Reset all demodata" journey wiped accounts so 104 of 127 journeys reported `The username or password you entered is incorrect`, identical on empty and seeded databases. Skill gains three questions a seed must answer, a use-case checklist, a destructive-action scan, a generic traps table with symptoms, measured expectations and a denominator checklist; routing trigger now fires on seed/test/demo data and empty pages. Independent review fixes: object ids follow the Mendix bigint pattern with a business-key guard (no uuids), associations are seeded where the page's path stores them, four new traps (enum names, inheritance chain, calculated attributes, view/external entities), and the port-offset and container-label traps moved to `harness-architecture.md`. — MendixMau
Expand Down
6 changes: 3 additions & 3 deletions agents/gate-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ This table is your whole list. The baseline table in the project's CLAUDE.local.
get the per-document list behind a rise. Name each rule that rose, the documents behind it,
and whether the rise belongs to the script under review or to earlier debt. A rise is a FAIL
for the script even when mxbuild is clean. Never re-baseline to turn a row green — that is
the user's decision, made in chat; `--update-baseline` refuses a rise without `--reason`, a
security-tier rise (SEC*, CONV006) also without `--approved-in-chat`, and logs each one to
`.claude/lint-acceptances.tsv`. A row reading `LINT BLIND` is an instrument fault (a rule
the user's decision; `--update-baseline` refuses a rise without `--reason`, and then
without the user running `bin/baseline-approve.sh lint` in a separate terminal (never you), and
logs each one to `.claude/lint-acceptances.tsv`. A row reading `LINT BLIND` is an instrument fault (a rule
inspected nothing), not a rise: name the rule and its configuration fix. `LINT BLIND: rules
reverted to stock (…)` means `mxcli init` re-seeded the toolkit rules and lint did not run:
the fix is the toolkit's `bin/sync-project.sh`, never a re-baseline. If the row says
Expand Down
45 changes: 45 additions & 0 deletions bin/gate-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2544,6 +2544,39 @@ else
printf 'Artifacts: FAULT — bin/lib/artifact-check.sh missing from %s; no artifact was checked\n' "$TOOLKIT_DIR"
fi

# ---------------------------------------------------------------------------
# Baselines — did a ratchet baseline move without a person's approval? (#348)
#
# lint-gate.sh and conformance-check.sh record every baseline they write, with its content, and a
# loosening write only lands against a row bin/baseline-approve.sh wrote (a person, in a
# terminal). baseline-lock.sh verify replays the records against their content: a baseline, or a
# recorded write, looser than the write before it with no approval is FAIL (a hand edit, or a
# forged record — #375 review). MANUAL: no record at all (predates the lock or the log was
# removed), or looser content a git merge produced. Committed records may not go missing. CR is
# stripped before comparing. Both FAIL and MANUAL block, since only a person clears either. The toolkit's own baseline-lock.sh reads the
# project, whichever version the project installed. BLOCKS Stages 5 and 6 (below); the
# remedy, a person running `bin/baseline-approve.sh <kind> --current`, is never blocked.
BASE_STATUS="PASS"; BASE_NOTE=""; BASE_BLOCK=0
BASE_LOCK="$TOOLKIT_DIR/project-bin/baseline-lock.sh"
if [ -f "$BASE_LOCK" ]; then
for _bk in lint conformance; do
_bv="$(bash "$BASE_LOCK" verify "$PROJECT_DIR" "$_bk" 2>/dev/null)"
_bs="$(printf '%s' "$_bv" | cut -f1)"
case "$_bs" in
NONE) continue ;;
FAIL|GONE) BASE_STATUS="FAIL"; BASE_BLOCK=1 ;;
MANUAL) [ "$BASE_STATUS" = PASS ] && BASE_STATUS="MANUAL"; BASE_BLOCK=1 ;;
PASS) ;;
*) [ "$BASE_STATUS" = PASS ] && BASE_STATUS="MANUAL"; BASE_BLOCK=1; _bv="$_bk could not be verified" ;;
esac
BASE_NOTE="$BASE_NOTE${BASE_NOTE:+; }$_bk: $(printf '%s' "$_bv" | cut -f2-)"
done
[ -n "$BASE_NOTE" ] || { BASE_STATUS="PASS"; BASE_NOTE="no ratchet baseline in this project yet"; }
else
BASE_STATUS="MANUAL"; BASE_NOTE="project-bin/baseline-lock.sh missing from $TOOLKIT_DIR — cannot evaluate, which is not a pass"
fi
printf "Baselines (human-approved?): %s — %s\n" "$BASE_STATUS" "$BASE_NOTE"

# ---------------------------------------------------------------------------
# Source sufficiency — was the source actually characterised, or did Stage 0
# pass on nobody having looked?
Expand Down Expand Up @@ -3265,6 +3298,18 @@ if [ -n "$REQUESTED_STAGE" ]; then
# skills/close-the-loop.md was written to retire, and a warning shown once per key
# (advise()'s own semantics) is too easy to have already been shown and forgotten by the time
# a report goes un-filed again.
# No Stage-5/6 gate passes on a ratchet baseline that moved without a person's approval:
# a rise baselined away is a FAIL nobody was shown. See the Baselines block above.
# MANUAL blocks too (#375 confirmation review): every non-PASS state here is one only a person
# clears, and a non-blocking MANUAL was a one-commit bypass.
if { [ "$REQUESTED_STAGE" = "5" ] || [ "$REQUESTED_STAGE" = "6" ]; } && [ "$BASE_BLOCK" = 1 ]; then
echo "" >&2
echo "Gate BLOCKED by a baseline no person has approved ($BASE_STATUS): $BASE_NOTE" >&2
echo "Show the user the change (git log -p on the baseline file). If they accept it, THEY run" >&2
echo "bin/baseline-approve.sh <kind> --current in a separate terminal window (not via the agent" >&2
echo "or Claude Code's ! prefix: it needs a TTY); otherwise restore it from git." >&2
exit 1
fi
if { [ "$REQUESTED_STAGE" = "5" ] || [ "$REQUESTED_STAGE" = "6" ]; } && [ "$DISP_STATUS" = "FAIL" ]; then
echo "" >&2
echo "Gate BLOCKED by report-disposition-check: $DISP_NOTE" >&2
Expand Down
17 changes: 17 additions & 0 deletions bin/install-claude-permissions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
# runs the same mxcli underneath. A skill that genuinely needs the bare form runs it from a
# script (the rule matches the top-level command only).
#
# THE SECOND DENY (2026-10-14, #348): the baseline-approve.sh command, a direct
# `baseline-lock.sh record`, and writes into the baseline logs. Approving a ratchet baseline move
# is the user's act; the agent could type `--approved-in-chat` itself. Narrow on purpose (#375
# review): a rule matching the log's file name blocked `git add` and `git log -p` on it.
#
# THE ONE HOOK: `bash bin/session-check.sh || true` on SessionStart — the project-local check
# that says at the top of every session whether the model on disk is verified, whether the
# mxbuild gate can run here, and whether the installed guard scripts are stale. It never blocks.
Expand Down Expand Up @@ -184,6 +189,18 @@ DENY=(
"Bash(./mxcli exec:*)"
"Bash(mxcli exec:*)"
"Bash(./mxcli.exe exec:*)"
# #348: approving a ratchet baseline move is the user's act. Denies the approve command, a
# direct `baseline-lock.sh record` (instruments call it from inside their scripts, which these
# top-level rules do not see), shell redirection into the two logs, and file-tool edits of the
# logs and snapshots. Reading, `git add`/`git commit -- …`/`git log -p` on them stay allowed.
# See project-bin/baseline-lock.sh for what this does not stop.
"Bash(*baseline-approve.sh*)"
"Bash(*baseline-lock.sh*record*)"
"Bash(*>*baseline-approvals.tsv*)"
"Bash(*>*baseline-writes.tsv*)"
"Edit(**/baseline-approvals.tsv)"
"Edit(**/baseline-writes.tsv)"
"Edit(**/baseline-history/**)"
)
ASK=()

Expand Down
6 changes: 5 additions & 1 deletion bin/install-harness-permissions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,18 @@ echo "=== copilot ==="
require_py
VSCODE_SETTINGS="$PROJECT_DIR/.vscode/settings.json"
COPILOT_SIDECAR="$PROJECT_DIR/.vscode/.mxtk-autoapprove-added.json"
# `/baseline-approve\.sh/` and `/baseline-lock\.sh.*record/` are false (#348): approving a
# baseline move is the user's act, so Copilot must ask, never auto-run them.
# Only relative, committable entries — see the header note above for why the two absolute
# $TOOLKIT_ROOT entries Claude gets are deliberately not mirrored here.
COPILOT_ENTRIES_JSON='{
"./mxcli": true,
"mxcli": true,
"./bin/exec.sh": true,
"bin/exec.sh": true,
"/^\\.?\\/?bin\\/[^ ]+\\.sh$/": true
"/^\\.?\\/?bin\\/[^ ]+\\.sh$/": true,
"/baseline-approve\\.sh/": false,
"/baseline-lock\\.sh.*record/": false
}'
"$PY" - "$VSCODE_SETTINGS" "$COPILOT_SIDECAR" "$MODE" "$COPILOT_ENTRIES_JSON" <<'PY' || { ec=$?; [ "$ec" -gt "$RC" ] && RC=$ec; }
import json, os, shutil, sys
Expand Down
5 changes: 4 additions & 1 deletion bin/lib/install-manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m
# may run itself (stops only the pid run --local recorded for THIS project), and `mxcli test` on a
# scratch copy under the test-port lock. exec.sh names app-restart.sh in its watch advisory, so
# it has to be installed beside it.
MXTK_PROJECT_BIN="app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh"
# baseline-lock.sh and baseline-approve.sh joined on 2026-10-14 (#348) as a PAIR: lint-gate.sh
# and conformance-check.sh resolve the lock as their sibling, and the approve command (run by the
# user, never the agent) is what their refusal names.
MXTK_PROJECT_BIN="baseline-lock.sh baseline-approve.sh app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh"

# Files in project-bin/ that are deliberately NOT installed into projects. The reverse check
# below flags anything named by NEITHER list, so a new file in project-bin/ has to be either
Expand Down
3 changes: 2 additions & 1 deletion bin/lib/sec014-notice.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ mxtk_sec014_notice() {
echo " external_roles: \"RoleA, RoleB\" # one text, not a YAML list"
echo "\"none\" (or SEC014 enabled: false) switches this security rule off: only on the user's answer in chat, recorded in"
echo "PROJECT.md — never to silence this notice. The first configured run raises SEC014 0 -> N, so"
echo "re-baseline right after: bin/lint-gate.sh --update-baseline --reason \"SEC014 configured\" --approved-in-chat"
echo "re-baseline right after: bin/lint-gate.sh --update-baseline --reason \"SEC014 configured\", then the user"
echo "runs bin/baseline-approve.sh lint in a separate terminal (an agent cannot approve it)"
return 0
}
4 changes: 2 additions & 2 deletions lint-rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,8 @@ asked in chat. `external_roles: "none"` switches off a security rule, and so doe
`rules: SEC014: enabled: false`. Either one is set only on the user's answer and recorded as a
decision row in `PROJECT.md`. An agent never sets one to make the NOTE go away. The first run after configuring raises SEC014 from 0 to N at once, and lint-gate
would blame that security-tier rise on whatever script ran next. So re-baseline in the same step:
`bin/lint-gate.sh --update-baseline --reason "SEC014 configured" --approved-in-chat`, after the
user has seen the findings.
`bin/lint-gate.sh --update-baseline --reason "SEC014 configured"`, then the user, having seen the
findings, runs `bin/baseline-approve.sh lint` in a separate terminal window.

**Tuning a toolkit rule never means editing it.** `conv009` and `sec014` read their settings
with `get_option()` from `.claude/lint-config.yaml` (`rules: <RULE_ID>: options:`), so the file
Expand Down
3 changes: 2 additions & 1 deletion lint-rules/sec014_external_role_write.star
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
# finding here would make lint-gate exit PASS-BLIND on every exec of every synced project until
# someone answers a question — the #229 pattern. The silence is not a pass: the sync names it.
# After configuring, SEC014 jumps 0 -> N in one run; re-baseline right then
# (lint-gate.sh --update-baseline --reason "SEC014 configured" --approved-in-chat), or the
# (lint-gate.sh --update-baseline --reason "SEC014 configured", then the user runs
# baseline-approve.sh lint), or the
# rise is blamed on whatever script runs next.
#
# SELF-CHECKS (configured but unusable: the rule must say so, never report a clean pass):
Expand Down
Loading
Loading