crewforth 3.1.0-rc.1 → 3.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -80,8 +80,16 @@ versioning follows [SemVer](https://semver.org/).
80
80
  rules of its architecture guide, the rebuild cost model, layout by window size and never by device type, isolates,
81
81
  platform channels, generated localisations, the mix of unit, widget, integration and golden tests, measuring in
82
82
  profile mode on a real device, and a release without a secret in it. Its done-definition is `dart format
83
- --set-exit-if-changed .`, `flutter analyze`, `flutter test` and the accessibility guideline test. Every rule was
84
- checked against Flutter's and Dart's own documentation; the sources are in the skill's `references/`. 40 skills now.
83
+ --set-exit-if-changed .`, `flutter analyze`, `flutter test` and the accessibility guideline test. It also holds
84
+ what a real app taught it: panes that split at a foldable's hinge, a list and a detail whose selection and typed
85
+ input survive a change of window class, a widget test at one width of each window class, figures of one width for
86
+ amounts, case changed by the language's rules, no inline text (one central file is enough for one language), a
87
+ domain layer with no `package:flutter` import, goldens made in one pinned container, and a store build that
88
+ refuses the flag of a closed feature. For an app that holds sensitive data: an encrypted database, its key in the
89
+ platform's secure store, both kept out of backup and device transfer, every network call through a transport bound to its own allowed hosts,
90
+ and no ad in a window that shows a sensitive screen. The rules were checked against Flutter's, Dart's and the
91
+ named packages' and platforms' own pages; the sources, and what was not checked, are in the skill's `references/`.
92
+ 40 skills now.
85
93
  - **In `auto` and `dontAsk`, your own message approves a commit or a push.** Those modes answer a permission prompt
86
94
  with software, so the commit gate fails closed there and the only way through was to switch mode for every commit.
87
95
  Now a message that is nothing but `approve: commit`, `approve: push` or `approve: commit+push` (`onay:` works too)
@@ -110,9 +118,39 @@ versioning follows [SemVer](https://semver.org/).
110
118
  - **Studio: a layout for a narrow window.** Below 640 px the panel is one column: the navigator is a drawer, the
111
119
  inspector and the conversation take the whole stage and stop above the dock. Checked in a narrow desktop browser
112
120
  window; not measured on a phone.
121
+ - **Studio: what a session spent.** A summary box on the canvas and one line in the navigator give the session's
122
+ time, its tokens (agents included) and an estimate of the cost, such as `51m · 2.8M · ~$3.22`. The cost is an
123
+ estimate at API list price and reads as one (a leading `~`; on a subscription it is not what you are billed). The
124
+ prices are one table, read from the pricing page on 2026-10-08; a model with no row in it shows `—`, never a guess.
125
+ Not checked against an invoice.
126
+ - **Studio: background work is shown.** A Bash command sent to the background is listed on its session (`1 in
127
+ background` on the card, **In the background** in the inspector) until the notice that ends it arrives.
128
+ - **Studio: a question waiting in a terminal is named.** For a session started in a terminal, a permission question
129
+ can only be answered there; the view now says so, with the tool and the command: `Waiting for an answer in the
130
+ terminal: Bash · npm run build`.
131
+ - **Studio: the inspector can be resized** (drag its left edge, arrow keys, double-click for the default; 280 to
132
+ 720 px, remembered).
113
133
 
114
134
  ### Changed
115
135
 
136
+ - **Studio: agents are named by their role.** `crew-backend-expert` reads Backend, `general-purpose` General Purpose,
137
+ in the graph, the Timeline, the List, the dock, the inspector and the conversation; the declared type is under the
138
+ name in the inspector and on hover. A task cut short on a card is said in full on hover.
139
+ - **Studio: a group of agents opens beside itself.** Several agents of one type are one card; in a window of 1024 px
140
+ and up, clicking it puts its agents in the next column as ordinary cards, and opening another group folds the
141
+ first. Below that width it opens downward as before.
142
+ - **In `auto` and `dontAsk`, a refused commit or push says what to ask and what to send back.** The refusal was a
143
+ paragraph with the approval text somewhere inside it, and the question the model made of it was as long. It now
144
+ asks for one shape: the commit message in one code block and, under it, one line with the exact text to send,
145
+ `approve: commit`, `approve: push` or `approve: commit+push` (`onay: …`), named for the call that was refused. A
146
+ sentence that says yes is still not an approval, and nothing about what an approval covers has changed.
147
+ - **Where the approval cannot be recorded, the refusal says so.** The record is written by `prompt-approval.sh`. In a
148
+ session whose settings do not wire it (a worktree that carries an older Crewforth, for one) the message
149
+ `approve: commit` recorded nothing, and the refusal went on asking for it. The gate now reads the settings it can
150
+ see (the plugin's `hooks.json`, the project's `settings.json` and `settings.local.json`, the user's
151
+ `settings.json`) and, when none wires the hook, says the approval path is closed in this session and names the
152
+ two ways that work: Shift+Tab, or your own terminal. The command is refused either way. `doctor.sh` warns about
153
+ the same thing, and about a worktree of the repository that carries another Crewforth version.
116
154
  - **After a final release, npm's `next` no longer names something older than `latest`.** A final moved `latest`
117
155
  only, so `npx crewforth@next` went on installing the rc before it. The release now moves `next` to the final when
118
156
  what `next` names is older; an rc of a later version stays where it is.
@@ -197,6 +235,16 @@ versioning follows [SemVer](https://semver.org/).
197
235
  the same (measured on Windows, in a real session), and the conversation says who refused it. Known limit: the dock
198
236
  can ask about a call that another gate refuses anyway.
199
237
 
238
+ ### Fixed
239
+
240
+ - **Studio: full screen keeps its panels.** In full screen the navigator, the inspector and the conversation could
241
+ not be opened, so a selected agent showed nothing and a request under "Needs you" could not be answered. A style
242
+ rule hid every panel there; it is gone, and full screen now only takes the browser away.
243
+ - **Studio: a background agent that is still working is no longer drawn "Done".** Claude Code sends a `completed`
244
+ notice each time a background agent stops, and the agent can be resumed after it; Studio took the first notice as
245
+ final. A notice now stands only until the agent writes to its transcript again, and while its session is running
246
+ an agent in the middle of a turn is drawn Running.
247
+
200
248
  ## [3.0.4] — 2026-10-05
201
249
 
202
250
  ### Security
@@ -87,8 +87,11 @@ On a mixed/non-atomic diff, **propose a split**; do not commit before approval (
87
87
  The tool-level gate `guard-bash.sh` intercepts commit/push in **every** permission mode: in normal modes it raises an
88
88
  approval prompt only the user can answer — so present the message FIRST, then run the commit yourself and let the user
89
89
  approve it at the prompt. Never hand the user a command to paste into their own terminal. In `auto` and `dontAsk`
90
- the prompt reaches nobody: stage, present the message, and the user approves by sending a message that is only
91
- `approve: commit` (`approve: push`, `approve: commit+push`; `onay:` too). It covers what is staged on that HEAD.
90
+ the prompt reaches nobody: stage, then ask in this shape and with nothing after it — the message in ONE code block,
91
+ and under it one line in the user's language: "If you approve, write only this: `approve: commit`" (`approve: push`,
92
+ `approve: commit+push`; `onay:` too). The user approves by sending that text alone; a sentence that says yes is not an
93
+ approval. It covers what is staged on that HEAD. If the gate says the approval path is closed in this session (the
94
+ recording hook is not wired), say so and offer Shift+Tab or their own terminal instead.
92
95
  Then run `git commit -m '…'` alone in its call (single-quoted, or from a here-document with a quoted delimiter),
93
96
  and a push as `git push <remote> <branch>`. Never write that message yourself. Under `plan` and
94
97
  `bypassPermissions` the gate fails closed; there the user must switch modes or pre-authorise with
@@ -96,6 +96,10 @@ _mt() {
96
96
  'SessionStart not wired — session rehydration after /compact or /clear is inactive (update Crewforth)') s="SessionStart bağlı değil — /compact ya da /clear sonrasında oturum toparlanmıyor (Crewforth'u güncelleyin)" ;;
97
97
  'SessionStart wired (session rehydration active)') s='SessionStart bağlı (oturum toparlama devrede)' ;;
98
98
  'settings.json is invalid JSON') s='settings.json geçersiz JSON' ;;
99
+ 'approval by your own message is wired (auto / dontAsk: approve: commit)') s='kendi mesajınızla onay bağlı (auto / dontAsk: onay: commit)' ;;
100
+ "settings.json wires prompt-approval.sh but the script is missing — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)") s="settings.json prompt-approval.sh'i bağlıyor ama betik yok — auto ve dontAsk modunda 'onay: commit' mesajı hiçbir şey kaydetmez; Shift+Tab ile mod değiştirin ya da kendi terminalinizden commit edin (Crewforth'u güncelleyin)" ;;
101
+ "prompt-approval.sh is not wired on UserPromptSubmit — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)") s="prompt-approval.sh UserPromptSubmit'e bağlı değil — auto ve dontAsk modunda 'onay: commit' mesajı hiçbir şey kaydetmez; Shift+Tab ile mod değiştirin ya da kendi terminalinizden commit edin (Crewforth'u güncelleyin)" ;;
102
+ "worktree %s carries Crewforth %s, this one %s — a session there runs that version's gates and approval path (update Crewforth there)") s="%s worktree'sinde Crewforth %s var, burada %s — orada açılan oturum o sürümün kapılarını ve onay yolunu koşar (Crewforth'u orada güncelleyin)" ;;
99
103
  "fix the syntax by hand — restoring Crewforth's file would drop any hooks you added") s="sözdizimini elle düzeltin — Crewforth'un dosyasını geri yüklemek eklediğiniz hook'ları siler" ;;
100
104
  'settings.json wires hooks through the %s placeholder — on Windows its separators are stripped before bash runs, so NO hook launches and every gate is silently absent') s="settings.json hook'ları %s yer tutucusuyla bağlıyor — Windows'ta ayırıcılar bash çalışmadan silinir, bu yüzden HİÇBİR hook başlamaz ve bütün kapılar sessizce yok olur" ;;
101
105
  'update Crewforth (npx crewforth adopt) — hook commands become:') s="Crewforth'u güncelleyin (npx crewforth adopt) — hook komutları şu olur:" ;;
@@ -349,6 +353,14 @@ if [ -f "$S" ]; then
349
353
  || bad "settings.json hook events empty or missing:%s — those gates won't fire" "restore settings.json from Crewforth (npx crewforth update)" "$EMPTY"
350
354
  sn="$(awk -v op=len -v path=hooks.SessionStart -f "$SJ" "$S" 2>/dev/null)"
351
355
  case "$sn" in ''|0) warn "SessionStart not wired — session rehydration after /compact or /clear is inactive (update Crewforth)" ;; *) ok "SessionStart wired (session rehydration active)" ;; esac
356
+ # In auto and dontAsk the only approval is the user's own message, and ONE hook records it. Where it is not wired
357
+ # the gate stays closed and a message 'approve: commit' records nothing (field report: a worktree on an older
358
+ # Crewforth). A warning, not a failure: commit and push still cannot happen without the user.
359
+ case "$(awk -v op=get -v path=hooks.UserPromptSubmit -f "$SJ" "$S" 2>/dev/null)" in
360
+ *prompt-approval.sh*) if [ -f .claude/hooks/prompt-approval.sh ]; then ok "approval by your own message is wired (auto / dontAsk: approve: commit)"
361
+ else warn "settings.json wires prompt-approval.sh but the script is missing — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)"; fi ;;
362
+ *) warn "prompt-approval.sh is not wired on UserPromptSubmit — in auto and dontAsk the message 'approve: commit' records nothing; switch mode with Shift+Tab or commit in your own terminal (update Crewforth)" ;;
363
+ esac
352
364
  else bad "settings.json is invalid JSON" "fix the syntax by hand — restoring Crewforth's file would drop any hooks you added"; fi
353
365
  # `${CLAUDE_PROJECT_DIR}` inside a hook command is the shape that breaks on Windows, and it breaks invisibly.
354
366
  # Claude Code substitutes that placeholder into the command STRING before any shell sees it; on Windows the
@@ -370,6 +382,20 @@ if [ -f "$S" ]; then
370
382
  else
371
383
  bad "settings.json missing — the tool-level gates (commit approval, guards, context) are INACTIVE" "reinstall Crewforth"
372
384
  fi
385
+ # 4b) Another worktree of this repository on another Crewforth. A session that moves there runs THAT version's hooks
386
+ # against this one's expectations: its gates, its approval path (3.0 has none for auto mode). `.claude/` is
387
+ # tracked in some projects, so a worktree cut from an older branch carries the older kit.
388
+ if [ -f .claude/VERSION ] && git rev-parse --git-dir >/dev/null 2>&1; then
389
+ _wv0="$(head -1 .claude/VERSION 2>/dev/null | tr -cd '0-9A-Za-z.-')"; _wt0="$(git rev-parse --show-toplevel 2>/dev/null)"
390
+ while IFS= read -r _wl; do
391
+ case "$_wl" in "worktree "*) _wp="${_wl#worktree }" ;; *) continue ;; esac
392
+ [ "$_wp" != "$_wt0" ] && [ -f "$_wp/.claude/VERSION" ] || continue
393
+ _wv="$(head -1 "$_wp/.claude/VERSION" 2>/dev/null | tr -cd '0-9A-Za-z.-')"
394
+ [ "$_wv" = "$_wv0" ] || warn "worktree %s carries Crewforth %s, this one %s — a session there runs that version's gates and approval path (update Crewforth there)" "$_wp" "${_wv:-?}" "$_wv0"
395
+ done <<EOF_WT
396
+ $(git worktree list --porcelain 2>/dev/null)
397
+ EOF_WT
398
+ fi
373
399
 
374
400
  # 4a) Skill listing budget. Claude Code puts a listing of every model-invocable skill's name and description into
375
401
  # context each turn and caps it at skillListingBudgetFraction of the context window (default 0.01); over the
@@ -6167,6 +6167,35 @@ GBCP
6167
6167
  ( cd "$DTMP" && env -u CREW_LANG CREW_I18N_MISS="$DTMP/miss2" bash .claude/eval/doctor-twin.sh >/dev/null 2>&1 )
6168
6168
  grep -qx 'planted line with no translation' "$DTMP/miss2" && pass "twin: a planted untranslated line is named by the miss list" \
6169
6169
  || fail "twin: the miss list did not name a planted untranslated line — the empty list above proves nothing"
6170
+ # THE APPROVAL PATH, AS DOCTOR SEES IT. In auto and dontAsk one hook records the user's own message; where it is
6171
+ # not wired, or another worktree carries another Crewforth, the gate stays closed and its advice used to be wrong
6172
+ # (field report, 3.1.0-rc.2). Four states of the wiring and two of the worktree, each read from doctor's own lines.
6173
+ DAP="$(mktemp -d)"; DAP="$(cd -P "$DAP" && pwd)"; mkdir -p "$DAP/p/.claude"
6174
+ for d in eval hooks; do [ -d "$ROOT/$d" ] && cp -R "$ROOT/$d" "$DAP/p/.claude/$d"; done
6175
+ cp "$ROOT/settings.json" "$DAP/p/.claude/settings.json" 2>/dev/null; echo 3.1.0 > "$DAP/p/.claude/VERSION"
6176
+ ( cd "$DAP/p" && git init -q . && git config user.email t@example.com && git config user.name t && git config core.hooksPath /dev/null \
6177
+ && echo x > f && git add f && git commit -qm init && git worktree add -q "$DAP/old" -b old ) >/dev/null 2>&1
6178
+ _dap(){ ( cd "$DAP/p" && CREW_LANG=en bash .claude/eval/doctor.sh 2>/dev/null ); }
6179
+ if [ ! -d "$DAP/old" ]; then fail "FIXTURE: the linked worktree for doctor's approval cases was not created"
6180
+ else
6181
+ _d1="$(_dap)"
6182
+ case "$_d1" in *"approval by your own message is wired"*) case "$_d1" in *"carries Crewforth"*|*"records nothing"*) fail "doctor warned about the approval path on a healthy install with a worktree that carries no Crewforth" ;;
6183
+ *) pass "doctor: the approval hook is reported wired on a fresh install, with no warning" ;; esac ;;
6184
+ *) fail "doctor did not report the approval hook as wired on a fresh install" ;; esac
6185
+ mkdir -p "$DAP/old/.claude"; echo 3.1.0 > "$DAP/old/.claude/VERSION"; _d2="$(_dap)"
6186
+ echo 3.0.4 > "$DAP/old/.claude/VERSION"; _d3="$(_dap)"
6187
+ case "$_d2" in *"carries Crewforth"*) fail "doctor warned about a worktree on the SAME Crewforth" ;; *)
6188
+ # The path is git's own spelling (`C:/…` on Windows, where this script says `/tmp/…`), so only its last part is read.
6189
+ case "$_d3" in *"/old carries Crewforth 3.0.4, this one 3.1.0"*) pass "doctor: a worktree on another Crewforth is named with both versions; one on the same version is not" ;;
6190
+ *) fail "doctor did not name a worktree that carries Crewforth 3.0.4 beside this 3.1.0" ;; esac ;; esac
6191
+ mv "$DAP/p/.claude/hooks/prompt-approval.sh" "$DAP/pa.sh"; _d4="$(_dap)"; mv "$DAP/pa.sh" "$DAP/p/.claude/hooks/prompt-approval.sh"
6192
+ case "$_d4" in *"wires prompt-approval.sh but the script is missing"*) pass "doctor: wired with the script missing is a warning that names the two ways that still work" ;;
6193
+ *) fail "doctor did not warn when prompt-approval.sh is wired and missing" ;; esac
6194
+ sed 's/prompt-approval\.sh/some-other.sh/' "$DAP/p/.claude/settings.json" > "$DAP/s.tmp" && mv "$DAP/s.tmp" "$DAP/p/.claude/settings.json"; _d5="$(_dap)"
6195
+ case "$_d5" in *"prompt-approval.sh is not wired on UserPromptSubmit"*"Shift+Tab"*) pass "doctor: an install whose settings do not wire the approval hook is warned, with Shift+Tab and the terminal named" ;;
6196
+ *) fail "doctor did not warn when prompt-approval.sh is not wired on UserPromptSubmit" ;; esac
6197
+ fi
6198
+ rm -rf "$DAP"
6170
6199
  # WHAT git IS POINTED AT. .claude/git-shim is Crewforth's too: the updater points git there when the project has a
6171
6200
  # hook chain of its own, and each shim runs Crewforth's hook and then the project's. Doctor did not know it: on such a
6172
6201
  # project it answered "not Crewforth's hooks" and advised `git config core.hooksPath .claude/hooks`, the command that
@@ -6897,10 +6926,11 @@ _pa_new(){ # a fresh repository on branch feat/x with one remote, a staged chan
6897
6926
  && git config user.name t && git config core.hooksPath /dev/null && git checkout -q -b feat/x && echo one > a.txt && git add a.txt \
6898
6927
  && git commit -qm init && git remote add origin "$_PA/remote.git" && echo two >> a.txt && git add a.txt ) >/dev/null 2>&1
6899
6928
  _pa_review; }
6929
+ # The fixture wires the recording hook, as an install does (_pa_review writes the settings file): the refusal reads it.
6900
6930
  # The remote's address AS GIT SPELLS IT. On Git Bash git answers `C:/…` where the shell says `/c/…`, so an address is
6901
6931
  # never compared against a path this script built: it is asked back from git (_pa_url), and "elsewhere" is derived from it.
6902
6932
  _pa_url(){ ( cd "$_paw" && git remote get-url --push "${1:-origin}" 2>/dev/null ); }
6903
- _pa_review(){ ( cd "$_paw" && mkdir -p .claude && printf '{"diff_oid":"%s","head":"%s"}\n' "$(git diff --cached | git hash-object --stdin)" "$(git rev-parse --verify --quiet HEAD)" > .claude/review-pass.json ); }
6933
+ _pa_review(){ ( cd "$_paw" && mkdir -p .claude && printf '%s' '{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}' > .claude/settings.json && printf '{"diff_oid":"%s","head":"%s"}\n' "$(git diff --cached | git hash-object --stdin)" "$(git rev-parse --verify --quiet HEAD)" > .claude/review-pass.json ); }
6904
6934
  _pa_rec="$_paw/.git/crewforth-approval"
6905
6935
  # $1 = mode, $2 = the prompt AS JSON TEXT (already escaped), $3 = cwd (default: the repository)
6906
6936
  _pa_say(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"%s","hook_event_name":"UserPromptSubmit","prompt":"%s"}' "${3:-$_paw}" "$1" "$2" \
@@ -6917,7 +6947,7 @@ _pa_op(){ sed -n 's/^op=//p' "$_pa_rec" 2>/dev/null; }
6917
6947
  if [ "$UNITS" != 1 ]; then
6918
6948
  # The cases below feed payloads to the two hooks; like the other gate unit cases they belong to the source run.
6919
6949
  # An installed project keeps the two pins after them: the shared definitions and the wiring.
6920
- skip scope "approval by the user's own message: the unit cases run in the source checkout (scope=install)" 37
6950
+ skip scope "approval by the user's own message: the unit cases run in the source checkout (scope=install)" 39
6921
6951
  elif [ ! -f "$HOOKS/prompt-approval.sh" ]; then
6922
6952
  fail "hooks/prompt-approval.sh is missing — in auto and dontAsk nothing can turn the user's own yes into an approval"
6923
6953
  else
@@ -7458,6 +7488,43 @@ else
7458
7488
  if [ "$_pc2" -ge 5 ] && [ "$_pc2" -le 12 ]; then pass "an approval costs it a handful of git calls ($_pc2 external commands for commit+push), paid once per approval"
7459
7489
  else fail "the approval hook ran $_pc2 external commands for one commit+push approval — outside 5..12, so either it measured nothing or it grew"; fi
7460
7490
  fi
7491
+ # ---- what the refusal tells the model to do (3.1.0, field report) ---------------------------------------------
7492
+ # In auto the refusal is the only instruction the model gets, and the user sees what the model makes of it. Two
7493
+ # things are pinned: with the recording hook wired it asks for one shape (the message in one block, one line with
7494
+ # the exact text to send back, the operation of THIS call); with the hook not wired it says the path is closed and
7495
+ # does not tell anyone to type an approval. Verdicts do not move: each case is refused, and a matching record is
7496
+ # still allowed whatever the wiring says. The case sets CLAUDE_PROJECT_DIR and HOME itself: both are read.
7497
+ _pa_new; mkdir -p "$_PA/home"
7498
+ _pa_gate(){ printf '{"session_id":"s","cwd":"%s","permission_mode":"%s","tool_name":"Bash","tool_input":{"command":"%s"}}' "$_paw" "$1" "$2" > "$_PA/pl.json"
7499
+ ( cd "$_paw" && HOME="$_PA/home" CLAUDE_PROJECT_DIR="$_paw" CREW_GATE_LOG=/dev/null bash "$HOOKS/guard-bash.sh" < "$_PA/pl.json" >"$_PA/out" 2>"$_PA/err" ); _par=$?; _pae="$(cat "$_PA/err")"; }
7500
+ _pa_wire(){ printf '%s' "$1" > "$_paw/.claude/settings.json"; }
7501
+ _PAW_ON='{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/prompt-approval.sh"}]}]}}'
7502
+ _parow=""
7503
+ _pa_want(){ # $1 = label, $2 = text that must be there, $3 = text that must not
7504
+ case "$_par/$_pae" in 2/*"$2"*) case "$_pae" in *"$3"*) _parow="$_parow [$1: says '$3']" ;; esac ;; *) _parow="$_parow [$1: rc $_par, no '$2']" ;; esac; }
7505
+ _pa_wire "$_PAW_ON"
7506
+ _pa_gate auto 'git commit -m x'; _pa_want 'wired, commit' 'write only this: approve: commit' 'APPROVAL PATH IS CLOSED'
7507
+ _pa_want 'wired, commit, one block' 'in ONE code block' 'approve: push'
7508
+ _pa_gate dontAsk 'git push origin feat/x'; _pa_want 'wired, push' 'write only this: approve: push' 'APPROVAL PATH IS CLOSED'
7509
+ _pa_gate auto 'git commit -m x'; _pa_want 'wired, Turkish line' 'yalnız şunu yaz: onay: commit' 'APPROVAL PATH IS CLOSED'
7510
+ rm -f "$_paw/.claude/settings.json"
7511
+ _pa_gate auto 'git commit -m x'; _pa_want 'no settings' 'APPROVAL PATH IS CLOSED' 'write only this'
7512
+ _pa_want 'no settings, the ways' 'Shift+Tab' 'ONE code block'
7513
+ _pa_wire '{"note":"prompt-approval.sh","hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"bash .claude/hooks/route-hint.sh"}]}]}}'
7514
+ _pa_gate auto 'git push origin feat/x'; _pa_want 'named before the event only' 'APPROVAL PATH IS CLOSED' 'write only this'
7515
+ _pa_wire '{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"bash .claude/hooks/guard-bash.sh"}]}]}}'
7516
+ _pa_gate auto 'git commit -m x'; _pa_want 'other events only' 'APPROVAL PATH IS CLOSED' 'write only this'
7517
+ rm -f "$_paw/.claude/settings.json"; _pa_wire "$_PAW_ON"; mv "$_paw/.claude/settings.json" "$_paw/.claude/settings.local.json"
7518
+ _pa_gate auto 'git commit -m x'; _pa_want 'wired in settings.local.json' 'write only this: approve: commit' 'APPROVAL PATH IS CLOSED'
7519
+ rm -f "$_paw/.claude/settings.local.json"
7520
+ if [ -z "$_parow" ]; then pass "the auto-mode refusal asks in one shape when the approval hook is wired, and says the path is closed when it is not (9 readings, each refused)"
7521
+ else fail "the auto-mode refusal does not say what it should:$_parow"; fi
7522
+ # The wiring changes a sentence, never the verdict: with no settings at all, a record the hook wrote still allows
7523
+ # its command, and the same command without the record is refused.
7524
+ _pa_say auto 'approve: commit' >/dev/null; _pa_gate auto 'git commit -m x'; _pav1="$_par"
7525
+ _pa_new; _pa_gate auto 'git commit -m x'; _pav2="$_par"
7526
+ [ "$_pav1/$_pav2" = 0/2 ] && pass "the wiring check moves no verdict: a recorded approval is allowed (rc 0) and its absence refused (rc 2) with no settings file" \
7527
+ || fail "the wiring check moved a verdict: approved $_pav1 (want 0), unapproved $_pav2 (want 2)"
7461
7528
  fi # prompt-approval.sh present
7462
7529
 
7463
7530
  # ---- the two definitions that live in two files, and the wiring -----------------------------------------------
@@ -9171,6 +9238,35 @@ if [ -n "$SGR" ] && [ -f "$SGR/.gitattributes" ] && [ -d "$SGR/packaging" ] && [
9171
9238
  [ "$(printf 'README.md\r\n' | bash "$CDO")" = docs ] || _cv="$_cv crlf"
9172
9239
  [ -z "$_cv" ] && pass "ci-docs-only.sh: documentation reads docs; a hook, evals/run.sh, kit/README.md, ci.yml or an empty diff reads code" \
9173
9240
  || fail "ci-docs-only.sh gave the wrong verdict for:$_cv"
9241
+ # The third answer, `text`: a pull request that changes only the text of skills, agents or commands skips the
9242
+ # macOS and Windows jobs as a documentation one does, and still runs the Linux verify job. That list is pinned
9243
+ # the same way, and every pattern must be a .md under one of the six text directories: `kit/*`, a script or
9244
+ # kit/CLAUDE.md in it would let a gate change skip the platforms where it can break.
9245
+ cdo_tpats(){ awk '/# TEXT-PATTERNS-START/{f=1;next} /# TEXT-PATTERNS-END/{f=0} f && /\) *return 0/ {sub(/^[[:space:]]*/,""); sub(/\).*/,""); print}' "$1"; }
9246
+ cdo_tunsafe(){ while IFS= read -r _p; do case "$_p" in kit/skills/\*.md|kit/agents/\*.md|kit/commands/\*.md|plugin/skills/\*.md|plugin/agents/\*.md|plugin/commands/\*.md) ;; *) printf '%s\n' "$_p" ;; esac; done; }
9247
+ _tp="$(cdo_tpats "$CDO" | tr '\n' ' ' | sed 's/ $//')"
9248
+ _twant='kit/skills/*.md kit/agents/*.md kit/commands/*.md plugin/skills/*.md plugin/agents/*.md plugin/commands/*.md'
9249
+ _ttu="$(printf '%s\n' 'kit/*' 'kit/hooks/*.md' 'kit/skills/*' 'kit/skills/*.md' | cdo_tunsafe | grep -c .)"
9250
+ if [ "$_tp" != "$_twant" ]; then fail "the text-only list changed — it reads '$_tp', pinned '$_twant'. Widening it lets a change skip macOS and Windows; update this pin deliberately"
9251
+ elif [ -n "$(cdo_tpats "$CDO" | cdo_tunsafe)" ]; then fail "a text-only pattern is not a .md under skills, agents or commands: $(cdo_tpats "$CDO" | cdo_tunsafe | tr '\n' ' ')"
9252
+ elif [ "$_ttu" != 3 ]; then fail "the text-pattern check caught $_ttu of 3 planted patterns (kit/*, kit/hooks/*.md, kit/skills/*) — it reads nothing"
9253
+ else pass "the text-only list is exactly the .md files of skills, agents and commands in kit/ and plugin/; 3 planted wider patterns are caught"; fi
9254
+ _cv=""
9255
+ [ "$(cdo kit/skills/x/SKILL.md plugin/skills/x/references/y.md)" = text ] || _cv="$_cv text-set"
9256
+ [ "$(cdo kit/agents/a.md CHANGELOG.md README.md)" = text ] || _cv="$_cv text+docs"
9257
+ [ "$(cdo kit/skills/x/SKILL.md kit/hooks/guard-bash.sh)" = code ] || _cv="$_cv text+hook"
9258
+ [ "$(cdo kit/skills/x/scripts/run.sh)" = code ] || _cv="$_cv skill-script"
9259
+ [ "$(cdo kit/skills/x/SKILL.md kit/CLAUDE.md)" = code ] || _cv="$_cv text+rulebook"
9260
+ [ "$(cdo kit/skills/x/SKILL.md kit/hooks/trace-blocklist.txt)" = code ] || _cv="$_cv text+blocklist"
9261
+ [ "$(cdo README.md)" = docs ] || _cv="$_cv docs-stays-docs"
9262
+ [ -z "$_cv" ] && pass "ci-docs-only.sh: skill, agent and command text reads text, with documentation too; with a hook, a skill's script, kit/CLAUDE.md or a blocklist it reads code" \
9263
+ || fail "ci-docs-only.sh gave the wrong text verdict for:$_cv"
9264
+ # The Linux verify job is gated on nothing: it holds the checks a text change can break.
9265
+ _cyv="$(awk '/^ verify:$/{f=1;next} f && /^ [a-z-]+:$/{f=0} f' "$SGR/.github/workflows/ci.yml" | grep -cE '^ (if|needs):')"
9266
+ _cyn="$(awk '/^ verify:$/{f=1;next} f && /^ [a-z-]+:$/{f=0} f' "$SGR/.github/workflows/ci.yml" | grep -c 'bash packaging/verify.sh')"
9267
+ [ "$_cyv" = 0 ] && [ "$_cyn" -ge 1 ] \
9268
+ && pass "ci.yml runs the Linux verify job on every pull request, a text-only one included (no if:, no needs:)" \
9269
+ || fail "ci.yml's verify job is conditional ($_cyv if:/needs: line(s)) or was not found ($_cyn verify.sh call(s)): a text-only pull request would skip the budget, pointer, routing and plugin checks"
9174
9270
  # ...and ci.yml really uses it: both cross-platform jobs are gated on its answer, nothing else is.
