Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`
38 changes: 38 additions & 0 deletions openspec/specs/analyze/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion openspec/specs/bluetooth-scanning/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`
Expand Down
25 changes: 25 additions & 0 deletions openspec/specs/cli/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>` to stdout (where `<version>` 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.

Expand Down
34 changes: 34 additions & 0 deletions openspec/specs/event-log/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
80 changes: 80 additions & 0 deletions openspec/specs/scenes/spec.md
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions openspec/specs/tui-shell/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading