Skip to content
Merged
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 @@ -17,6 +17,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are.

## Unreleased

- fix(interview-mode): **`attended` and `unattended` are now read as aliases for `steering` and `auto` — a project that opted into unattended as documented was silently run as steering.** CLAUDE.md, the runbook and intake Q9 taught `Interview mode: unattended`, but `bin/interview-mode.sh` knew only `steering | assist | auto` and fell back to steering. The aliases are accepted in every source (env, `.interview-mode`, `PROJECT.md`, `--set`) under the same parse rules, with one stderr note. The `PROJECT.md` value is read after stripping bold/backticks: a bare mode or alias (`**auto**`, `` `auto` ``, `auto.`) resolves, a note may follow `steering`, but `assist`/`auto` with any trailing text falls back to steering with a "must stand alone on the line" message — so an annotated or hedged line never resolves quieter than before (two hedge word lists were tried in review and both leaked); env, session file and `--set` stay exact. Output stays canonical so `interview-gate.sh` is unchanged, and any other value still falls back to steering. Docs now name the canonical values (vocabulary in `interview-protocol.md`); intake Q9's body offers the three modes with steering the default (heading kept: it is sync's locator), and `sync-project.sh` reads an earlier unanswered Q9 body as unanswered rather than "edited". `test-interview-mode.sh` +57 checks (19 → 76) — MendixMau (found in the 1.0 README review)
- fix(dispatching, sync-project, obligation-check): **review follow-ups for the 2026-10-14 integration batch.** `dispatching.md` names app-restart exits 4 and 5 beside 3 and 1. `test-test-scratch.sh` drops `ls | grep -q` under pipefail for here-strings; sync step 4d no longer aborts on empty doctor output; `_ob_has_denominator` strips NUL bytes so bash stops warning. — integration 2026-10-14 reviews
- feat(lint): **Lint Batch 4 — SEC014 external role can write state, audit or ownership members; CONV009 reads its `maxObjects` option.** SEC014 ([#268](https://github.com/MendixMau/mxcli-project-toolkit/issues/268)): a partner role with `create, read *, write *` on self-owned entities passed every stock rule, so it could set its own record's status through the client API. Lint showed only CONV006 +4, which was re-baselined, and a manual review found it five days later. The new rule flags an external role (named in `lint-config.yaml` as `external_roles: "A, B"`, or `"none"`) that writes all members, a `Status`/`State`/`Result`/`Stage`/`Phase` enumeration, an `…At`/`…By` attribute, or the association its own XPath uses for ownership. It is a warning in the security tier. A `_rule` finding marks the rule blind when the option is blank or given as a YAML list (mxcli passes a list as `None`), when a role name does not exist, or when the catalog is not FULL. Each message is under 160 characters and names the place to fix it, because lint-gate prints only that much. An **absent** option is not a `_rule` finding, so it does not block every exec (the #229 pattern). Instead `bin/sync-project.sh` prints a NOTE on every sync through new `bin/lib/sec014-notice.sh` until the option is set. It is a NOTE rather than a counted warning, so a pristine scaffold still syncs clean under `--strict` (fixture `tests/wave2/test-sec014-notice.sh`, 14 cases; `test-bug12-sync.sh` 61/61). `"none"` switches a security rule off, so it is set only on the user's answer in chat and recorded in `PROJECT.md`. Right after configuring, re-baseline with `lint-gate.sh --update-baseline --reason "SEC014 configured" --approved-in-chat`, because the 0 → N jump would otherwise be blamed on the next script. CONV009 ([#267](https://github.com/MendixMau/mxcli-project-toolkit/issues/267)): the stock rule hard-codes 15, so the `maxObjects: 20` example in the config `mxcli init` writes did nothing. The toolkit copy reads `maxObjects` and `countLoopBodies`, with the **default kept at 15** (raising it is a maintainer call). It replaces the stock file through `STOCK-HASHES.txt`, which gains conv009 hashes for v0.17.0/0.24.0/0.25.0 and a full v0.26.0 set, captured by `mxcli init` from the official release assets; `test-stock-hashes.sh` T4 passes live on all four. `lint-that-actually-runs.md` now says `options:` reaches only rules that call `get_option()`. 32 cases in `tests/lint-rules/test-batch4-rules.sh`; the harness gained `get_option`, `permissions_for`, `attributes_for`, `user_roles` and `role_mappings`. Field run (official mxcli 0.26.0, two model copies): stock CONV009 stayed at 5 with `maxObjects: 20`, while the toolkit copy gave 5 at 15, 10 at 10 and 6 at 20 with loop bodies counted. SEC014 gave 1 true finding with the model's external role configured, and 7 with every role configured, matched by a SQL replay. Both rules load on 0.17.0 and 0.24.0 too. CONV009's "every flow reports 0 objects" self-check now needs `activities_for()` to find a real object in a zero-count flow, so an early greenfield model with empty microflows is not reported blind. `lint-rules/README.md` and the TXT002 header now give the supported way to turn a toolkit rule off. ISO dates in particular are a toolkit opinion, so a project with a non-ISO display standard sets `rules: TXT002: enabled: false` in `lint-config.yaml` and records that in `PROJECT.md`. It must not delete the file, which sync would re-install. On mxcli 0.26.0, a rule with 1 finding gave 0 once disabled. After #308, there is one opt-out story, always the user's decision (asked in chat, recorded in `PROJECT.md`), and it never deletes a `.star` file. `enabled: false` in `lint-config.yaml` stops a rule reporting; this is preferred. `.claude/lint-rules-disabled.txt` with a `# reason:` stops sync installing a toolkit rule, and refuses stock-seeded rules, a list that now includes conv009. `rules: SEC014: enabled: false` is the same decision as `external_roles: "none"`. While SEC014 is undecided, `project-bin/lint-gate.sh` also prints one uncounted `note: SEC014 not configured …` line on stderr on every run (no exit-code change, no lint row). Both it and the sync NOTE now count `external_roles` only when it sits under the `SEC014` key. `test-sec014-notice.sh` has 24 cases. — MendixMau
- fix(lint-gate): **rules that `mxcli init` reverted to stock now stop the lint run as BLIND instead of showing up as a rise on the next script** ([#264](https://github.com/MendixMau/mxcli-project-toolkit/issues/264)). `bin/lint-gate.sh` reads `.claude/.mxtk-lint-receipt` (written by `bin/lib/install-lint-rules.sh`) before linting: a stock-seeded rule (the file names `mxcli init` writes, now marked `stock` in the receipt) whose md5 no longer matches and whose `# mxtk-lint-rule:` header is gone, or any receipt-listed rule that is missing, is named, the remedy `bin/sync-project.sh <project>` (fallback `--upgrade-lint-rules all`) is printed, and the run exits 1 with `BLIND — lint rules reverted to stock` (exec.sh logs `LINT BLIND`, not `LINT ROSE`); `--update-baseline` is refused over reverted rules, also under the `LINT_RULES_REVERTED_OK=1` look-anyway override. Field cases: CONV010 26 -> 169 blamed on a pages-only exec, and a re-seed that sat five days while the baseline absorbed stock counts. So the remedy always clears it, `bin/lib/install-lint-rules.sh` restores such a stock-seeded rule even when its body matches no known stock hash (a newer mxcli's seed, or a hand edit that also lost the header: it was reported LOCALLY MODIFIED and never written), labelled `header missing — restored from toolkit, your copy kept as <rule>.reseeded-<ts>` with the newest 3 copies kept; a headerless toolkit-only or configurable rule is still a local edit, never overwritten. The installer also writes each receipt row's kind, prunes rows for rules no longer shipped, and honours `.claude/lint-rules-disabled.txt`: one line per rule with `# reason: …` (`#\s*reason:` in both tools), a user decision recorded in `PROJECT.md`; a line with no reason, or naming a stock-seeded rule, is ignored with a warning (`lint-rules/README.md` had told projects to just delete txt002). Fixture `tests/wave2/test-lint-receipt-revert.sh` builds its receipt with the real installer and uses a genuine v0.25.0 stock body: 55/55 here, 28 fail on master's lint-gate and installer. — AlvinnYeo
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Every stage and module build follows the **Live Checklist Protocol** (`skills/co
## One decision register
All gate decisions land in the consuming project's `PROJECT.md`, marked `CONFIRMED` or `ASSUMED`. The `skills/checkpoints/` CAC files are the packaged mechanism that runs the runbook's interview protocol at the six busiest transitions — they write to `PROJECT.md`, never to a separate state file.

**Ask, then stop.** Gate questions are actually asked in chat — on Claude Code via `AskUserQuestion` (clickable options), on any other harness as plain numbered markdown with "something else" always available — and **either way the agent ends its turn and waits for the answer**. `AskUserQuestion` is a Claude Code built-in, not something a Copilot/Cursor/Windsurf session can install; the rendering is tool-specific, the asking and the stopping are not. `skills/interview-protocol.md` §3 → "Asking on a non-Claude agent" is the single source of that rule; every other mention defers there. `ASSUMED` is earned by asking (user said "you decide"), never by skipping the question — finding the answer in the source justifies the *recommendation*, not silence. Unattended runs are opt-in only (`Interview mode: unattended` in `PROJECT.md`, at the user's explicit request).
**Ask, then stop.** Gate questions are actually asked in chat — on Claude Code via `AskUserQuestion` (clickable options), on any other harness as plain numbered markdown with "something else" always available — and **either way the agent ends its turn and waits for the answer**. `AskUserQuestion` is a Claude Code built-in, not something a Copilot/Cursor/Windsurf session can install; the rendering is tool-specific, the asking and the stopping are not. `skills/interview-protocol.md` §3 → "Asking on a non-Claude agent" is the single source of that rule; every other mention defers there. `ASSUMED` is earned by asking (user said "you decide"), never by skipping the question — finding the answer in the source justifies the *recommendation*, not silence. Unattended runs are opt-in only (`Interview mode: auto` in `PROJECT.md`, alias `unattended`, at the user's explicit request; modes in `skills/interview-protocol.md`).

## Key skills and when to load them

Expand Down
5 changes: 3 additions & 2 deletions bin/exec-approval.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ fi

if [ -z "$MODE" ] && [ -f "$PROJECT_DIR/PROJECT.md" ]; then
# `Exec approval: auto` anywhere in PROJECT.md. Case-insensitive on the key and the value;
# first occurrence wins so an appended log entry cannot silently retune the project — same
# convention as interview-mode.sh's own PROJECT.md read.
# first occurrence wins so an appended log entry cannot silently retune the project. The whole
# rest of the line is the value, so any trailing note is invalid → `ask`. (interview-mode.sh
# allows a note after `steering` only; this read deliberately stays exact.)
FROM_MD=$(grep -iEm1 '^[[:space:]]*(-[[:space:]]*)?\**Exec approval\**[[:space:]]*:' "$PROJECT_DIR/PROJECT.md" 2>/dev/null \
| sed -E 's/.*[Aa]pproval\**[[:space:]]*:[[:space:]]*//' \
| tr '[:upper:]' '[:lower:]' | tr -d '[:space:]*`')
Expand Down
81 changes: 66 additions & 15 deletions bin/interview-mode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@
# alternative, cost if wrong, and a consent stamp naming auto-mode rather
# than a human. The recording is the entire safety net; see interview-protocol.md.
#
# ALIASES. `attended` = steering and `unattended` = auto, accepted in every source (env, the
# session file, PROJECT.md, --set) under the same parse rules, with one stderr note. Output is
# always the canonical name, so readers (claude-hooks/hooks/interview-gate.sh) never see an
# alias. Why: CLAUDE.md, the runbook and intake Q9 taught `Interview mode: unattended` while
# this script knew only the three names, so a project that opted in as documented silently
# ran as steering.
#
# RESOLUTION ORDER (first hit wins)
# 1. $CLAUDE_INTERVIEW_MODE one command, one session
# 2. <project>/.claude/.interview-mode set by --set, survives the turn
# 3. PROJECT.md `Interview mode:` the project's stated default
# 3. PROJECT.md `Interview mode:` the project's stated default (assist/auto must stand alone)
# 4. steering
#
# A typo never resolves quieter. An unrecognised value anywhere falls back to steering and
Expand All @@ -42,6 +49,7 @@ ACTION="${2:-}"

usage() {
echo "usage: interview-mode.sh <project-dir> [--explain|--set steering|assist|auto|--clear|--remote]" >&2
echo " (aliases: attended = steering, unattended = auto)" >&2
exit 2
}

Expand All @@ -53,14 +61,25 @@ is_valid() {
case " $VALID " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}

# Map an alias to its canonical mode, noting it on stderr; anything else passes through
# unchanged for is_valid to judge. Case-insensitive like the PROJECT.md read (bash 3.2: tr).
canon() {
_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
case "$_lc" in
attended) echo "interview-mode: '$1' read as 'steering' (alias)" >&2; echo steering ;;
unattended) echo "interview-mode: '$1' read as 'auto' (alias)" >&2; echo auto ;;
*) printf '%s\n' "$1" ;;
esac
}

STATE_DIR="$PROJECT_DIR/.claude"
MODE_FILE="$STATE_DIR/.interview-mode"
REMOTE_FILE="$STATE_DIR/.interview-remote"

# --- writers ---------------------------------------------------------------------------
case "$ACTION" in
--set)
NEW="${3:-}"
NEW=$(canon "${3:-}")
is_valid "$NEW" || { echo "interview-mode: unknown mode '$NEW' (want: $VALID)" >&2; exit 2; }
mkdir -p "$STATE_DIR" || exit 2
printf '%s\n' "$NEW" > "$MODE_FILE" || exit 2
Expand Down Expand Up @@ -91,16 +110,17 @@ MODE=""
SOURCE=""

if [ -n "${CLAUDE_INTERVIEW_MODE:-}" ]; then
if is_valid "$CLAUDE_INTERVIEW_MODE"; then
MODE="$CLAUDE_INTERVIEW_MODE"; SOURCE="\$CLAUDE_INTERVIEW_MODE"
FROM_ENV=$(canon "$CLAUDE_INTERVIEW_MODE")
if is_valid "$FROM_ENV"; then
MODE="$FROM_ENV"; SOURCE="\$CLAUDE_INTERVIEW_MODE"
else
echo "interview-mode: \$CLAUDE_INTERVIEW_MODE='$CLAUDE_INTERVIEW_MODE' is not a mode; using steering" >&2
MODE="steering"; SOURCE="fallback (bad \$CLAUDE_INTERVIEW_MODE)"
fi
fi

if [ -z "$MODE" ] && [ -f "$MODE_FILE" ]; then
FROM_FILE=$(tr -d '[:space:]' < "$MODE_FILE" 2>/dev/null)
FROM_FILE=$(canon "$(tr -d '[:space:]' < "$MODE_FILE" 2>/dev/null)")
if is_valid "$FROM_FILE"; then
MODE="$FROM_FILE"; SOURCE="$MODE_FILE"
else
Expand All @@ -112,16 +132,47 @@ fi
if [ -z "$MODE" ] && [ -f "$PROJECT_DIR/PROJECT.md" ]; then
# `Interview mode: assist` anywhere in PROJECT.md. Case-insensitive on the key and the
# value; first occurrence wins so an appended log entry cannot silently retune the project.
FROM_MD=$(grep -iEm1 '^[[:space:]]*(-[[:space:]]*)?\**Interview mode\**[[:space:]]*:' "$PROJECT_DIR/PROJECT.md" 2>/dev/null \
| sed -E 's/.*[Mm]ode\**[[:space:]]*:[[:space:]]*//' \
| tr '[:upper:]' '[:lower:]' | tr -d '[:space:]*`')
if [ -n "$FROM_MD" ]; then
if is_valid "$FROM_MD"; then
MODE="$FROM_MD"; SOURCE="PROJECT.md"
else
echo "interview-mode: PROJECT.md says 'Interview mode: $FROM_MD', which is not a mode; using steering" >&2
MODE="steering"; SOURCE="fallback (bad PROJECT.md value)"
fi
# PROJECT.md is human-written and gets annotated, so (after stripping bold/backticks and
# lower-casing):
# - a value that is exactly one mode or alias (trailing '.' allowed) is used;
# - trailing text after `steering`/`attended` is a harmless note — steering is the loudest;
# - trailing text after `assist`/`auto`/`unattended` falls back to steering, saying a
# quieter mode must stand alone on the line.
# WHY NOT "first word wins": that let `auto? (to confirm)`, `auto | steering` and
# `auto (pending sign-off)` resolve QUIETER than the old whole-line read. Two hedge word
# lists were tried on #335 and both leaked (`auto / steering`, `(maybe)`, `(tentative)` …).
# Never taking a quieter mode from an annotated line cannot leak. The env var, session file
# and --set stay exact: those are machine-written.
MD_RAW=$(grep -iEm1 '^[[:space:]]*(-[[:space:]]*)?\**Interview mode\**[[:space:]]*:' "$PROJECT_DIR/PROJECT.md" 2>/dev/null \
| sed -E 's/^[^:]*:[[:space:]]*//' | tr '[:upper:]' '[:lower:]' | tr -d '*`' \
| sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
if [ -n "$MD_RAW" ]; then
MD_WHOLE=$(printf '%s' "$MD_RAW" | sed -E 's/\.$//')
case "$MD_WHOLE" in
*[[:space:]]*) # a mode plus trailing text
MD_FIRST=$(printf '%s\n' "$MD_RAW" | awk '{print $1}' | sed -E 's/[,.;:)]+$//')
MD_CANON=$(canon "$MD_FIRST" 2>/dev/null)
case "$MD_CANON" in
steering)
FROM_MD=$(canon "$MD_FIRST"); MODE="$FROM_MD"; SOURCE="PROJECT.md" ;;
assist|auto)
echo "interview-mode: PROJECT.md says 'Interview mode: $MD_RAW'; a quieter mode must stand alone on the line — write 'Interview mode: $MD_CANON' and put notes on the next line. Using steering." >&2
MODE="steering"; SOURCE="fallback (quieter mode with trailing text)" ;;
*)
echo "interview-mode: PROJECT.md says 'Interview mode: $MD_RAW', which is not a mode; using steering" >&2
MODE="steering"; SOURCE="fallback (bad PROJECT.md value)" ;;
esac
;;
*)
FROM_MD=$(canon "$MD_WHOLE")
if is_valid "$FROM_MD"; then
MODE="$FROM_MD"; SOURCE="PROJECT.md"
else
echo "interview-mode: PROJECT.md says 'Interview mode: $MD_RAW', which is not a mode; using steering" >&2
MODE="steering"; SOURCE="fallback (bad PROJECT.md value)"
fi
;;
esac
fi
fi