9175
9271
  _cy="$SGR/.github/workflows/ci.yml"
9176
9272
  _gated="$(grep -c "if: needs.changes.outputs.scope == 'code'" "$_cy")"
@@ -103,7 +103,7 @@ if [ -n "${IN:-}" ] && [ -n "${PSID:-}" ]; then
103
103
  # In auto and dontAsk the user's own message is the approval (hooks/prompt-approval.sh); plan and
104
104
  # bypassPermissions have no such route.
105
105
  case "$PM" in
106
- auto|dontAsk) PMHOW="stage the change and show the message, then the user approves by sending a message that is only 'approve: commit' ('approve: push', 'approve: commit+push'; 'onay:' works too). It covers what is staged at that moment, for 30 minutes, until their next message; never write it for them" ;;
106
+ auto|dontAsk) PMHOW="stage the change, show the message in ONE code block and under it one line in the user's language, 'If you approve, write only this: approve: commit' ('approve: push', 'approve: commit+push'; 'onay:' works too); the user approves by sending that text alone. It covers what is staged at that moment, for 30 minutes, until their next message; never write it for them" ;;
107
107
  *) PMHOW="a real yes still comes first, then the user switches mode (or exports CLAUDE_GIT_OK=1 in headless/CI)" ;;
108
108
  esac
