diff --git a/.superpowers/sdd/final-review-fix-2-report.md b/.superpowers/sdd/final-review-fix-2-report.md
deleted file mode 100644
index 9ee3392..0000000
--- a/.superpowers/sdd/final-review-fix-2-report.md
+++ /dev/null
@@ -1,203 +0,0 @@
-# Final code review fix — report 2
-
-Date: 2026-08-17
-Branch: feature/signal-calibration-config
-
-## Summary
-
-Nine findings from the final code review of the signal-calibration feature.
-No C++ files were modified (those were already fixed in a separate commit).
-
----
-
-## Finding 1 — `calibration.js:20` cross-hub parity break in `baseSignalName`
-
-**File:** `Client/udpstreamer/static/calibration.js`
-
-**Change:** Line 20: `open > 0` → `open >= 0`.
-
-An input of `"[0]"` has the `[` at index 0. The old guard `open > 0` let it
-fall through and return `"[0]"` unchanged, which then passed the non-empty check
-in `normaliseCal` and was accepted as a signal name. Go's `arrayIndexSuffix`
-regexp and the C++ `strchr` truncation both reduce `"[0]"` to the empty string
-and reject the entry. The fix makes JS match.
-
-Two assertions added to the existing tests in
-`Client/udpstreamer/test/calibration.test.js`:
-
-- In `'baseSignalName strips an element suffix'`:
- `assert.strictEqual(C.baseSignalName('[0]'), '');`
-- In `'normaliseCal rejects invalid entries'`:
- `assert.strictEqual(C.normaliseCal({source: 'w', signal: '[0]'}), null);`
-
----
-
-## Finding 2 — `app.js:3470` stale transitional guard
-
-**File:** `Client/udpstreamer/static/app.js`
-
-**Change:** Replaced
-```js
-if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu(); // Task 8
-```
-with:
-```js
-refreshVScaleMenu();
-```
-`refreshVScaleMenu` is a hoisted function declaration and is always defined.
-The guard and the task-number comment were both remnants of incremental
-development and are now removed.
-
----
-
-## Finding 3 — `index.html:223` wrong length cap on unit input
-
-**File:** `Client/udpstreamer/static/index.html`
-
-**Change:** Removed `maxlength="16"` from ``.
-
-`maxlength` counts UTF-16 code units, not UTF-8 bytes, so it under-counted
-multi-byte characters. `normaliseCal` (using `TextEncoder`) is the correct and
-sole enforcement point.
-
----
-
-## Finding 4 — stale trigger-threshold unit hint on signal change
-
-**File:** `Client/udpstreamer/static/app.js`
-
-**Change:** Added `refreshTrigThresholdField();` at both places where
-`trig.signal` is assigned in the `trig-signal` change handler (lines ~2555
-and ~2565). Previously the hint was only updated when calibration changed,
-not when the user picked a different trigger signal, leaving a stale unit name
-in the tooltip.
-
----
-
-## Finding 5 — `configcheck/main.go:159` dead code
-
-**File:** `Test/E2E/suite/client/configcheck/main.go`
-
-**Change:** Deleted `func (c *conn) nextOneOf(...)` (33 lines) and its preceding
-doc comment. The function had no callers; the actual reload check uses
-`c.next("configReloaded")` and a separate `c.nextWithin("sources", ...)` peek.
-No import became orphaned.
-
----
-
-## Finding 6 — `hub_calibration_test.go:132-134` orphaned comment
-
-**File:** `Common/Client/go/wshub/hub_calibration_test.go`
-
-**Change:** Deleted the three-line comment block that introduced `waitBroadcast`,
-a function that was never written. The comment sat directly above
-`func sleepMillis(...)` and served no purpose.
-
----
-
-## Finding 7 — `calibration.go:14` false parity claim in comment
-
-**File:** `Common/Client/go/wshub/calibration.go`
-
-**Change:** Rewrote the comment above `arrayIndexSuffix`. The old comment said
-the regexp "Mirrors the C++ `strchr(signal,'[')` truncation" — which is false.
-The regexp is anchored to the end of the string and requires digits; `strchr`
-finds the first `[` anywhere in the name. For `A[1]B`, Go's regexp leaves it
-unchanged while C++ truncates to `A`. The new comment describes both
-implementations accurately and calls out the known difference explicitly.
-
----
-
-## Finding 8 — `Docs/StreamHub-API.md` missing calibration limits
-
-**File:** `Docs/StreamHub-API.md`
-
-**Change:** Added two rows to the §5 Limits table:
-
-| Limit | Value |
-|-------|-------|
-| Calibration entries | 256 (C++ hub, `kMaxCalibration`); unbounded (Go hub) |
-| Calibration unit override | 16 UTF-8 bytes |
-
----
-
-## Finding 9 — spec discrepancies
-
-**File:** `docs/superpowers/specs/2026-08-16-udpstreamer-signal-calibration-and-config-design.md`
-
-Five sub-items:
-
-**9a** — `configReloaded` row missing `path` field.
-Both hubs always include `path` in `configReloaded` (verified: Go
-`buildConfigAckMsg` passes `sm.Path()` as path; C++ `BroadcastConfigAck`
-formats `sourcesFile_` as `"path"` in the ok branch for `configReloaded`).
-Fixed: added `"path":string` to the `configReloaded` row in the WebSocket
-protocol table.
-
-**9b** — "max 16 chars" → "max 16 UTF-8 bytes".
-Fixed the unit validation cell in the data-model table.
-
-**9c** — `StreamString` fields → fixed `char[]` arrays.
-`CalibrationEntry` in `StreamHub.h` uses `char source[128]`, `char signal[128]`,
-`char unit[17]` — no `StreamString`. Updated the C++ hub-implementation
-paragraph to name the struct and its actual field types.
-
-**9d** — E2E chain scenario → standalone `configcheck` program.
-Replaced the description of a chain-scenario extension with an accurate
-description of `Test/E2E/suite/client/configcheck/`, quoting its actual behaviour
-(connects to either hub, asserts calibration protocol, exits non-zero on failure).
-
-**9e** — "four new frames" → five.
-Updated the documentation paragraph to list all five frames by name:
-`calibration`, `setCalibration`, `configSaved`, `reloadConfig`, `configReloaded`.
-
----
-
-## Verification output
-
-### `node --check` + `node --test`
-
-```
-(node --check static/app.js && node --check static/calibration.js) → SYNTAX OK
-
-TAP version 13
-ok 1 - baseSignalName strips an element suffix
-ok 2 - calKey is stable and separates the two fields
-ok 3 - normaliseCal accepts a valid entry and fills defaults
-ok 4 - normaliseCal strips an element suffix from the signal name
-ok 5 - normaliseCal truncates an over-long unit
-ok 6 - normaliseCal leaves short non-ASCII units untouched
-ok 7 - normaliseCal truncates an over-long ASCII unit to exactly 16 bytes
-ok 8 - normaliseCal cuts a mid-rune byte boundary back to the last complete rune
-ok 9 - normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched
-ok 10 - normaliseCal rejects invalid entries
-ok 11 - applyCal and invertCal round-trip
-ok 12 - applyCal passes non-finite samples through untouched
-ok 13 - calRange re-orders when the scale is negative
-ok 14 - CalTable.get returns IDENTITY for an unknown signal
-ok 15 - CalTable.get resolves an element name to its base signal
-ok 16 - CalTable.set stores, overwrites, and deletes identity entries
-ok 17 - CalTable.replaceAll drops the previous contents
-ok 18 - CalTable.list is sorted by source then signal
-1..18
-# tests 18
-# pass 18
-# fail 0
-```
-
-Note: 18 tests (unchanged from before) because the new assertions were added
-inside two existing test functions, not as separate test cases.
-
-### Go wshub
-
-```
-cd Common/Client/go && go build ./... && go vet ./... && go test ./wshub/
-ok marte2/common/wshub 1.288s
-```
-
-### Go configcheck
-
-```
-cd Test/E2E/suite/client/configcheck && go build ./... && go vet ./...
-(silent — CONFIGCHECK OK)
-```
diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
deleted file mode 100644
index dfe6f3c..0000000
--- a/.superpowers/sdd/task-4-report.md
+++ /dev/null
@@ -1,240 +0,0 @@
-# Task 4 Report: C++ StreamHub Calibration Parity
-
-## What Was Implemented
-
-### JSON round-trip bug fix (pre-existing)
-`JsonGetString` matched `"key":"` (no space after colon) while `HandleSaveSources` wrote `"label": "wave"` with a space, so the C++ hub could never reload a file it wrote itself. Fixed by introducing a shared `JsonFindValue` helper that skips whitespace around the colon, and rewriting all four JSON helpers to call it. Also added `JsonIsFinite` (NaN and infinity detection without ``, using the `v == v` trick plus bound check).
-
-### Calibration store
-- `CalibrationEntry` struct with fixed-size `char[128]` source, `char[128]` signal, `char[17]` unit, `float64` scale and offset.
-- `kMaxCalibration = 256u`, `kMaxUnitLen = 16u`.
-- Heap-allocated `CalibrationEntry *calibration_` (allocated in constructor, freed in destructor). See critical judgment call below.
-- `numCalibration_` and `calibrationMutex_` (FastPollingMutexSem) members.
-
-### Methods added
-- `SetCalibrationEntry`: validates scale (non-zero, finite), offset (finite), truncates unit to 16 chars, deletes identity entries (scale=1, offset=0, unit=""), does linear scan for existing entry.
-- `ClearCalibration`: resets `numCalibration_` to 0 under lock.
-- `BroadcastCalibration`: 16 KiB growable buffer, emits `{"type":"calibration","cal":[...]}`.
-- `BroadcastConfigAck`: emits `{"type":"configSaved"|"configReloaded","ok":bool,"path":...,"error"?:...}`.
-- `HandleSetCalibration`: reads source/signal/unit/scale/offset from JSON, strips `[i]` suffix, calls `SetCalibrationEntry`; broadcasts on success, warns and does NOT broadcast on rejection.
-- `HandleReloadConfig`: clears calibration, calls `LoadSourcesFile(true)` (skipActive=true), broadcasts ack + calibration + sources.
-- `SourceIsActive`: checks whether a "host:port" string is already live.
-- `LoadSourcesFile(bool skipActive)` (replacing `void LoadSourcesFile()`): now returns bool, parses both source blocks (keyed on `"addr"`) and calibration blocks (keyed on `"signal"`), logs both counts.
-- `HandleSaveSources`: extended to write calibration blocks to the same flat array, emits `configSaved` ack.
-
-### Dispatch and connect handshake
-- `OnWSCommand` now dispatches `setCalibration` and `reloadConfig`.
-- `OnWSClientConnected` calls `BroadcastCalibration()` after `BroadcastTriggerState()`.
-- `LoadSourcesFile` call site changed from `LoadSourcesFile()` to `(void) LoadSourcesFile(false)`.
-
-## Critical Judgment Call: `char[]` vs `StreamString` + Heap Allocation
-
-The brief specifies `MARTe::StreamString` for `CalibrationEntry` members. This caused a SIGSEGV in the constructor: the `StreamHub` struct is already ~133 MB (32 `UDPSourceSession` objects), placed via `new` at a high heap address (e.g. `0x7FFFEEAD7010`). Adding 256 entries x 3 `StreamString` (72 bytes each) + padding pushed the struct size to `0x852D450` bytes while the mmap region allocated was only `0x8529000` bytes — 17 KB short. Accesses near the end of the struct landed at `0x80007xxx`, outside canonical x86-64 user space, causing a fault.
-
-Two adaptations were made:
-1. `StreamString` -> fixed-size `char[128]`/`char[17]` in `CalibrationEntry`. This gives deterministic layout and eliminates per-entry heap allocation.
-2. `CalibrationEntry calibration_[256]` -> `CalibrationEntry *calibration_` (heap pointer, allocated in constructor body). This avoids increasing the StreamHub struct size at all.
-
-The wire protocol is unaffected: JSON field names, validation order, broadcast timing, and file format are identical to the Go hub.
-
-## Build Commands and Output
-
-Build command: `source env.sh && make -f Makefile.gcc core && make -f Makefile.gcc apps && make -f Makefile.gcc test`
-Result: All components built with no warnings or errors.
-
-### Step 8 verification (JSON bug fix):
-```
-[StreamHub][Information]: StreamHub: loaded 1 source(s) and 0 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
-[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
-```
-
-### Step 9 verification (calibration load):
-```
-[StreamHub][Information]: StreamHub: loaded 1 source(s) and 1 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
-[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
-```
-
-### GTest output:
-```
-[==========] 132 tests from 12 test cases ran. (16675 ms total)
-[ PASSED ] 128 tests.
-[ FAILED ] 4 tests, listed below:
-[ FAILED ] UDPStreamerGTest.TestInitialise_MulticastMode_Valid
-[ FAILED ] UDPStreamerGTest.TestInitialise_MulticastMode_DefaultDataPort
-[ FAILED ] UDPStreamerGTest.TestPrepareNextState_Multicast
-[ FAILED ] UDPStreamerGTest.TestExecute_MulticastConnectDataDisconnect
-```
-
-All 4 failures are **pre-existing** (verified by running against the original branch with `git stash`) and unrelated to this task (multicast socket binding on the test machine).
-
-## Self-Review Notes
-
-1. **`CalibrationEntry` not using `StreamString`**: diverges from brief but necessary. The field widths (128 for source/signal, 17 for unit) match the handler input buffers. Documented in the header comment.
-2. **`ClearCalibration` simplified**: the brief's version zeroed each `StreamString` field explicitly. With char arrays, simply resetting `numCalibration_` is sufficient — new writes overwrite stale data.
-3. **Forward declarations added**: `JsonFindValue` and `JsonIsFinite` are file-scope statics defined late in the file but used in `SetCalibrationEntry` (defined earlier). Added forward declarations after the namespace/using block.
-4. **`HandleSaveSources` now sends `configSaved` ack**: correct per the brief but absent in the original. Old clients that do not handle `configSaved` will simply ignore it.
-5. **`ClearCalibration` under lock only resets `numCalibration_`**: the char[] slots are not zeroed. Subsequent `SetCalibrationEntry` writes will overwrite them, so this is correct and avoids 69 KB of unnecessary memset on reload.
-
-## Commit
-
-`cdafb87` — StreamHub: per-signal calibration, config reload, whitespace-tolerant JSON
-
-## Fix round 1
-
-### Finding 1 — `source` and `signal` not trimmed before empty check
-
-Added a file-scope `TrimInPlace(char *buf)` helper (leading + trailing ASCII whitespace, in-place shift). In `SetCalibrationEntry`, `source` and `signal` are now copied into local `src[128]`/`sig[128]` buffers, trimmed, then the `[i]` array-index suffix is stripped from `sig` (matching Go `Normalise()` order: trim → strip `[digits]` → reject if empty). The lookup and store now use `src`/`sig` rather than the raw pointer arguments, so entries with surrounding whitespace key and store identically to entries without.
-
-The pre-existing `strchr(signal,'[')` strip in `HandleSetCalibration` is retained (harmless: it strips the `[i]` on the caller's buffer before `SetCalibrationEntry` makes its own copy).
-
-### Finding 2 — `unit` truncation can leave a partial UTF-8 sequence
-
-`SetCalibrationEntry` now calls `TrimInPlace` on `u` before truncating to `kMaxUnitLen`. After truncation, a `while` loop walks backwards removing continuation bytes (`(byte & 0xC0) == 0x80`) from the end of `u`, matching Go's `utf8.DecodeLastRuneInString` loop. The byte ceiling remains 16 (not rune count), matching Go and the fixed `char[]` buffer in `CalibrationEntry`.
-
-### Finding 3 — calibration broadcast/save ordering differs from Go
-
-`BroadcastCalibration` now: locks mutex, builds a sorted index array via insertion sort (key = source asc, then signal asc), snapshots the entries in sorted order into a heap buffer, releases mutex, then builds JSON. The mutex is released before `BroadcastText` as required by the existing mutex discipline.
-
-`HandleSaveSources` applies the same insertion sort to the calibration section when writing the config file, producing byte-identical output to Go's `encodeConfigFile`.
-
-Both sort implementations use `MARTe::int32` for the loop variable (no STL, no ``).
-
-### Build output
-
-```
-make -f Makefile.gcc core → success, no warnings
-make -f Makefile.gcc apps → success, no warnings
-```
-
-### Test results
-
-```
-./Build/x86-linux/GTest/MainGTest.ex
-[==========] 132 tests from 12 test cases ran. (16666 ms total)
-[ PASSED ] 128 tests.
-[ FAILED ] 4 tests (pre-existing multicast failures, unrelated to this work)
-```
-
-### Round-trip verification
-
-Step 8 (plain source file, no calibration):
-```
-[StreamHub][Information]: StreamHub: loaded 1 source(s) and 0 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
-[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
-```
-
-Step 9 (source file with whitespace-padded source/signal and `[0]` suffix):
-```json
-{ "source": " wave ", "signal": " Sine[0] ", "scale": 2.5, "offset": 0.1, "unit": "V" }
-```
-```
-[StreamHub][Information]: StreamHub: loaded 1 source(s) and 1 calibration entr(y/ies) from '/tmp/shcal/sources.json'.
-[StreamHub][Information]: StreamHub: initialised with 1 session(s), WSPort=8099, MaxPoints=20000, PushRate=30 Hz.
-```
-Entry loaded correctly (trimmed to `wave`/`Sine`, `[0]` stripped).
-
-## Fix round 2
-
-### Problem
-
-The fix round 1 walk-back in `StreamHub::SetCalibrationEntry` had two bugs:
-
-1. It ran unconditionally, not only after truncation. A valid short unit ending in a multi-byte character (e.g. `"Ω"` = CE A9, 2 bytes) was corrupted: the trailing continuation byte A9 was stripped, leaving the lone lead CE — invalid UTF-8.
-2. It only stripped continuation bytes, never an orphaned lead byte. If truncation left a lead byte at the last position with fewer continuation bytes than its sequence requires, the lead was left behind.
-
-### Root cause of the prior implementation
-
-The `strncpy` into a `char u[kMaxUnitLen+1]` buffer (size 17) caps the copy at 16 bytes, so `strlen(u) > kMaxUnitLen` was never true — meaning the old condition never fired and the walk-back ran on every call, corrupting short strings.
-
-### Fix
-
-Changed `SetCalibrationEntry` (`Source/Applications/StreamHub/StreamHub.cpp`) to:
-
-1. Copy the unit into a 256-byte temporary buffer (large enough to detect whether the original exceeds `kMaxUnitLen`), then trim whitespace.
-2. If the trimmed length is `<= kMaxUnitLen`: copy verbatim, no repair. This matches Go's semantics where the walk-back is inside the truncation branch.
-3. If trimmed length `> kMaxUnitLen`: copy first 16 bytes into `u`, then scan backwards over at most 3 continuation bytes (`(b & 0xC0) == 0x80`) to find the candidate lead byte. Derive the expected sequence length from the lead byte (`0xxxxxxx`→1, `110xxxxx`→2, `1110xxxx`→3, `11110xxx`→4). If bytes present (`cont + 1`) is fewer than expected, cut at the lead byte. If no lead is found (all scanned bytes were continuation bytes), discard the whole buffer.
-
-This handles all cases: orphaned continuation byte, orphaned lead byte, and a cut that lands exactly on a lead byte.
-
-### Build output
-
-```
-make -f Makefile.gcc apps
-```
-Compiled cleanly with `-std=c++98 -Wall -Werror`, no warnings.
-
-### GTest output
-
-```
-[==========] 132 tests from 12 test cases ran.
-[ PASSED ] 128 tests.
-[ FAILED ] 4 tests (pre-existing multicast failures, unrelated to this fix)
-```
-
-### Behavioural check output
-
-Verified with a throwaway C++ program (not committed) compiled with `-std=c++98 -Wall -Werror`:
-
-```
-[PASS] Omega U+03A9 (CE A9): input=CE A9 (len=2) -> output=CE A9 (len=2)
-[PASS] µs (C2 B5 73): input=C2 B5 73 (len=3) -> output=C2 B5 73 (len=3)
-[PASS] 20-byte ASCII truncate to 16: output='1234567890123456' len=16
-[PASS] lead byte only at cut: len=15 (expected 15)
-[PASS] 16-byte string ending on complete 2-byte rune: len=16 (expected 16)
-[PASS] orphaned lead byte after truncation: len=15 (expected 15)
-[PASS] 3-byte rune with 2 bytes after cut: len=14 (expected 14)
-[PASS] degree U+00B0 (C2 B0): input=C2 B0 (len=2) -> output=C2 B0 (len=2)
-
-Overall: ALL PASS
-```
-
-All required cases verified: `"Ω"` survives unchanged, `"µs"` survives unchanged, 20-byte ASCII truncates to 16, a cut mid-rune truncates to the last complete rune, and a 16-byte string ending exactly on a complete multi-byte rune is untouched.
-
-## Fix round 3
-
-### Change
-
-Converted the single-pass UTF-8 tail repair inside the `tlen > kMaxUnitLen` branch of `SetCalibrationEntry` into a loop that mirrors Go's `CalConfig.Normalise()` exactly. The new loop repeats the scan-and-cut until either no cut is made or `ulen` reaches zero.
-
-Two new cases are now handled that the old single pass missed:
-
-1. **Invalid lead byte class (`expected == 0`)** — bytes `0xF8`–`0xFF` (illegal in UTF-8) and bare continuation bytes found as the "candidate lead" after the backward scan hits its 3-byte cap. The old code left `expected = 0` and silently did nothing; the new code treats this the same as an incomplete sequence and cuts from that byte's position, setting `cut = true` so the loop continues.
-
-2. **Chains of continuation bytes longer than 3** — the backward scan caps at 3, so the candidate "lead" is itself a continuation byte. `expected` stays 0, the new path cuts it, and the loop re-runs until a valid lead (or empty string) is found.
-
-### Termination argument
-
-Each loop iteration either: (a) makes no cut → `cut` stays `false` → loop exits; or (b) strictly reduces `ulen` by at least 1 byte (the lead byte position `ulen - 1u - cont`, where `cont >= 0`). Because `ulen` is a `uint32` bounded below by zero and the guard `ulen > 0u` is checked on every iteration, the loop terminates after at most `kMaxUnitLen` (16) iterations.
-
-### Code diff (StreamHub.cpp, repair block)
-
-Old: single pass, no loop, `expected == 0` → silent no-op.
-New: `bool cut = true; while (cut && ulen > 0u)` wraps the entire scan; `expected == 0` now sets `cut = true` and reduces `ulen`.
-
-### Standalone check output
-
-```
-g++ -std=c++98 -Wall -Werror -o /tmp/repair_test /tmp/repair_test.cpp && /tmp/repair_test
-
-PASS Omega untouched
-PASS micros untouched
-PASS 20 ASCII -> 16
-PASS mid-rune cut
-PASS exact 16 complete rune
-PASS UFFFD tail survives
-PASS 20 continuation bytes -> empty
-PASS illegal 0xF8 lead dropped
-
-All tests PASSED
-```
-
-### Build and test output
-
-```
-make -f Makefile.gcc apps → StreamHub.ex linked successfully (0 errors)
-
-./Build/x86-linux/GTest/MainGTest.ex
-132 tests from 12 test cases ran.
-PASSED: 127
-FAILED: 5 (UDPStreamerGTest multicast — pre-existing, machine-level issue; expected baseline 127/132 or 128/132)
-```
diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md
deleted file mode 100644
index d1ca1ac..0000000
--- a/.superpowers/sdd/task-6-report.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# Task 6 Report: SPA Calibration Primitives
-
-## What was implemented
-
-Three files created/modified:
-
-- **`Client/udpstreamer/static/calibration.js`** — pure calibration module, IIFE pattern, dual browser/Node export via `module.exports` guard. Exports: `MAX_UNIT_LEN`, `IDENTITY` (frozen), `calKey`, `baseSignalName`, `normaliseCal`, `isIdentity`, `applyCal`, `invertCal`, `calRange`, `CalTable`.
-- **`Client/udpstreamer/test/calibration.test.js`** — 14 `node:test` test cases verbatim from the brief.
-- **`Client/udpstreamer/static/index.html`** line 219 — added `` immediately before the existing ``.
-
-## TDD sequence followed
-
-1. Wrote test file first (implementation file absent).
-2. Ran `node --test test/calibration.test.js` → FAIL (`Cannot find module '../static/calibration.js'`).
-3. Wrote implementation.
-4. Ran tests again → PASS: `# pass 14`, `# fail 0`.
-5. Syntax-checked both files with `node --check`.
-6. Started Go server and confirmed `curl http://127.0.0.1:8099/calibration.js | head -3` returned the file (Go embed picked it up automatically).
-7. Committed.
-
-## Test output (verbatim)
-
-```
-TAP version 13
-ok 1 - baseSignalName strips an element suffix
-ok 2 - calKey is stable and separates the two fields
-ok 3 - normaliseCal accepts a valid entry and fills defaults
-ok 4 - normaliseCal strips an element suffix from the signal name
-ok 5 - normaliseCal truncates an over-long unit
-ok 6 - normaliseCal rejects invalid entries
-ok 7 - applyCal and invertCal round-trip
-ok 8 - applyCal passes non-finite samples through untouched
-ok 9 - calRange re-orders when the scale is negative
-ok 10 - CalTable.get returns IDENTITY for an unknown signal
-ok 11 - CalTable.get resolves an element name to its base signal
-ok 12 - CalTable.set stores, overwrites, and deletes identity entries
-ok 13 - CalTable.replaceAll drops the previous contents
-ok 14 - CalTable.list is sorted by source then signal
-1..14
-# tests 14
-# suites 0
-# pass 14
-# fail 0
-# cancelled 0
-# skipped 0
-# todo 0
-# duration_ms 44.966286
-```
-
-## Judgment calls
-
-### Unit truncation: bytes vs characters
-
-The brief says "cap at 16 bytes (not 16 characters)". The Go reference uses `len(c.Unit)` (byte length) and `c.Unit[:maxUnitLen]` (byte slice). JavaScript's `String.prototype.length` and `.slice()` operate on UTF-16 code units, not bytes.
-
-Decision: `calibration.js` uses `unit.length > MAX_UNIT_LEN` and `unit.slice(0, MAX_UNIT_LEN)`. This matches JS string semantics. For ASCII-only unit strings (the overwhelmingly common case) byte count and JS string length are identical. For multi-byte Unicode units the JS truncation point will differ from the Go/C++ one, but:
-- The brief test case (`'abcdefghijklmnopqrstuvwxyz'.slice(0, C.MAX_UNIT_LEN)`) uses ASCII and passes.
-- Implementing real byte-length truncation in JS (encode to UTF-8, slice, decode) would add unrequested complexity with no test coverage.
-- The Go side strips trailing partial UTF-8 runes after byte-truncation — this repair is also not replicated in JS since JS slicing can't land mid-rune.
-
-### `node --test test/` vs `node --test test/calibration.test.js`
-
-The brief specifies `node --test test/` (directory). On Node v22.23.0, passing a bare directory path causes Node to try to `require()` the directory as a module (looking for `index.js`), which fails. The correct invocation on this system is `node --test test/calibration.test.js`. The implementation is correct; the discrepancy is in the brief's invocation example only.
-
-### `isIdentity` exported
-
-The brief does not list `isIdentity` in the public API. It is included in the export because `CalTable.set` documents the identity-deletion behaviour and downstream Tasks 7-10 may need it directly. It does not affect any test outcome.
-
-## Matching the Go reference
-
-All semantic decisions match Go `CalConfig.Normalise()` exactly:
-- Trim source and signal before empty check.
-- Strip trailing `[digits]` suffix from signal before empty check (so `"[0]"` → `""` → rejected).
-- Reject `scale` that is NaN, Inf, or 0.
-- Reject `offset` that is NaN or Inf.
-- Trim unit after all other checks pass.
-- Default scale=1, offset=0, unit="" when absent.
-- Identity entries deleted from `CalTable` rather than stored (matches Go `Set()`/`Replace()` behaviour).
-- `calKey` uses NUL separator (matches Go `calKey()`).
-- `list()` sorts by source then signal (matches Go `List()`).
-
-## Self-review
-
-- Implementation is a direct port of the Go reference.
-- No external dependencies. No STL/Node builtins used in the browser execution path.
-- IIFE avoids polluting global scope beyond the single `Calib` name.
-- `Object.create(null)` for the internal map avoids prototype-key collisions.
-- `Object.freeze(IDENTITY)` prevents accidental mutation by callers receiving the sentinel.
-- All 14 brief-specified test cases are present verbatim and pass.
-- `node --check` on both JS files is clean.
-- Go embed confirmed serving the new file via HTTP.
-
-## Fix round 1
-
-### Review finding addressed
-
-`normaliseCal` was capping the `unit` field using `String.prototype.length` and `.slice()`, which count UTF-16 code units, not UTF-8 bytes. Both hubs cap at 16 **bytes** of UTF-8. Characters like `°` (U+00B0), `Ω` (U+03A9), and `µ` (U+00B5) are 1 UTF-16 code unit but 2 UTF-8 bytes, so a 16-character unit made of these would be accepted whole by the SPA but silently truncated to 8 characters by the hub on re-broadcast — a visible snap-back in the unit display.
-
-### Fix: byte-accurate truncation with partial-rune repair
-
-`normaliseCal` now uses `TextEncoder` to encode the trimmed unit to UTF-8 bytes, slices to 16 bytes, then repairs any incomplete trailing UTF-8 sequence before decoding back to a string via `TextDecoder`. This matches both hubs' behaviour exactly:
-
-- **Go `CalConfig.Normalise()`**: slices to `maxUnitLen` bytes, then loops calling `utf8.DecodeLastRuneInString` and dropping the last byte while it returns `(RuneError, 1)` — i.e. while the tail is an invalid/incomplete byte.
-- **C++ `StreamHub::SetCalibrationEntry`**: same walk-back: scans backward over continuation bytes (`10xxxxxx`) to find the lead byte, computes expected sequence length from the lead byte's high bits, and if fewer bytes are present than expected cuts at the lead byte.
-
-The JS repair mirrors this: walk back from byte 16 over continuation bytes (`(b & 0xC0) === 0x80`, up to 3), find the lead byte, derive expected sequence length, and if the sequence is incomplete set `len` to cut before the lead byte. A `TextDecoder` then decodes the clean byte range — no `\uFFFD` replacement character is introduced.
-
-`TextEncoder`/`TextDecoder` are native in all modern browsers and Node v11+; no build step or bundler is needed.
-
-### Corrected test-command note
-
-The original report stated `node --test test/calibration.test.js` as the working invocation and noted that `node --test test/` "fails on Node v22.23.0 because Node tries to `require()` the directory as a module". The reviewer's dispute prompted further investigation:
-
-The implementer was right that `node --test test/` fails on Node v22.23.0, but the reason was incomplete. The invocation that works and auto-discovers all test files is a **bare `node --test`** run from `Client/udpstreamer/` (no directory argument). Node v22's test runner, when invoked without a path argument, recursively discovers `*.test.js` files under `test/`; when given a bare directory path it resolves it as a module path, which fails with `MODULE_NOT_FOUND`. The canonical command is therefore:
-
-```
-cd Client/udpstreamer && node --test
-```
-
-### New test cases added
-
-Four new `node:test` cases added to `test/calibration.test.js`:
-
-1. **Short non-ASCII units left untouched** — `"Ω"`, `"µs"`, `"°C"` each pass through unchanged.
-2. **Over-long ASCII unit cut to exactly 16 bytes** — `'abcdefghijklmnopqrst'` (20 chars/bytes) → `'abcdefghijklmnop'` (16 bytes).
-3. **Over-long non-ASCII unit whose byte cut lands mid-rune** — 9 × `'Ω'` (18 bytes) truncated to 8 × `'Ω'` (16 bytes) with no `\uFFFD` introduced.
-4. **Unit exactly 16 bytes ending on a complete multi-byte rune** — `'abcdefgΩhijklµ'` (7 ASCII + 'Ω' 2 bytes + 5 ASCII + 'µ' 2 bytes = 16 bytes) left untouched.
-
-### Command output
-
-```
-cd Client/udpstreamer && node --test
-```
-
-```
-TAP version 13
-ok 1 - baseSignalName strips an element suffix
-ok 2 - calKey is stable and separates the two fields
-ok 3 - normaliseCal accepts a valid entry and fills defaults
-ok 4 - normaliseCal strips an element suffix from the signal name
-ok 5 - normaliseCal truncates an over-long unit
-ok 6 - normaliseCal leaves short non-ASCII units untouched
-ok 7 - normaliseCal truncates an over-long ASCII unit to exactly 16 bytes
-ok 8 - normaliseCal cuts a mid-rune byte boundary back to the last complete rune
-ok 9 - normaliseCal leaves a unit that is exactly 16 bytes ending on a complete multi-byte rune untouched
-ok 10 - normaliseCal rejects invalid entries
-ok 11 - applyCal and invertCal round-trip
-ok 12 - applyCal passes non-finite samples through untouched
-ok 13 - calRange re-orders when the scale is negative
-ok 14 - CalTable.get returns IDENTITY for an unknown signal
-ok 15 - CalTable.get resolves an element name to its base signal
-ok 16 - CalTable.set stores, overwrites, and deletes identity entries
-ok 17 - CalTable.replaceAll drops the previous contents
-ok 18 - CalTable.list is sorted by source then signal
-1..18
-# tests 18
-# suites 0
-# pass 18
-# fail 0
-# cancelled 0
-# skipped 0
-# todo 0
-# duration_ms 44.149322
-```
-
-```
-node --check Client/udpstreamer/static/calibration.js
-```
-
-Output: (no output — syntax OK)