From 595a3918279d5f5b7b5a56412460810a44455433 Mon Sep 17 00:00:00 2001 From: Chaoyi Chen Date: Fri, 22 May 2026 15:23:41 +0800 Subject: [PATCH] chore(archive): apply add-scene-awareness spec deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the spec deltas from add-scene-awareness (#114) into the canonical openspec/specs/ tree: - NEW openspec/specs/scenes/spec.md — full capability spec (Purpose + 3 requirements + 8 scenarios) - MODIFIED specs/cli/spec.md — adds --scene SCENE requirement - MODIFIED specs/event-log/spec.md — adds session_meta requirement - MODIFIED specs/analyze/spec.md — adds session_meta consumer + --for-llm scene context requirements - MODIFIED specs/bluetooth-scanning/spec.md — presence_gate_s default sourced from scene, --ble-presence-gate flag overrides - MODIFIED specs/tui-shell/spec.md — adds subtitle scene chip requirement (EN + ZH formats) Moves the change dir to openspec/changes/archive/2026-05-22-add-scene-awareness/. All artifacts done, all tasks complete, validate --specs --strict green (22/22 — scenes is the 22nd capability). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-add-scene-awareness}/design.md | 0 .../proposal.md | 0 .../specs/analyze/spec.md | 0 .../specs/bluetooth-scanning/spec.md | 0 .../specs/cli/spec.md | 0 .../specs/event-log/spec.md | 0 .../specs/scenes/spec.md | 0 .../specs/tui-shell/spec.md | 0 .../2026-05-22-add-scene-awareness}/tasks.md | 4 +- openspec/specs/analyze/spec.md | 38 +++++++++ openspec/specs/bluetooth-scanning/spec.md | 12 ++- openspec/specs/cli/spec.md | 25 ++++++ openspec/specs/event-log/spec.md | 34 ++++++++ openspec/specs/scenes/spec.md | 80 +++++++++++++++++++ openspec/specs/tui-shell/spec.md | 31 +++++++ 15 files changed, 221 insertions(+), 3 deletions(-) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/design.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/proposal.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/specs/analyze/spec.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/specs/bluetooth-scanning/spec.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/specs/cli/spec.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/specs/event-log/spec.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/specs/scenes/spec.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/specs/tui-shell/spec.md (100%) rename openspec/changes/{add-scene-awareness => archive/2026-05-22-add-scene-awareness}/tasks.md (96%) create mode 100644 openspec/specs/scenes/spec.md diff --git a/openspec/changes/add-scene-awareness/design.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/design.md similarity index 100% rename from openspec/changes/add-scene-awareness/design.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/design.md diff --git a/openspec/changes/add-scene-awareness/proposal.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/proposal.md similarity index 100% rename from openspec/changes/add-scene-awareness/proposal.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/proposal.md diff --git a/openspec/changes/add-scene-awareness/specs/analyze/spec.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/specs/analyze/spec.md similarity index 100% rename from openspec/changes/add-scene-awareness/specs/analyze/spec.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/specs/analyze/spec.md diff --git a/openspec/changes/add-scene-awareness/specs/bluetooth-scanning/spec.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/specs/bluetooth-scanning/spec.md similarity index 100% rename from openspec/changes/add-scene-awareness/specs/bluetooth-scanning/spec.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/specs/bluetooth-scanning/spec.md diff --git a/openspec/changes/add-scene-awareness/specs/cli/spec.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/specs/cli/spec.md similarity index 100% rename from openspec/changes/add-scene-awareness/specs/cli/spec.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/specs/cli/spec.md diff --git a/openspec/changes/add-scene-awareness/specs/event-log/spec.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/specs/event-log/spec.md similarity index 100% rename from openspec/changes/add-scene-awareness/specs/event-log/spec.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/specs/event-log/spec.md diff --git a/openspec/changes/add-scene-awareness/specs/scenes/spec.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/specs/scenes/spec.md similarity index 100% rename from openspec/changes/add-scene-awareness/specs/scenes/spec.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/specs/scenes/spec.md diff --git a/openspec/changes/add-scene-awareness/specs/tui-shell/spec.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/specs/tui-shell/spec.md similarity index 100% rename from openspec/changes/add-scene-awareness/specs/tui-shell/spec.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/specs/tui-shell/spec.md diff --git a/openspec/changes/add-scene-awareness/tasks.md b/openspec/changes/archive/2026-05-22-add-scene-awareness/tasks.md similarity index 96% rename from openspec/changes/add-scene-awareness/tasks.md rename to openspec/changes/archive/2026-05-22-add-scene-awareness/tasks.md index fe3de1a..18332ed 100644 --- a/openspec/changes/add-scene-awareness/tasks.md +++ b/openspec/changes/archive/2026-05-22-add-scene-awareness/tasks.md @@ -69,5 +69,5 @@ ## 10. Merge + archive -- [ ] PR open, reviewed, merged -- [ ] `/opsx:archive add-scene-awareness` +- [x] PR open, reviewed, merged (#114) +- [x] `/opsx:archive add-scene-awareness` diff --git a/openspec/specs/analyze/spec.md b/openspec/specs/analyze/spec.md index 7c32b2f..3b85ce1 100644 --- a/openspec/specs/analyze/spec.md +++ b/openspec/specs/analyze/spec.md @@ -80,3 +80,41 @@ takeaway — the rest is context. - **WHEN** three heuristics fire - **THEN** the report ends with a TODO section containing three bulleted `todo` lines, one per Insight + +### Requirement: Analyze SHALL consume `session_meta` and surface scene in the report header +`diting analyze` SHALL parse the first line of each input JSONL as a potential `session_meta` event; when present, the report header SHALL include a "Scene:" line naming the scene plus its resolution source. + +For multi-file glob input where input JSONLs were captured under different scenes, the report header SHALL summarise the mix (e.g., `Scenes: 3 × home, 1 × office`) rather than picking one. Per-network rankings and the top-contributors table SHALL note when their data spans multiple scenes so the user reads ranks knowing different baselines are mixed. + +JSONL files written by pre-`session_meta` diting builds SHALL be tolerated — when `session_meta` is absent, the analyzer SHALL render `Scene: unknown (pre-scene-aware capture)` in the report header and proceed without scene-derived assumptions. + +#### Scenario: Single-session report header surfaces scene +- **WHEN** `diting analyze diting-20260522-130000.jsonl` runs against a JSONL whose first line is `{"type":"session_meta","scene":"office","scene_source":"cli",...}` +- **THEN** the report header includes `Scene: office (cli)` + +#### Scenario: Multi-session mix is reported +- **WHEN** `diting analyze logs/*.jsonl --since 30d` runs across files captured under different scenes +- **THEN** the header summarises the scene mix instead of picking one + +#### Scenario: Pre-scene-aware log degrades gracefully +- **WHEN** the input JSONL has no `session_meta` line (e.g. a v1.5.0 capture) +- **THEN** the report renders `Scene: unknown (pre-scene-aware capture)` and continues normally + +### Requirement: `--for-llm` SHALL inject scene context into the prompt template +The Markdown prompt written by `diting analyze --for-llm` SHALL include a `[Scene context]` paragraph immediately before the role section. The paragraph is sourced from `scene_defaults(scene)["llm_prior"]` and tells the LLM what baseline to expect for the captured environment, framed as "look for departures from this baseline, not the baseline itself". + +For single-scene input the paragraph mentions the scene + observed BSSID / BLE counts from the data. For multi-scene input the paragraph names the mix and instructs the LLM to compare across scenes. + +For JSONL input lacking `session_meta` the paragraph SHALL still be present but acknowledge the gap: "Scene unknown (pre-scene-aware capture). Apply general priors only." + +#### Scenario: Office-mode capture gives the LLM the office prior +- **WHEN** `diting analyze diting-office.jsonl --for-llm` runs against an office-scene log +- **THEN** the generated `prompt.txt` includes a `[Scene context]` paragraph mentioning `office` mode and the "dense enterprise env" prior; the paragraph appears BEFORE the role / tasks / output-format / guardrail sections + +#### Scenario: Multi-scene bundle acknowledges the mix +- **WHEN** the bundle spans both home-scene and office-scene logs +- **THEN** the paragraph names the mix and instructs the LLM to compare across scenes rather than apply one prior + +#### Scenario: Pre-scene-aware log +- **WHEN** the input has no session_meta +- **THEN** the paragraph notes the gap and tells the LLM to use general priors only diff --git a/openspec/specs/bluetooth-scanning/spec.md b/openspec/specs/bluetooth-scanning/spec.md index eaf7e60..35a3cb7 100644 --- a/openspec/specs/bluetooth-scanning/spec.md +++ b/openspec/specs/bluetooth-scanning/spec.md @@ -159,7 +159,9 @@ The exclusion is implemented via the `category_only=True` flag on `service_categ - **Bypass path** — the identifier's first observation carries a non-null `name` OR the identifier comes from the `_connected` snapshot. Graduates to PRESENT on the same tick, `BLEDeviceSeenEvent` fires with the original `first_seen` timestamp. - **Gated path** — the identifier's first observation is anonymous (no helper-given `name`, only `vendor` + RSSI). The identifier enters PENDING with a stored `first_seen` timestamp. On each subsequent tick, the poller checks whether `(now - first_seen) >= presence_gate_s`. When that elapses AND the identifier is still in `_devices`, the identifier graduates to PRESENT and `BLEDeviceSeenEvent` fires with `timestamp = first_seen` (NOT the wall-clock graduation time). -`presence_gate_s` is configurable via `BLEPoller(presence_gate_s=...)` and defaults to **5.0** seconds. A value of `0.0` restores the pre-gate "every first-seen identifier fires seen on its first observation" behaviour, including for anonymous adverts; in that case PENDING is bypassed entirely. +`presence_gate_s` is configurable via `BLEPoller(presence_gate_s=...)`. The default `presence_gate_s` for any given session SHALL be sourced from the active scene: `scene_defaults(get_scene())["ble_presence_gate_s"]`. With the four canonical scenes that resolves to `home=5.0`, `office=15.0`, `public=30.0`, `audit=0.0`. The `home` value (5.0) matches the pre-scene v1.5.0 default — upgrading users who do not pass `--scene` see no behaviour change. + +`--ble-presence-gate D` on the CLI SHALL override the scene-derived default — the explicit flag is narrower-scoped and always wins. A value of `0.0` (whether from `--scene audit` or from `--ble-presence-gate 0`) restores the pre-gate "every first-seen identifier fires seen on its first observation" behaviour, including for anonymous adverts; in that case PENDING is bypassed entirely. `BLEPoller` SHALL emit `BLEDeviceLeftEvent` when a PRESENT device's `last_seen` falls more than the existing TTL behind the latest snapshot AND the device is then removed from state. @@ -187,6 +189,14 @@ The `BLEPoller.events()` async iterator's union return type SHALL include `BLEDe - **WHEN** `BLEPoller(presence_gate_s=0.0)` is constructed AND an anonymous advertisement populates `_devices[ident]` for the first time - **THEN** `BLEDeviceSeenEvent` is yielded on the same tick, with no PENDING state entered +#### Scenario: Scene `office` sources a 15 s gate +- **WHEN** `diting --scene office` is launched with no explicit `--ble-presence-gate` +- **THEN** `BLEPoller.presence_gate_s == 15.0` for the session + +#### Scenario: `--ble-presence-gate` overrides scene +- **WHEN** `diting --scene office --ble-presence-gate 5s` is launched +- **THEN** `BLEPoller.presence_gate_s == 5.0` for the session; the scene name remains `office` for session_meta / LLM context + #### Scenario: TTL eviction fires left - **WHEN** a tracked device's `last_seen` exceeds the BLE TTL relative to the latest snapshot's `now` AND the identifier had previously graduated to PRESENT - **THEN** `BLEDeviceLeftEvent` is yielded with `seen_for_seconds = last_seen - first_seen`; the entry is removed from `_state` diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index f3057ee..b600ff4 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -134,6 +134,31 @@ Watchdog SEMANTICS — severity gate, silence window, env-var configuration, not - **WHEN** the user runs `diting monitor` (no `--notify`) - **THEN** the headless monitor emits JSONL events with NO `osascript` invocations +### Requirement: `--scene SCENE` SHALL select one of the four named scenes for the session +The `--scene` flag SHALL accept exactly one of `home`, `office`, `public`, `audit`. The flag is global — valid on the default TUI subcommand, `monitor`, `calibrate`, and any future subcommand that runs pollers. + +Resolution precedence is documented in the `scenes` capability and SHALL be honoured here: `--scene` (highest) > `DITING_SCENE` env var > default `home`. + +Invalid values SHALL exit non-zero with a clear error listing the four accepted names. Missing value (`--scene` with no argument) SHALL exit non-zero. + +The resolved scene SHALL be threaded through every subcommand that constructs a `BLEPoller`, so the poller's `presence_gate_s` default matches `scene_defaults(scene)["ble_presence_gate_s"]` unless `--ble-presence-gate D` explicitly overrides it. Override precedence: explicit `--ble-presence-gate` > scene-derived default. + +#### Scenario: User in an office picks the office scene +- **WHEN** they run `diting --scene office` +- **THEN** the BLE presence gate defaults to 15 s (not the home / default 5 s) + +#### Scenario: Explicit `--ble-presence-gate` overrides scene +- **WHEN** they run `diting --scene office --ble-presence-gate 5s` +- **THEN** the BLE presence gate is 5 s; the scene name is still `office` (used by session_meta and LLM context) + +#### Scenario: Invalid scene name +- **WHEN** they run `diting --scene shop` +- **THEN** stderr prints "unsupported scene: 'shop'" and lists the four valid names; the process exits non-zero + +#### Scenario: Missing value +- **WHEN** they run `diting --scene` (no argument) +- **THEN** stderr prints a clear error; the process exits non-zero + ### Requirement: `--version` SHALL print the running version and exit 0 `diting --version` SHALL print exactly one line `diting ` to stdout (where `` is the value of `importlib.metadata.version("diting")`) and exit with status 0. The flag SHALL be recognised at the top level only; passing it after a subcommand (`diting once --version`) SHALL be ignored or rejected by the subcommand's own argument parser. diff --git a/openspec/specs/event-log/spec.md b/openspec/specs/event-log/spec.md index 9d71100..b7e1028 100644 --- a/openspec/specs/event-log/spec.md +++ b/openspec/specs/event-log/spec.md @@ -128,3 +128,37 @@ The no-op logger contract (writer accepts `None` for `enable_logging=False` runs - **WHEN** `EventLogger(None)` has any of the seven new methods called on it - **THEN** the call returns silently; no file is opened, no exception is raised + +### Requirement: Every session JSONL SHALL open with a `session_meta` event +On `EventLogger` open (when a writable path is supplied), the writer SHALL emit exactly one `session_meta` line as the **first line** of the file, before any other event. The line carries the session-wide context that downstream tools (the analyzer, the `--for-llm` bundle, third-party `jq` consumers) need to interpret per-event data correctly. + +The `session_meta` event SHALL include these fields: + +| Field | Type | Source | +|---|---|---| +| `type` | string, fixed `"session_meta"` | constant | +| `ts` | ISO-8601 local TZ, same format as other events | `datetime.now(LOCAL_TZ)` at writer open | +| `scene` | string, one of `home` / `office` / `public` / `audit` | `get_scene()` | +| `scene_source` | string, one of `cli` / `env` / `default` | resolution layer in `cli.py` | +| `diting_version` | string | `importlib.metadata.version("diting")` | +| `ssid` | string or null | latest connection's SSID at open time; null if not yet connected | +| `gateway_ip` | string or null | latest connection's gateway IP; null if not yet known | +| `hostname` | string | `socket.gethostname()` | + +Field omission rules match the existing event schema: `None` values are written through (NOT skipped) because downstream consumers want to distinguish "not known" from "not measured". `ts` follows the existing local-TZ-offset convention. + +Per-event lines following the session_meta SHALL remain unchanged — the scene context lives ONLY in the session header, never per-event, to avoid the ~20 byte × N-events overhead at session scale. + +When `diting monitor` is invoked (stdout mode), the same `session_meta` line SHALL be emitted as the first stdout line, byte-identical to the file-mode case (this preserves the existing "byte-identical streams" guarantee from the `--log` / `monitor` parity requirement). + +#### Scenario: First line of a session log is session_meta +- **WHEN** `diting --log /tmp/x.jsonl --scene office` runs and exits cleanly +- **THEN** the first line of `/tmp/x.jsonl` parses as `{"type": "session_meta", "scene": "office", "scene_source": "cli", ...}` + +#### Scenario: session_meta records the resolution source +- **WHEN** `DITING_SCENE=office diting --log /tmp/x.jsonl` runs (no `--scene` flag) +- **THEN** the `scene_source` field of the session_meta line is `"env"` + +#### Scenario: session_meta when SSID is unknown at start +- **WHEN** diting launches without a current Wi-Fi connection +- **THEN** the session_meta line still emits with `"ssid": null` and `"gateway_ip": null`; subsequent per-event lines may carry the SSID once it's known diff --git a/openspec/specs/scenes/spec.md b/openspec/specs/scenes/spec.md new file mode 100644 index 0000000..3f75025 --- /dev/null +++ b/openspec/specs/scenes/spec.md @@ -0,0 +1,80 @@ +# scenes Specification + +## Purpose + +A **scene** is diting's notion of "where the user is right now" — a named environment that carries default knobs and a baseline-expectation prior. Four canonical scenes (`home` / `office` / `public` / `audit`) cover the bulk of diting's use cases at meaningful baselines: a home apartment where every new BLE identifier is interesting vs an office floor where continuous Continuity churn IS the baseline vs a cafe where almost everything is passers-by vs an audit session where the user wants every advert recorded. + +Active scene is set once at process startup, threaded through pollers + the JSONL session header + the LLM prompt bundle. Downstream consumers (the analyzer's cross-session aggregation, the `--for-llm` prompt template) use the scene as load-bearing context for interpretation. + +## Requirements + +### Requirement: diting SHALL recognise four named scenes that describe the user's network environment +diting SHALL accept one of exactly four scene names: `home`, `office`, `public`, `audit`. Each scene describes a class of network environment and carries a set of default knobs (currently one: `ble_presence_gate_s`; more in future phases). + +Scene semantics: + +- `home` — apartment / own Wi-Fi, ~10-15 BLE devices, single AP. Sparse RF; anomalies dominate the signal. **Default scene** when no flag and no env var are set. +- `office` — corporate floor, enterprise Wi-Fi, 50+ BLE devices, dense BSSID surface. Continuous Continuity / Find My churn is the baseline. +- `public` — cafe / train / plane / public Wi-Fi. Highest cardinality of unique identifiers; almost everything is passers-by. +- `audit` — actively investigating: forensics / security / brief-advertiser debug / device research. All filtering disabled; record everything. + +Names SHALL be lowercase ASCII. Unknown scene names SHALL exit with a clear error. + +#### Scenario: home is the default scene +- **WHEN** `diting` is invoked with no `--scene` flag and no `DITING_SCENE` env var +- **THEN** `get_scene()` returns `"home"` and `scene_defaults("home")["ble_presence_gate_s"]` is 5.0 (matches the v1.5.0 default behaviour) + +#### Scenario: invalid scene name exits with error +- **WHEN** `diting --scene shop` is invoked +- **THEN** the process exits non-zero with a stderr message listing the valid scene names + +### Requirement: Scene resolution SHALL follow a documented precedence +diting SHALL pick the active scene at startup using this precedence (highest first): + +1. CLI flag `--scene SCENE` (explicit per-session) +2. Env var `DITING_SCENE=SCENE` (shell-level persistent preference) +3. Default `home` + +A blank env var (set but empty) is treated as absent so a parent shell can clear it with `DITING_SCENE= diting`. An invalid env-var value SHALL print a stderr warning and fall back to the default (not exit), so a broken shell rc doesn't break startup. + +The CLI flag wins over an env var even when both are set; the env var wins over the default. The resolved scene's **source** (`cli` / `env` / `default`) SHALL be retrievable separately from the scene name — downstream consumers (the JSONL `session_meta`, the analyzer's report header) record this source so users can later distinguish "I explicitly chose this" from "the default kicked in". + +#### Scenario: CLI flag wins over env var +- **WHEN** `DITING_SCENE=office diting --scene home` is invoked +- **THEN** the active scene is `home` and the source is `cli` + +#### Scenario: env var fills in when no flag +- **WHEN** `DITING_SCENE=office diting` is invoked +- **THEN** the active scene is `office` and the source is `env` + +#### Scenario: blank env var falls to default +- **WHEN** `DITING_SCENE= diting` is invoked (env var set to empty string) +- **THEN** the active scene is `home` and the source is `default` + +#### Scenario: invalid env var warns and falls to default +- **WHEN** `DITING_SCENE=shop diting` is invoked +- **THEN** a stderr warning is printed; the active scene is `home`; source is `default`; the process continues to launch (does NOT exit) + +### Requirement: `scene_defaults(scene)` SHALL return a stable mapping of knobs +The function `scene_defaults(scene: str) -> dict[str, Any]` SHALL return a dict keyed by knob name. The dict MUST include `ble_presence_gate_s` (float seconds) and `llm_prior` (string for LLM prompt injection). Other keys MAY be present in future phases without breaking callers — callers SHALL read keys defensively (use `.get(name, default)`). + +Per-scene values for the keys defined in this phase: + +| Scene | `ble_presence_gate_s` | `llm_prior` (abridged) | +|---|---|---| +| `home` | 5.0 | "small known network — novelty matters" | +| `office` | 15.0 | "dense enterprise env — baseline churn expected" | +| `public` | 30.0 | "hostile shared Wi-Fi — cardinality is noise" | +| `audit` | 0.0 | "raw capture — no filtering applied" | + +#### Scenario: home presence gate is 5 s +- **WHEN** `scene_defaults("home")["ble_presence_gate_s"]` is read +- **THEN** the value is exactly `5.0` + +#### Scenario: audit presence gate is 0 s +- **WHEN** `scene_defaults("audit")["ble_presence_gate_s"]` is read +- **THEN** the value is exactly `0.0` + +#### Scenario: callers read knobs defensively +- **WHEN** code reads `scene_defaults("home").get("future_knob", "fallback")` against a build that doesn't yet implement `future_knob` +- **THEN** the call returns `"fallback"` and does NOT raise diff --git a/openspec/specs/tui-shell/spec.md b/openspec/specs/tui-shell/spec.md index cc30599..d076576 100644 --- a/openspec/specs/tui-shell/spec.md +++ b/openspec/specs/tui-shell/spec.md @@ -333,3 +333,34 @@ i18n: the new wrapper strings (`SSID: {ssid}`, `SSID: {prev} → {new}`, `SSID { - **WHEN** the event has `ssid=None` - **THEN** the rendered line is unchanged from the legacy (pre-enrichment) shape — the trailing `· SSID …` segment is absent + +### Requirement: The subtitle SHALL include a scene chip +The `BrandHeader`'s subtitle line (rendered from `App.sub_title` / `_build_subtitle()`) SHALL include the active scene as a short chip alongside the existing view name and scan-interval indicator. The chip uses dim styling consistent with the rest of the subtitle. + +Format (EN): + +``` +view: Wi-Fi · scan 7s · [home] +``` + +Format (ZH): + +``` +视图:Wi-Fi · 扫描间隔 7s · [家] +``` + +The chip text is the scene name in the active locale (EN catalog: `home` / `office` / `public` / `audit`; ZH catalog: `家` / `公司` / `公共` / `排查`). The square brackets are part of the format and are NOT locale-dependent. + +The subtitle SHALL re-render when the active view or scan interval changes; the scene chip itself never changes during a session (scene is fixed at startup), but it MUST be re-rendered with the subtitle to remain visible after each refresh. + +#### Scenario: EN home scene chip +- **WHEN** `diting --scene home` is launched in an EN locale +- **THEN** the subtitle reads `view: Wi-Fi · scan 7s · [home]` + +#### Scenario: ZH office scene chip +- **WHEN** `diting --scene office --lang zh` is launched +- **THEN** the subtitle reads `视图:Wi-Fi · 扫描间隔 7s · [公司]` + +#### Scenario: Audit scene visible in title +- **WHEN** `diting --scene audit` is launched +- **THEN** the subtitle includes `[audit]` (EN) or `[排查]` (ZH) — a fast visual indicator that all gating is disabled