109
109
  echo "🔒 Permission mode: $PM. git commit/push fail closed in this mode (§4.4). Only when a commit or push comes up: say so before you ask for approval; $PMHOW. Otherwise do not mention it."
@@ -2355,6 +2355,26 @@ _approval_ok(){ # 0 = the user's recorded approval covers THIS call. Otherwise
2355
2355
  [ -n "$tr" ] && [ "$cur" = "$tr" ] || { _APW="the commit on HEAD does not carry what the user approved"; return 1; }
2356
2356
  return 0
2357
2357
  }
2358
+ # IS THE RECORDING HOOK WIRED? Asked only when a commit or a push is about to be refused, so that the refusal names
2359
+ # a way that exists. It reads the settings this hook can see: the plugin's own hooks.json, the project's
2360
+ # settings.json and settings.local.json, the user's settings.json. HONEST SCOPE: the text of each file is searched
2361
+ # for the script's name after the event's name; it is not parsed, and managed settings are not read. A wrong answer
2362
+ # changes a sentence, never the verdict: the command is refused either way. eval/doctor.sh does the parsed check.
2363
+ _appr_wired(){ # 0 = prompt-approval.sh is beside this hook and a settings file wires it; else _APRW names what was read
2364
+ local f t d="${CLAUDE_PROJECT_DIR:-.}" seen="" here="${BASH_SOURCE%/*}"
2365
+ [ "$here" = "${BASH_SOURCE}" ] && here=.
2366
+ d="${d//\\//}"; d="${d%/}"
2367
+ _APRW="no prompt-approval.sh beside this hook"
2368
+ [ -f "$here/prompt-approval.sh" ] || return 1
2369
+ for f in "${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT//\\//}/hooks/hooks.json}" "$d/.claude/settings.json" "$d/.claude/settings.local.json" "${HOME:+${HOME//\\//}/.claude/settings.json}"; do
2370
+ [ -n "$f" ] && [ -f "$f" ] || continue
2371
+ seen="$seen${seen:+, }$f"
2372
+ t=""; IFS= read -r -d '' t < "$f" || true
2373
+ case "$t" in *'"UserPromptSubmit"'*prompt-approval.sh*) return 0 ;; esac
2374
+ done
2375
+ _APRW="read: ${seen:-no settings file found}"
2376
+ return 1
2377
+ }
2358
2378
  allow_approved(){
2359
2379
  gatelog ALLOW 4.4 "the user's own approval message covers this command"
2360
2380
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"%s"}}\n' \
@@ -3144,11 +3164,22 @@ ${BRANCH_WARN}Approve only if the commit message above was shown to you and you
3144
3164
  gatelog BLOCK 4.4 "commit/push with no matching approval from the user"
