Skip to content

Commit c0f62d3

Browse files
chenchaoyiclaude
andauthored
chore(archive): apply add-scene-autodetect-and-persistence deltas (#117)
Applies the spec deltas from #116 into the canonical openspec/specs/ tree: - MODIFIED specs/scenes/spec.md — resolution precedence expanded from 3 tiers (cli / env / default) to 5 tiers (cli / env / yaml / auto / default); 6 new scenarios; new documentation of fall-through behaviour for invalid env. - ADDED specs/scenes/spec.md — `scenes.yaml SHALL map networks to scenes` requirement (4 scenarios covering missing file, SSID match, gateway_mac precedence, invalid entry handling). - ADDED specs/scenes/spec.md — `Auto-detect heuristic SHALL classify from observable network signals` requirement (5 scenarios covering Enterprise auth, BSSID density, sparse network, open network not auto-classifying as public). - MODIFIED specs/event-log/spec.md — session_meta `scene_source` field enum extended from {cli, env, default} to {cli, env, yaml, auto, default}; 2 new scenarios for the yaml + auto sources. - ADDED specs/tui-shell/spec.md — `Scene classification SHALL print a one-line banner at startup` requirement (4 scenarios covering auto banner, yaml banner, explicit-flag silence, DITING_SCENE_QUIET). Moves the change dir to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/. All artifacts done, all tasks complete, validate --specs --strict green (22/22). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7659d83 commit c0f62d3

9 files changed

Lines changed: 147 additions & 17 deletions

File tree

openspec/changes/add-scene-autodetect-and-persistence/design.md renamed to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/design.md

File renamed without changes.

openspec/changes/add-scene-autodetect-and-persistence/proposal.md renamed to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/proposal.md

File renamed without changes.

openspec/changes/add-scene-autodetect-and-persistence/specs/event-log/spec.md renamed to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/specs/event-log/spec.md

File renamed without changes.

openspec/changes/add-scene-autodetect-and-persistence/specs/scenes/spec.md renamed to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/specs/scenes/spec.md

File renamed without changes.

openspec/changes/add-scene-autodetect-and-persistence/specs/tui-shell/spec.md renamed to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/specs/tui-shell/spec.md

File renamed without changes.

openspec/changes/add-scene-autodetect-and-persistence/tasks.md renamed to openspec/changes/archive/2026-05-22-add-scene-autodetect-and-persistence/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,5 @@
6060

6161
## 10. Merge + archive
6262

63-
- [ ] PR open, reviewed, merged
64-
- [ ] `/opsx:archive add-scene-autodetect-and-persistence`
63+
- [x] PR open, reviewed, merged (#116)
64+
- [x] `/opsx:archive add-scene-autodetect-and-persistence`

openspec/specs/event-log/spec.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ The `session_meta` event SHALL include these fields:
139139
| `type` | string, fixed `"session_meta"` | constant |
140140
| `ts` | ISO-8601 local TZ, same format as other events | `datetime.now(LOCAL_TZ)` at writer open |
141141
| `scene` | string, one of `home` / `office` / `public` / `audit` | `get_scene()` |
142-
| `scene_source` | string, one of `cli` / `env` / `default` | resolution layer in `cli.py` |
142+
| `scene_source` | string, one of `cli` / `env` / `yaml` / `auto` / `default` | resolution layer in `cli.py` |
143143
| `diting_version` | string | `importlib.metadata.version("diting")` |
144144
| `ssid` | string or null | latest connection's SSID at open time; null if not yet connected |
145145
| `gateway_ip` | string or null | latest connection's gateway IP; null if not yet known |
@@ -162,3 +162,19 @@ When `diting monitor` is invoked (stdout mode), the same `session_meta` line SHA
162162
#### Scenario: session_meta when SSID is unknown at start
163163
- **WHEN** diting launches without a current Wi-Fi connection
164164
- **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
165+
166+
The `scene_source` field's expanded value set lets analyzers distinguish:
167+
168+
- `cli` — user explicitly passed `--scene SCENE`.
169+
- `env``DITING_SCENE` env var.
170+
- `yaml``scenes.yaml` matched the current network.
171+
- `auto` — heuristic classified from active connection signals.
172+
- `default` — nothing decided; fell to `home`.
173+
174+
#### Scenario: yaml-resolved scene records source `yaml`
175+
- **WHEN** `scenes.yaml` matches the current SSID, diting launches the TUI with `--log /tmp/x.jsonl`
176+
- **THEN** the first line of `/tmp/x.jsonl` has `"scene_source": "yaml"`
177+
178+
#### Scenario: auto-detected scene records source `auto`
179+
- **WHEN** the user has no `--scene` / no env var / no yaml match, and the active connection is WPA2 Enterprise
180+
- **THEN** the session_meta line has `"scene": "office"` and `"scene_source": "auto"`

openspec/specs/scenes/spec.md

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,39 @@ diting SHALL pick the active scene at startup using this precedence (highest fir
3333

3434
1. CLI flag `--scene SCENE` (explicit per-session)
3535
2. Env var `DITING_SCENE=SCENE` (shell-level persistent preference)
36-
3. Default `home`
36+
3. `scenes.yaml` lookup (per-network persistent assignment by SSID or gateway MAC)
37+
4. Auto-detect heuristic (classify from active connection's security mode + visible BSSID density)
38+
5. Default `home`
3739

38-
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.
40+
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 next tier (yaml, then heuristic, then default), not exit; a broken shell rc should not break startup.
3941

40-
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".
42+
The CLI flag wins over an env var even when both are set; the env var wins over the yaml; the yaml wins over the heuristic. The resolved scene's **source** SHALL be retrievable separately from the scene name and SHALL be one of: `cli`, `env`, `yaml`, `auto`, `default`. Downstream consumers (JSONL `session_meta`, the analyzer's report header) record this source so users can later distinguish "I explicitly chose this" from "diting guessed".
4143

42-
#### Scenario: CLI flag wins over env var
43-
- **WHEN** `DITING_SCENE=office diting --scene home` is invoked
44-
- **THEN** the active scene is `home` and the source is `cli`
44+
When no Wi-Fi connection is available at startup (`get_connection()` returns None), the yaml and heuristic tiers are both skipped and the resolution falls straight to `default`.
4545

46-
#### Scenario: env var fills in when no flag
47-
- **WHEN** `DITING_SCENE=office diting` is invoked
48-
- **THEN** the active scene is `office` and the source is `env`
46+
#### Scenario: CLI flag wins over yaml + heuristic
47+
- **WHEN** `scenes.yaml` says the current SSID maps to `home` AND the user runs `diting --scene office`
48+
- **THEN** the active scene is `office` and the source is `cli`
4949

50-
#### Scenario: blank env var falls to default
51-
- **WHEN** `DITING_SCENE= diting` is invoked (env var set to empty string)
50+
#### Scenario: yaml lookup wins over heuristic
51+
- **WHEN** no `--scene` flag, no `DITING_SCENE` env var, AND `scenes.yaml` has an entry matching the current SSID
52+
- **THEN** the active scene is the yaml-assigned value; the source is `yaml`; the heuristic does NOT run
53+
54+
#### Scenario: heuristic fires when no higher tier decides
55+
- **WHEN** no `--scene` flag, no `DITING_SCENE` env var, no `scenes.yaml` match, AND the active connection has security `"WPA2 Enterprise"`
56+
- **THEN** the active scene is `office` and the source is `auto`
57+
58+
#### Scenario: heuristic falls to home for sparse RF
59+
- **WHEN** no flag, no env, no yaml, AND security is `"WPA2 Personal"`, AND only 5 BSSIDs are visible
60+
- **THEN** the active scene is `home` and the source is `auto`
61+
62+
#### Scenario: no Wi-Fi connection falls straight to default
63+
- **WHEN** the machine has no associated Wi-Fi (`get_connection()` returns None)
5264
- **THEN** the active scene is `home` and the source is `default`
5365

54-
#### Scenario: invalid env var warns and falls to default
55-
- **WHEN** `DITING_SCENE=shop diting` is invoked
56-
- **THEN** a stderr warning is printed; the active scene is `home`; source is `default`; the process continues to launch (does NOT exit)
66+
#### Scenario: invalid env var warns and falls through (NOT to default)
67+
- **WHEN** `DITING_SCENE=shop diting` is invoked on an enterprise network
68+
- **THEN** a stderr warning is printed; the yaml and heuristic tiers are evaluated; the active scene reflects whichever tier resolves first (likely `office` from auto-detect)
5769

5870
### Requirement: `scene_defaults(scene)` SHALL return a stable mapping of knobs
5971
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)`).
@@ -78,3 +90,75 @@ Per-scene values for the keys defined in this phase:
7890
#### Scenario: callers read knobs defensively
7991
- **WHEN** code reads `scene_defaults("home").get("future_knob", "fallback")` against a build that doesn't yet implement `future_knob`
8092
- **THEN** the call returns `"fallback"` and does NOT raise
93+
94+
### Requirement: `scenes.yaml` SHALL map networks to scenes
95+
A user-curated `scenes.yaml` file SHALL be an optional resolution input for the active scene. The file lives at `./scenes.yaml` by default (resolved against cwd at startup, matching the `aps.yaml` pattern); the path is overridable via `DITING_SCENES_FILE`.
96+
97+
The file format is a top-level mapping with a single `networks` key whose value is a list of entries. Each entry SHALL carry exactly one of `ssid` or `gateway_mac` as the match key, plus a `scene` field naming one of the four canonical scenes.
98+
99+
```yaml
100+
networks:
101+
- ssid: HomeNet
102+
scene: home
103+
- ssid: Meituan
104+
scene: office
105+
- gateway_mac: 14:51:7e:71:5a:1a
106+
scene: office
107+
```
108+
109+
Resolution semantics:
110+
111+
- A missing file SHALL be treated as an empty registry (no error, no warning).
112+
- A malformed top-level (not a mapping, or `networks` not a list) SHALL print a stderr warning and behave as an empty registry; diting SHALL continue to launch.
113+
- An individual entry with an invalid `scene` value SHALL be skipped with a stderr warning naming the offending entry; the rest of the file SHALL still load.
114+
- When BOTH an `ssid` entry AND a `gateway_mac` entry could match the current connection, the `gateway_mac` match wins (more specific).
115+
- The loader is read-only — diting SHALL NOT write back to `scenes.yaml` based on auto-detect results. The file is human-curated.
116+
117+
#### Scenario: SSID match returns the assigned scene
118+
- **WHEN** `scenes.yaml` contains `{ ssid: Meituan, scene: office }` AND the current connection's SSID is `Meituan`
119+
- **THEN** the lookup returns `office`
120+
121+
#### Scenario: gateway_mac wins over ssid when both match
122+
- **WHEN** `scenes.yaml` contains `{ ssid: eduroam, scene: home }` AND `{ gateway_mac: 14:51:..., scene: office }`, AND the current connection has SSID `eduroam` AND gateway MAC `14:51:...`
123+
- **THEN** the lookup returns `office`
124+
125+
#### Scenario: invalid scene name in yaml is skipped
126+
- **WHEN** `scenes.yaml` contains `{ ssid: Meituan, scene: shop }`
127+
- **THEN** a stderr warning identifies the offending entry; the resolution proceeds to the next tier (heuristic)
128+
129+
#### Scenario: missing yaml is silent
130+
- **WHEN** no `scenes.yaml` file exists
131+
- **THEN** the yaml tier returns no match; no warning is printed
132+
133+
### Requirement: Auto-detect heuristic SHALL classify from observable network signals
134+
When no higher tier (CLI / env / yaml) decides the scene AND a Wi-Fi connection is available, diting SHALL run a pure-function heuristic against the connection's security mode and visible BSSID density.
135+
136+
The classifier `classify_environment(security: str | None, visible_bssid_count: int, ssid: str | None) -> tuple[str, str]` returns `(scene, reason)`. Rules evaluated in priority order:
137+
138+
1. **Enterprise auth → `office`.** If `security` contains the substring "Enterprise" (case-insensitive — matches WPA2 Enterprise / WPA3 Enterprise / WPA-Enterprise), classify as `office`. Reason: `"{security} auth"`.
139+
2. **High BSSID density → `office`.** If `visible_bssid_count >= 30`, classify as `office`. Reason: `"{N} BSSIDs visible"`.
140+
3. **Otherwise → `home`.** Reason: `"no enterprise auth, sparse BSSID surface"`.
141+
142+
`public` is intentionally NOT auto-classified — open Wi-Fi exists in homes (neighbour's), offices (guest networks), and public spaces; without active probing diting cannot distinguish them. Public is opt-in via `--scene public` or `DITING_SCENE=public`.
143+
144+
The 30-BSSID threshold is a constant; tuning is a future concern.
145+
146+
#### Scenario: WPA2 Enterprise classifies as office regardless of BSSID count
147+
- **WHEN** `classify_environment("WPA2 Enterprise", 5, "Meituan")` is called
148+
- **THEN** returns `("office", "WPA2 Enterprise auth")`
149+
150+
#### Scenario: WPA3 Enterprise also classifies as office
151+
- **WHEN** `classify_environment("WPA3 Enterprise", 12, "Corp")` is called
152+
- **THEN** returns `("office", "WPA3 Enterprise auth")`
153+
154+
#### Scenario: dense BSSID surface without enterprise still classifies as office
155+
- **WHEN** `classify_environment("WPA2 Personal", 47, "BigComplex")` is called
156+
- **THEN** returns `("office", "47 BSSIDs visible")`
157+
158+
#### Scenario: sparse personal network classifies as home
159+
- **WHEN** `classify_environment("WPA2 Personal", 8, "HomeNet")` is called
160+
- **THEN** returns `("home", "no enterprise auth, sparse BSSID surface")`
161+
162+
#### Scenario: open network does NOT classify as public
163+
- **WHEN** `classify_environment("None", 12, "CoffeeBar-WiFi")` is called
164+
- **THEN** returns `("home", "no enterprise auth, sparse BSSID surface")` — public auto-detection is out of scope

openspec/specs/tui-shell/spec.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,33 @@ The subtitle SHALL re-render when the active view or scan interval changes; the
364364
#### Scenario: Audit scene visible in title
365365
- **WHEN** `diting --scene audit` is launched
366366
- **THEN** the subtitle includes `[audit]` (EN) or `[排查]` (ZH) — a fast visual indicator that all gating is disabled
367+
368+
### Requirement: Scene classification SHALL print a one-line banner at startup
369+
When the scene was resolved by `scenes.yaml` lookup or by the auto-detect heuristic (i.e. `scene_source ∈ {yaml, auto}`), diting SHALL print exactly one line to **stderr** before launching the TUI / monitor, explaining the choice. The banner format is:
370+
371+
EN:
372+
- `auto-detected scene: <scene> (<reason>)` — for source `auto`
373+
- `pinned scene: <scene> (matched "<key>" in scenes.yaml)` — for source `yaml`
374+
- `scene: home (default — no Wi-Fi connection at startup)` — for source `default` when no connection is available
375+
376+
ZH equivalents in the matching catalog. The banner SHALL go to **stderr** (not stdout) so that `diting monitor > out.jsonl` streams stay clean. When `DITING_SCENE_QUIET=1` is set, the banner SHALL be suppressed (for users / scripts that want silent startup).
377+
378+
When the scene was resolved by `--scene` flag or `DITING_SCENE` env var (source `cli` or `env`), NO banner is printed — the user already knows what they asked for.
379+
380+
The banner SHALL fire exactly once per session, before the TUI's alt-screen takes over (so it stays visible in the shell's scroll-back after diting exits).
381+
382+
#### Scenario: auto-detect banner names the reason
383+
- **WHEN** diting launches on a WPA2 Enterprise network without `--scene` / env / yaml
384+
- **THEN** stderr carries one line: `auto-detected scene: office (WPA2 Enterprise auth)`
385+
386+
#### Scenario: scenes.yaml hit banner names the match key
387+
- **WHEN** `scenes.yaml` contains `{ ssid: Meituan, scene: office }` and diting launches connected to `Meituan`
388+
- **THEN** stderr carries one line: `pinned scene: office (matched "Meituan" in scenes.yaml)`
389+
390+
#### Scenario: explicit `--scene` is silent
391+
- **WHEN** diting launches with `--scene office`
392+
- **THEN** no scene banner is printed
393+
394+
#### Scenario: DITING_SCENE_QUIET=1 suppresses the banner
395+
- **WHEN** `DITING_SCENE_QUIET=1 diting` is invoked on a WPA2 Enterprise network
396+
- **THEN** the scene is still resolved to `office` (auto), the chip still shows in the TUI, but no banner is printed

0 commit comments

Comments
 (0)