From 56d16abf859c560693d842d4636ec116435ce570 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:52:23 +0000 Subject: [PATCH 1/4] fix(interview-mode): read attended/unattended as aliases for steering/auto CLAUDE.md, the runbook and intake Q9 told users to write `Interview mode: unattended` in PROJECT.md, but bin/interview-mode.sh accepted only steering|assist|auto and fell back to steering. A project that opted into unattended as documented silently ran as steering. - interview-mode.sh: canon() maps attended -> steering and unattended -> auto in every source (env, .interview-mode, PROJECT.md, --set), case-insensitive, with one stderr alias note. Output is always the canonical name, so interview-gate.sh is unchanged. Typos still fall back to steering. - Docs name the canonical values; interview-protocol.md carries the alias vocabulary, CLAUDE.md and the runbook point there. - intake Q9 body offers steering/assist/auto, steering the default. The heading is unchanged (it is the "## 9." locator and the text sync matches on). sync-project.sh treats any Q9 body still opening "_Not yet asked._" as unanswered, so the body rewrite does not turn untouched scaffolds into the "edited, NOT touching it" warning. - test-interview-mode.sh: +21 checks, 13 of which fail on master (aliases per source, typo, no note on canonical values, hook does not block on unattended). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- bin/interview-mode.sh | 29 ++++++++++++-- bin/lib/intake-template.sh | 8 ++-- bin/sync-project.sh | 8 +++- skills/conversion-runbook.md | 2 +- skills/interview-protocol.md | 5 ++- tests/wave2/test-interview-mode.sh | 61 ++++++++++++++++++++++++++++++ 8 files changed, 103 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6faaca..b0ab3e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; 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` +21 checks (19 → 40; 13 fail on master) — 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 ` (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 .reseeded-` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9fdce3ff..425608d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/bin/interview-mode.sh b/bin/interview-mode.sh index 6d5b3b07..255e3009 100755 --- a/bin/interview-mode.sh +++ b/bin/interview-mode.sh @@ -24,6 +24,13 @@ # 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. /.claude/.interview-mode set by --set, survives the turn @@ -42,6 +49,7 @@ ACTION="${2:-}" usage() { echo "usage: interview-mode.sh [--explain|--set steering|assist|auto|--clear|--remote]" >&2 + echo " (aliases: attended = steering, unattended = auto)" >&2 exit 2 } @@ -53,6 +61,17 @@ 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" @@ -60,7 +79,7 @@ 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 @@ -91,8 +110,9 @@ 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)" @@ -100,7 +120,7 @@ if [ -n "${CLAUDE_INTERVIEW_MODE:-}" ]; then 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 @@ -116,6 +136,7 @@ if [ -z "$MODE" ] && [ -f "$PROJECT_DIR/PROJECT.md" ]; then | sed -E 's/.*[Mm]ode\**[[:space:]]*:[[:space:]]*//' \ | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]*`') if [ -n "$FROM_MD" ]; then + FROM_MD=$(canon "$FROM_MD") if is_valid "$FROM_MD"; then MODE="$FROM_MD"; SOURCE="PROJECT.md" else diff --git a/bin/lib/intake-template.sh b/bin/lib/intake-template.sh index 863c1dce..2e639c53 100644 --- a/bin/lib/intake-template.sh +++ b/bin/lib/intake-template.sh @@ -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? diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 97ba2fc4..78c893cf 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -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 diff --git a/skills/conversion-runbook.md b/skills/conversion-runbook.md index 96ab7ab5..4daae017 100644 --- a/skills/conversion-runbook.md +++ b/skills/conversion-runbook.md @@ -238,7 +238,7 @@ Note the ruling this encodes, from the user, 2026-08-12: *"At each gate, also gi A proposal beats a questionnaire because it asks the user to **correct** something rather than supply it cold — and by the time each gate arrives, the agent has read the source and has evidence to put behind its recommendation. -**Unattended mode is opt-in, never inferred.** The "default + record + proceed" behavior (skipping the wait, not the question — questions still get logged in `PROJECT.md` open-questions) is only legal when the user explicitly said to run unattended, recorded at Stage P in `PROJECT.md` (`Interview mode: unattended`). Default is **attended**: every gate question is asked in chat and the turn ends there. (Failure: an attended run reached Stage 3 with zero questions asked, reading "a solo run never stalls" as "never ask" — `process/baseline-incident-history.md` §R5.) +**Unattended mode is opt-in, never inferred.** The "default + record + proceed" behavior (skipping the wait, not the question — questions still get logged in `PROJECT.md` open-questions) is only legal when the user explicitly said to run unattended, recorded at Stage P in `PROJECT.md` (`Interview mode: auto`, alias `unattended`; modes in `interview-protocol.md`). Default is **steering**: every gate question is asked in chat and the turn ends there. (Failure: an attended run reached Stage 3 with zero questions asked, reading "a solo run never stalls" as "never ask" — `process/baseline-incident-history.md` §R5.) **Unattended runs keep a Ruling ledger.** Nobody can be asked, so the register alone is not enough: an `ASSUMED` line records the decision and sometimes the reason, never the blast diff --git a/skills/interview-protocol.md b/skills/interview-protocol.md index 93394e80..850f810e 100644 --- a/skills/interview-protocol.md +++ b/skills/interview-protocol.md @@ -33,8 +33,9 @@ bin/interview-mode.sh --explain Set it with an `Interview mode:` line in `PROJECT.md` (project default), or `bin/interview-mode.sh --set assist` (this session), or `$CLAUDE_INTERVIEW_MODE` (one -command). Any unrecognised value anywhere falls back to `steering` and says so: the failure -direction of a typo must be "asked too much". +command). `attended` and `unattended` are accepted aliases for `steering` and `auto` (noted on +stderr; the script always outputs the canonical name). Any other unrecognised value falls back to +`steering` and says so: the failure direction of a typo must be "asked too much". **`remote` is a flag, not a fourth mode.** It says the user is not at the keyboard, so gates message them and the run continues rather than parking. It changes the *channel*, never how diff --git a/tests/wave2/test-interview-mode.sh b/tests/wave2/test-interview-mode.sh index 9b6d764f..3ffa0a73 100644 --- a/tests/wave2/test-interview-mode.sh +++ b/tests/wave2/test-interview-mode.sh @@ -80,6 +80,64 @@ bash "$SUBJECT" "$P" --explain 2>/dev/null | grep -q '^from:' \ bash "$SUBJECT" 2>/dev/null; [ "$?" -eq 2 ] && ok "no argument exits 2" || bad "no argument did not exit 2" +# --- aliases: the words the docs used to teach ---------------------------------------------- +# CLAUDE.md, the runbook and intake Q9 told users to write `Interview mode: unattended`. The +# script knew only steering|assist|auto, so a project that opted in exactly as documented was +# silently run as steering. `unattended` = auto and `attended` = steering, in every source; +# stdout stays the canonical name so every consumer (interview-gate.sh) keeps working. +A="$WORK/alias"; mkdir -p "$A/.claude" +# alias_case