3145
3165
  echo "GUARD (§4.4): 'git commit/push' needs the user's approval, and in '$PERM_MODE' a permission prompt is answered by software, not by a person." >&2
3146
3166
  echo "Not allowed now: $_APW." >&2
3147
- echo "Present the commit MESSAGE to the user (stage first: the approval covers what is staged at that moment). Then one of:" >&2
3148
- echo " (a) the user sends a message that is ONLY the approval: 'approve: commit', 'approve: push' or 'approve: commit+push' ('onay: …' works too)." >&2
3149
- echo " It is tied to the staged diff and HEAD at that moment, lasts 30 minutes and ends at the user's next message. The command then runs ALONE in its call (no cd, no pipe, nothing chained): git commit -m …, or git push <remote> <branch>. OR" >&2
3150
- echo " (b) the user presses Shift+Tab to switch to default/acceptEdits — in this session, no restart — and this gate asks them directly, OR" >&2
3151
- echo " (c) the user runs the command in their own terminal." >&2
3167
+ if ! _appr_wired; then
3168
+ # The record is written by one hook. Where it is not wired, a message from the user records nothing, and
3169
+ # telling them to type one sends them into a wall (measured in the field: a worktree on an older Crewforth).
3170
+ echo "THE APPROVAL PATH IS CLOSED IN THIS SESSION: the hook that records the user's approval (prompt-approval.sh) is not wired on UserPromptSubmit in the settings in effect ($_APRW). A message 'approve: …' records nothing here." >&2
3171
+ echo "Tell the user exactly that, in their language, and give them the two ways that work: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3172
+ echo "Do not create an approval any other way." >&2
3173
+ exit 2
3174
+ fi
3175
+ _aps=push; git_has "$CMD_SEEN" 'commit' && _aps=commit
3176
+ echo "ASK THE USER NOW, in their language, in this shape and with nothing after it (stage first: the approval covers what is staged at that moment):" >&2
3177
+ echo " 1. the commit message, whole, in ONE code block;" >&2
3178
+ echo " 2. under it ONE line: If you approve, write only this: approve: $_aps" >&2
3179
+ echo " (in Turkish: Onaylıyorsan yalnız şunu yaz: onay: $_aps). Name the one that fits: commit, push, or commit+push when a push follows the commit." >&2
3180
+ echo "Their reply has to be that text alone: a sentence such as 'go ahead and commit' is not an approval. It is tied to the staged tree and HEAD, lasts 30 minutes and ends at their next message." >&2
3181
+ echo "Then run the command ALONE in its call (no cd, no pipe, nothing chained): git commit -m '…', or git push <remote> <branch>." >&2
3182
+ echo "The other ways: Shift+Tab to default/acceptEdits (this gate then asks them directly), or the command in their own terminal." >&2
3152
3183
  echo "Only the user can write that message. Do not write it for them, and do not create an approval any other way." >&2