Expand Down
8 changes: 5 additions & 3 deletions bin/lib/intake-template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ unprompted. "Nothing further" is a valid answer — but it has to be the user's,

## 9. Interview mode: attended (default) or unattended?

_Not yet asked._ How to verify: ask the user; default is attended. Attended = every gate
question is asked in chat and the agent waits for the answer. Unattended (opt-in only, explicit
request required) = recommended options are applied as ASSUMED and logged for reconciliation.
_Not yet asked._ How to verify: ask the user; default is `steering`. Record it as
`Interview mode:` in PROJECT.md: `steering` = every gate question is asked in chat and the agent
waits; `assist` = questions batched at gates, low-consequence ones assumed and reported; `auto`
(opt-in only, explicit request required) = nothing blocks, positions recorded as ASSUMED for
reconciliation. `attended` / `unattended` are accepted aliases for `steering` / `auto`.

## 10. Is any of this already done outside the toolkit — and where does the pipeline pick up?

Expand Down
8 changes: 6 additions & 2 deletions bin/sync-project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,13 @@ elif [ -n "$INTAKE" ]; then
# alarming — it is the expected state of a project whose interview has not happened yet.
# Without this branch every freshly-scaffolded project drew the "text has been edited, NOT
# touching it" warning below, which is both false and --strict-failing.
# An earlier template's Q9 (body still opening "_Not yet asked._") is the same state: the
# 2026-10-14 body rewrite (steering|assist|auto) must not turn every untouched scaffold
# into the "edited, NOT touching it" warning below.
q9_current_text > "$Q9_REF"
if cmp -s "$Q9_TMP" "$Q9_REF"; then
echo "Note: $INTAKE Q9 is the current template, still unanswered — Stage P will FAIL until"
if cmp -s "$Q9_TMP" "$Q9_REF" \
|| sed -n '2,$p' "$Q9_TMP" | grep -v '^[[:space:]]*$' | head -1 | grep -q '^_Not yet asked\._'; then
echo "Note: $INTAKE Q9 is the unanswered template text — Stage P will FAIL until"
echo " the kickoff interview happens. That is correct; nothing to repair."

elif q9_stale_scaffold_text > "$Q9_REF"; cmp -s "$Q9_TMP" "$Q9_REF"; then
Expand Down
Loading
Loading