3153
3184
  exit 2 ;;
3154
3185
  *)
@@ -13,8 +13,8 @@ Otherwise do not use this skill: `frontend` and the project's own stack apply (s
13
13
  Trigger phrases: "riverpod", "go_router", "pubspec", "widget test", "analysis_options", "flutter_test", "golden test", "platform channel", "dart isolate", "gen-l10n"
14
14
 
15
15
  For generic frontend discipline, applies the `frontend` skill; this file only covers what Flutter adds. Every rule
16
- below was checked against Flutter's own documentation (docs.flutter.dev, dart.dev); the depth and the sources are in
17
- `references/`. Not used in a project on another stack (delete it if needed).
16
+ below was checked against Flutter's own documentation (docs.flutter.dev, dart.dev) or the page of the package or
17
+ platform it names; the depth and the sources are in `references/`, which also say what was not checked. Not used in a project on another stack (delete it if needed).
18
18
 
19
19
  ## The project decides, not this file
20
20
  - **Read before you write:** `pubspec.yaml` (the packages already chosen), `analysis_options.yaml` (the lint set and
@@ -31,7 +31,8 @@ below was checked against Flutter's own documentation (docs.flutter.dev, dart.de
31
31
  an abstract class, so tests can use a fake. **No logic in widgets** — a widget renders state and forwards events.
32
32
  - **Unidirectional data flow** and **immutable models**; a model that crosses a layer is not mutated in place.
33
33
  - **Dependencies are injected** into view models and repositories, not looked up from inside them. A domain layer
34
- only when the logic needs one (the guide marks it conditional).
34
+ only when the logic needs one (the guide marks it conditional). **Where there is one it is plain Dart: no file in
35
+ it imports `package:flutter`**, and an architecture test reads its files and fails on such an import.
35
36
  - Feature folders (`features/<name>/`) with view, view model and tests together, as `frontend` says.
36
37
 
37
38
  ## Widgets and rebuilds — the cost model
@@ -48,6 +49,11 @@ below was checked against Flutter's own documentation (docs.flutter.dev, dart.de
48
49
  and desktop). Measure with **`MediaQuery.sizeOf`** (the whole window; cheaper than `MediaQuery.of`) or
49
50
  **`LayoutBuilder`** (the space this widget is given). Breakpoints come from the project's design system; the
50
51
  Material window classes are compact < 600 · medium 600–839 · expanded 840–1199 · large 1200–1599 · ≥ 1600 dp.
52
+ - **List and detail:** two panes side by side in an expanded window, a push to the detail in a compact one. **What
53
+ the user selected and what they typed survive a change of window class** (a fold, a rotation, a resized window):
54
+ that state lives above the two layouts, not inside either.
55
+ - **A foldable:** when `MediaQuery.displayFeatures` holds a hinge that separates the window, the panes split at the
56
+ hinge and no content is laid out on it.
51
57
  - **Do not lock orientation** — it is an accessibility problem. Keep scroll position across a layout change
52
58
  (`PageStorageKey`), and support mouse, trackpad and keyboard where the app runs on desktop or a large screen.
53
59
  - Accessibility: tap targets 48×48 (Android) / 44×44 (iOS), every tappable labelled, text contrast checked, the UI
@@ -66,23 +72,45 @@ below was checked against Flutter's own documentation (docs.flutter.dev, dart.de
66
72
  ## Navigation, text, data
67
73
  - Navigation: the project's router. Named routes are not recommended by Flutter (no custom deep-link handling, no
68
74
  browser forward button); a page-backed route is deep-linkable, a pageless one is not.
69
- - User-facing text goes through `gen-l10n` (ARB files, `AppLocalizations.of(context)`), plurals and placeholders in
70
- ICU form — never concatenated. The `i18n-integrity` skill checks the files.
75
+ - **No user-facing text is written inline in a widget, in any app.** An app in more than one language uses
76
+ `gen-l10n` (ARB files, `AppLocalizations.of(context)`), plurals and placeholders in ICU form — never concatenated;
77
+ the `i18n-integrity` skill checks the files. An app in one language may keep every text in one central file instead.
78
+ - **Changing case follows the language.** Dart's `toUpperCase()` / `toLowerCase()` use the language-independent
79
+ mapping (Turkish `i` becomes `I`, not `İ`), so text is cased through one helper that takes the locale.
71
80
  - Money is never a `double`; dates carry their time zone or are date-only on purpose (the `frontend` rules apply).
81
+ **Amounts and other numbers that line up are drawn with `FontFeature.tabularFigures()`**, so digits keep one width.
72
82
 
73
83
  ## Tests (the `testing` skill owns the strategy)
74
84
  - Many **unit** and **widget** tests (`flutter_test`), and enough **integration** tests (`integration_test/`, the
75
85
  binding initialised) for the important flows. Tests use fakes of the repositories and services (the guide's
76
86
  recommendation), which is why those sit behind abstract classes.
77
- - **Golden tests** (`matchesGoldenFile`) only where pixels are the contract; generated and compared on the same
78
- platform with fonts loaded first (`flutter test --update-goldens` refreshes them).
79
- - Every new screen gets a widget test at the narrowest and widest window the app supports, and the a11y guideline test.
87
+ - **Golden tests** (`matchesGoldenFile`) only where pixels are the contract, fonts loaded first. **They are generated
88
+ and compared in one place, a pinned container in CI**; `flutter test --update-goldens` is not run on a developer's
89
+ machine, whose rendering differs.
90
+ - **Every new screen reached from the app's navigation gets a widget test at one width of each window class** the
91
+ app supports (for example 320, 360, 700 and 1280 dp), **and a test that crosses a class boundary and finds the
92
+ selection and the typed input still there.** Plus the a11y guideline test.
93
+
94
+ ## If the app holds sensitive data (apply only then)
95
+ Financial, health or identity data, or anything the user would not want read off a lost phone or a backup:
96
+ - **The local database is encrypted**, and its **key lives in the platform's secure store** (Keychain, Keystore),
97
+ never in the database's own directory, in preferences or in the code.
98
+ - **The database and the key's files are kept out of the operating system's backup and device transfer**, on both
99
+ platforms.
100
+ - **Every network call goes through a transport bound to its own allowed host or hosts.** A call that leaves that
101
+ list is refused: it throws before anything is sent, and a test makes such a call and expects the throw. One
102
+ transport for the whole app or one per service are both this rule; a call made outside any transport is not.
103
+ - **In an app that shows ads, no ad is drawn in a window where a sensitive screen is visible** — in a two-pane
104
+ layout that is the whole window, not the pane.
105
+ How, and what was checked: **`references/sensitive-data.md`**.
80
106
 
81
107
  ## Performance and release
82
108
  - **Measure in profile mode on a real device** (`flutter run --profile`); debug mode is not indicative and profile
83
109
  mode does not run on an emulator or simulator. Frame budget: about 16 ms at 60 Hz, 8 ms at 120 Hz.
84
110
  - Release: `--obfuscate --split-debug-info=<dir>` where the target supports it, the symbols kept for `flutter
85
111
  symbolize`. Obfuscation is not encryption: **no secret ships in the app**. An app commits its `pubspec.lock`.
112
+ - **A feature closed by a build flag stays closed in the store build:** the store build fails when it is handed
113
+ that flag, on Android and on iOS. Detail: **`references/platform-async-release.md`**.
86
114
  - Logging: `debugPrint` prints in release mode too unless it sits behind a debug check or an assert; log through the
87
115
  project's logger (the `observability` skill).
88
116
 
@@ -90,5 +118,7 @@ below was checked against Flutter's own documentation (docs.flutter.dev, dart.de
90
118
  - `dart format --set-exit-if-changed .` exits 0 · `flutter analyze` clean (0 issues, the project's lint set) ·
91
119
  `flutter test` green — one run after the last edit, reported with its exit code and counts.
92
120
  - No widget holds business logic; no device-type or orientation check decides a layout.
93
- - The a11y guideline test passes on every new screen; the narrowest and widest window are tested.
121
+ - The a11y guideline test passes on every new screen; each window class the app supports is tested at one width,
122
+ and state survives a change of class.
123
+ - No user-facing text inline; the domain layer (if any) imports no `package:flutter`.
94
124
  - Works across the project's target platform matrix (Android / iOS / web / desktop as declared).
@@ -4,7 +4,8 @@ Checked against https://docs.flutter.dev/ui/adaptive-responsive/general,
4
4
  https://docs.flutter.dev/ui/adaptive-responsive/best-practices,
5
5
  https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility,
6
6
  https://docs.flutter.dev/ui/accessibility/accessibility-testing and the Material window size classes as Android's
7
- documentation states them (2026-10-02).
7
+ documentation states them (2026-10-02); the foldable part against
8
+ https://api.flutter.dev/flutter/widgets/MediaQueryData/displayFeatures.html (2026-10-07).
8
9
 
9
10
  ## The three steps
10
11
  1. **Abstract** the widgets that change with the space, and the data they share.
@@ -32,6 +33,23 @@ documentation states them (2026-10-02).
32
33
  - A list that keeps its layout across a rotation keeps its scroll position: `PageStorageKey`.
33
34
  - Mouse, trackpad and keyboard shortcuts work where the app runs on desktop or a large screen.
34
35
 
36
+ ## List and detail
37
+ - Expanded and wider: the list and the detail are two panes of one screen. Compact: the list is the screen and the
38
+ detail is pushed over it. Medium is the design system's call.
39
+ - **The selection and any typed input are kept across a change of window class.** Hold them above the two layouts
40
+ (the view model, or state owned by the widget that chooses the layout), so that going from two panes to one shows
41
+ the detail that was selected, with the form as it was, and going back does not clear it. A `GlobalKey` or a
42
+ `PageStorageKey` keeps a subtree's state only while its place in the tree allows it; state that must survive is
43
+ not left to that.
44
+
45
+ ## A foldable
46
+ - `MediaQuery.displayFeatures` (and `MediaQueryData.displayFeatures`) lists the areas of the display that hardware
47
+ obstructs. The documentation says it is populated on Android only. A **hinge** and a **cutout** obstruct the
48
+ display; a **fold** is a crease and does not. A hinge and a fold carry a state that tells the device's posture.
49
+ - When a hinge separates the window into two areas, **the panes split at the hinge**: one pane on each side, and
50
+ nothing — no text, no control, no divider the user must touch — laid out on the hinge's bounds.
51
+ - A window with no such feature, or with a fold that obstructs nothing, is laid out by its width alone.
52
+
35
53
  ## Accessibility, tested
36
54
  Rules (the `a11y` skill holds them for every stack): tap targets at least 48×48 on Android and 44×44 on iOS; a
37
55
  label on every tappable; text contrast at least 4.5:1 (3:1 for large text, 18 pt and above); the UI legible and
@@ -51,5 +69,13 @@ testWidgets('meets the accessibility guidelines', (tester) async {
51
69
  });
52
70
  ```
53
71
 
54
- Run it for every new screen, and run the screen's widget test at the narrowest and widest window the app supports
55
- (`tester.view.physicalSize` and `devicePixelRatio`, reset afterwards).
72
+ Run it for every new screen.
73
+
74
+ ## Widths, tested
75
+ - Every new screen reached from the app's navigation has a widget test at **one width of each window class the app
76
+ supports**, for example 320 and 360 dp (compact, a small and a common phone), 700 dp (medium) and 1280 dp (large).
77
+ Set the size with `tester.view.physicalSize` and `devicePixelRatio`, and reset both afterwards.
78
+ - One test **crosses a class boundary**: select an item and type into a form at one width, change the width to
79
+ another class, and expect the same selection and the same input.
80
+ - The narrowest and the widest window alone are not enough: a layout can be right at both ends and wrong in a class
81
+ between them.
@@ -33,6 +33,15 @@ https://dart.dev/tools/dart-format and https://dart.dev/tools/pub/private-files
33
33
  the browser's forward button does not work with them.
34
34
 
35
35
  ## Text and locales
36
+ - No user-facing text is written inline. An app in more than one language uses `gen-l10n` (below). An app in one
37
+ language may keep every text in one central file of constants instead: one place to read, review and later move
38
+ to ARB files.
39
+ - `String.toUpperCase()` "uses the language independent Unicode mapping and thus only works in some languages"
40
+ (api.dart.dev, 2026-10-07), and `toLowerCase()` likewise: under them Turkish `i` gives `I` and `I` gives `i`,
41
+ where the language has `İ` and `ı`. Text is cased by one helper of the project that takes the locale and handles
42
+ the letters its languages need; nothing calls the two methods on user-facing text directly.
43
+ - `FontFeature.tabularFigures()` (`TextStyle.fontFeatures`) asks the font for figures of one width, so amounts in a
44
+ column line up and a changing number does not shift the text beside it. It does nothing in a font without them.
36
45
  - `flutter_localizations` and `intl` in `pubspec.yaml`, `generate: true` under `flutter:`, an `l10n.yaml` with
37
46
  `arb-dir`, `template-arb-file`, `output-localization-file`. `flutter gen-l10n` (or `flutter pub get` / `run`)
38
47
  generates `AppLocalizations`.
@@ -47,6 +56,9 @@ https://dart.dev/tools/dart-format and https://dart.dev/tools/pub/private-files
47
56
  - Golden files: `matchesGoldenFile('goldens/x.png')`, refreshed with `flutter test --update-goldens`. The default
48
57
  test font (Ahem) draws boxes; custom fonts render differently across platforms and Flutter versions, so goldens are
49
58
  produced and compared on one platform, with fonts loaded first (`flutter_test_config.dart`).
59
+ - One place, in practice: a container image pinned by digest, with the Flutter version pinned, in CI. A golden that
60
+ changed is regenerated there (a CI job that uploads the new files, or the same image run by hand) and reviewed as
61
+ an image diff. `--update-goldens` on a developer's own machine produces files CI then fails.
50
62
 
51
63
  ## Release
52
64
  - `flutter build <target> --obfuscate --split-debug-info=<dir>` on the targets that support it (Android, iOS, macOS,
@@ -57,3 +69,32 @@ https://dart.dev/tools/dart-format and https://dart.dev/tools/pub/private-files
57
69
  - An application commits `pubspec.lock` (a library package does not): transitive upgrades become visible changes.
58
70
  - `dart format` rewrites files by default; in CI and in the DoD use `dart format --set-exit-if-changed .` (exit 1
59
71
  when anything would change). The line width is the project's `formatter` setting in `analysis_options.yaml`.
72
+
73
+ ## A feature closed by a build flag
74
+ A feature that must not reach the store yet (it waits for a review, a consent text, a legal check) is closed in code
75
+ by a compile-time flag: `const bool.fromEnvironment('NAME')`, set with `--dart-define=NAME=true`. The compiler drops
76
+ the closed branch. That keeps it out of an ordinary build; it does not keep it out of a store build that someone
77
+ runs with the flag. **The store build itself refuses the flag**, in the build system of each platform, before
78
+ anything is compiled:
79
+
80
+ - **Android (Gradle):** a task the release bundle depends on reads the project properties the Flutter Gradle plugin
81
+ is handed — `dart-defines` and `extra-front-end-options` — and fails the build when a closed flag is among them.
82
+ - **iOS (Xcode):** a Run Script build phase, placed first in the app target, reads the build settings `DART_DEFINES`
83
+ and `EXTRA_FRONT_END_OPTIONS` and fails a release archive the same way (a line that starts with `error:` shows
84
+ among Xcode's build errors).
85
+
86
+ The ways round a check that only looks for `NAME=true`, each of which the check closes:
87
+ - **The defines are encoded.** They arrive as one value: entries separated by commas, each the base64 of
88
+ `NAME=value`. The check decodes every entry; an entry it cannot decode fails the build.
89
+ - **Any value sets it.** The flag's name fails the build whatever follows the `=`, and so does the name anywhere
90
+ inside another entry.
91
+ - **The front end's own options.** A define can be passed to the Dart front end directly (`-DNAME=…`), and other
92
+ options replace what is compiled. A store build is given no front-end options at all; one that is, fails.
93
+ - **Only the store artifact is checked** — the release app bundle and the release archive. A debug or profile
94
+ build, and a release build for one's own device, may carry the flag.
95
+ - A test in the project pins the check: it runs it with the flag, with the flag encoded, with a front-end option,
96
+ and with none, and expects three failures and one pass.
97
+
98
+ Not from Flutter's documentation: the property and setting names above are what the Flutter tool hands to Gradle
99
+ and Xcode, read from a project that does this. They are not a documented contract, so the project's test also fails
100
+ when a Flutter upgrade renames them, instead of the check passing because it reads nothing.