Files
pm3py/docs/plans/2026-03-18-ndef-trace-decode.md
michael 615d4a6115 docs: roadmap index + custom-14a-command plans + backfill plan docs
- Add docs/plans/README.md as the roadmap index cataloguing all design/plan docs.
- Add the custom ISO14443-A command handling design + plan (L3 NTAG I2C
  SECTOR_SELECT / cross-sector reads + native auth; L4 static APDUs + WTX relay),
  produced from a multi-agent design workflow.
- Backfill previously-uncommitted plan docs (trace-formatter, ndef-trace-decode,
  live-sniff, firmware-upstream-rebase, 14a-live-trace) and NTAG5_SECURITY.md.
- Sync CLAUDE.md package structure with the committed transponder models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:48 -07:00

678 lines
23 KiB
Markdown

# NDEF Trace Decode Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Detect and decode NDEF content in ISO 15693 trace output — both sim trace and sniff — showing human-readable NDEF records in the annotation column, with overflow on the next line.
**Architecture:** Add an `decode_ndef()` function that parses NDEF TLV + records from block data. Wire it into `decode_15693_response()` so READ SINGLE BLOCK and READ MULTIPLE BLOCKS responses that contain NDEF data show decoded content. CC (block 0) gets its own annotation. Overflow annotations wrap to a continuation line at the annotation column.
**Tech Stack:** Python, pytest, existing hf_15.py decoder + sim trace_fmt.py
**Working directory:** `/home/work/pm3py/.worktrees/sim-framework/`
**Test command:** `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v`
---
### Context for implementer
The trace formatter produces lines like:
```
[Sim] Tag → Reader: 00 E1 40 0D 01 C1 C0 OK [4B]
[Sim] Tag → Reader: 00 03 0B D1 01 07 54 02 65 6E 74 65 73 74 FE 00 ... FD 02 OK [60B]
```
The response decoder (`decode_15693_response`) currently sees all success responses as either inventory (9B data), empty, or generic `OK [NB]`. We want to detect and decode:
1. **CC block** (block 0): `E1 40 XX YY``CC v1.0 MLEN=XX MBREAD=Y`
2. **NDEF TLV**: `03 LL <message> FE` → decode the NDEF records inside
3. **NDEF records**: Text → `NDEF Text "en" "hello"`, URI → `NDEF URI https://example.com`, Media → `NDEF MIME text/plain [5B]`
When the annotation is longer than the available space, it wraps to a continuation line right-justified at the annotation column (same pattern the hex wrapping already uses).
**Tricky parts:**
- The decoder only sees the response payload (after flags byte), with no context about WHICH command produced it. It must detect CC/NDEF purely from the data pattern.
- READ MULTIPLE BLOCKS responses can contain block 0 (CC) followed by NDEF data. The CC is in the first 4 bytes, NDEF TLV starts at byte 4.
- READ SINGLE BLOCK #0 response is just 4 bytes of CC data.
- We need both `hf_15.py` (sniff decoder) and `sim/trace_fmt.py` (sim decoder) to share the NDEF decode logic. Put the shared code in `hf_15.py` since that's where the 15693 decoders live; the sim trace_fmt calls into it via `decode_15693`.
**NFC Forum Type 5 CC format (4 bytes):**
- Byte 0: 0xE1 = NDEF magic
- Byte 1: version (upper nibble = major, lower = minor) + access bits
- Byte 2: MLEN (data area size / 8)
- Byte 3: feature flags (bit 0 = MBREAD)
**NDEF TLV format:**
- Type 0x03 = NDEF message, 0xFE = terminator, 0x00 = NULL (skip)
- Length: 1 byte if < 0xFF, else 0xFF + 2 bytes big-endian
- Value: raw NDEF message bytes
**NDEF record format:**
- Byte 0: flags (MB=0x80, ME=0x40, CF=0x20, SR=0x10, IL=0x08, TNF=0x07)
- Byte 1: TYPE_LENGTH
- If SR: byte 2 = PAYLOAD_LENGTH (1 byte), else bytes 2-5 = PAYLOAD_LENGTH (4 bytes BE)
- If IL: next byte = ID_LENGTH
- TYPE (TYPE_LENGTH bytes)
- ID (ID_LENGTH bytes, if IL set)
- PAYLOAD (PAYLOAD_LENGTH bytes)
**Well-known RTD types:**
- "T" (0x54): Text record payload[0] = lang_len, payload[1:1+lang_len] = lang, rest = text
- "U" (0x55): URI record payload[0] = prefix code, rest = URI suffix
---
### Task 1: NDEF TLV + record decoder
**Files:**
- Modify: `pm3py/hf_15.py`
- Modify: `tests/test_hf_15.py`
**Step 1: Write failing tests**
Append to `tests/test_hf_15.py`:
```python
from pm3py.hf_15 import decode_ndef_annotation
# ---- NDEF decode ----
class TestDecodeNdefAnnotation:
def test_cc_block(self):
"""4-byte CC recognized and decoded."""
data = bytes([0xE1, 0x40, 0x0D, 0x01])
ann = decode_ndef_annotation(data)
assert ann is not None
assert "CC" in ann
assert "v1.0" in ann
assert "MBREAD" in ann
def test_cc_no_mbread(self):
data = bytes([0xE1, 0x40, 0x10, 0x00])
ann = decode_ndef_annotation(data)
assert "CC" in ann
assert "MBREAD" not in ann
def test_ndef_text_record(self):
"""NDEF TLV with Text record."""
# TLV: 03 0B <ndef> FE
# Record: D1 01 07 54 02 65 6E 74 65 73 74
# MB+ME+SR, TNF=well-known, type="T", lang="en", text="test"
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert ann is not None
assert '"test"' in ann
def test_ndef_uri_record(self):
"""NDEF TLV with URI record."""
# URI: https://example.com → prefix 0x04 + "example.com"
uri_payload = bytes([0x04]) + b"example.com"
rec = bytes([0xD1, 0x01, len(uri_payload), 0x55]) + uri_payload
tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert "https://example.com" in ann
def test_ndef_mime_record(self):
"""NDEF TLV with MIME record."""
mime_type = b"text/plain"
payload = b"hello"
rec = bytes([0xD2, len(mime_type), len(payload)]) + mime_type + payload
tlv = bytes([0x03, len(rec)]) + rec + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert "text/plain" in ann
def test_no_ndef_magic(self):
"""Random data without NDEF TLV returns None."""
ann = decode_ndef_annotation(bytes([0x00, 0x00, 0x00, 0x00]))
assert ann is None
def test_cc_plus_ndef(self):
"""Data starting with CC followed by NDEF TLV (READ MULTIPLE from block 0)."""
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
data = cc + tlv + bytes(50) # pad like real response
ann = decode_ndef_annotation(data)
assert "CC" in ann
assert '"test"' in ann
def test_empty_ndef(self):
"""NDEF TLV with zero length."""
tlv = bytes([0x03, 0x00, 0xFE])
ann = decode_ndef_annotation(tlv)
assert ann is not None
assert "empty" in ann.lower() or "0B" in ann or "NDEF" in ann
def test_long_text_truncated(self):
"""Long text is truncated in annotation."""
long_text = "A" * 200
msg = ndef_text(long_text)
tlv = bytes([0x03, 0xFF, (len(msg) >> 8) & 0xFF, len(msg) & 0xFF]) + msg + bytes([0xFE])
ann = decode_ndef_annotation(tlv)
assert ann is not None
assert "..." in ann # truncated
```
**Step 2: Run tests to see them fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v`
Expected: FAIL `ImportError: cannot import name 'decode_ndef_annotation'`
**Step 3: Implement decode_ndef_annotation**
Add to `pm3py/hf_15.py`, after the existing `URI_PREFIXES` dict (copy it from type5.py or import it), before the `_15693_CMDS` dict:
```python
# ---- NDEF decode (for trace annotation) ----
# NFC Type 5 CC magic
_NDEF_MAGIC = 0xE1
# URI prefix codes (NFC Forum RTD URI)
_URI_PREFIXES = {
0x00: "",
0x01: "http://www.",
0x02: "https://www.",
0x03: "http://",
0x04: "https://",
0x05: "tel:",
0x06: "mailto:",
}
# TNF values
_TNF_EMPTY = 0x00
_TNF_WELL_KNOWN = 0x01
_TNF_MEDIA = 0x02
_TNF_URI = 0x03
_TNF_EXTERNAL = 0x04
_MAX_ANN_TEXT = 40 # truncate decoded text in annotations
def decode_ndef_annotation(data: bytes) -> str | None:
"""Decode NDEF content from block data for trace annotation.
Handles:
- CC block (4 bytes starting with 0xE1)
- NDEF TLV (starting with 0x03)
- CC + NDEF TLV (READ MULTIPLE from block 0)
Returns annotation string or None if not NDEF.
"""
if len(data) < 4:
return None
parts = []
offset = 0
# Check for CC at start
if data[0] == _NDEF_MAGIC:
ver_major = (data[1] >> 6) & 0x03
ver_minor = (data[1] >> 4) & 0x03
mlen = data[2]
features = data[3]
cc_str = f"CC v{ver_major}.{ver_minor} MLEN={mlen}"
if features & 0x01:
cc_str += " MBREAD"
parts.append(cc_str)
offset = 4
if offset >= len(data):
return cc_str
# Scan for NDEF TLV
while offset < len(data):
tlv_type = data[offset]
offset += 1
if tlv_type == 0x00: # NULL TLV — skip
continue
if tlv_type == 0xFE: # Terminator
break
if tlv_type != 0x03: # Not NDEF — stop
break
# Parse TLV length
if offset >= len(data):
break
tlv_len = data[offset]
offset += 1
if tlv_len == 0xFF:
if offset + 2 > len(data):
break
tlv_len = (data[offset] << 8) | data[offset + 1]
offset += 2
if tlv_len == 0:
parts.append("NDEF empty")
continue
# Parse NDEF message (one or more records)
msg_end = min(offset + tlv_len, len(data))
records = _parse_ndef_records(data[offset:msg_end])
for rec in records:
parts.append(rec)
offset = msg_end
return " | ".join(parts) if parts else None
def _parse_ndef_records(msg: bytes) -> list[str]:
"""Parse NDEF records from raw message bytes. Returns list of annotation strings."""
records = []
offset = 0
while offset < len(msg):
if offset + 3 > len(msg):
break
flags = msg[offset]
tnf = flags & 0x07
sr = bool(flags & 0x10)
il = bool(flags & 0x08)
type_len = msg[offset + 1]
offset += 2
# Payload length
if sr:
if offset >= len(msg):
break
payload_len = msg[offset]
offset += 1
else:
if offset + 4 > len(msg):
break
payload_len = int.from_bytes(msg[offset:offset + 4], "big")
offset += 4
# ID length
id_len = 0
if il:
if offset >= len(msg):
break
id_len = msg[offset]
offset += 1
# Type
if offset + type_len > len(msg):
break
rec_type = msg[offset:offset + type_len]
offset += type_len
# ID (skip)
offset += id_len
# Payload
if offset + payload_len > len(msg):
payload = msg[offset:]
else:
payload = msg[offset:offset + payload_len]
offset += payload_len
# Decode based on TNF + type
ann = _decode_ndef_record(tnf, rec_type, payload)
records.append(ann)
return records
def _decode_ndef_record(tnf: int, rec_type: bytes, payload: bytes) -> str:
"""Decode a single NDEF record into an annotation string."""
if tnf == _TNF_WELL_KNOWN:
if rec_type == b"T" and len(payload) >= 1:
lang_len = payload[0] & 0x3F
lang = payload[1:1 + lang_len].decode("ascii", errors="replace")
text = payload[1 + lang_len:].decode("utf-8", errors="replace")
if len(text) > _MAX_ANN_TEXT:
text = text[:_MAX_ANN_TEXT] + "..."
return f'NDEF Text "{lang}" "{text}"'
if rec_type == b"U" and len(payload) >= 1:
prefix = _URI_PREFIXES.get(payload[0], "")
suffix = payload[1:].decode("utf-8", errors="replace")
uri = prefix + suffix
if len(uri) > _MAX_ANN_TEXT:
uri = uri[:_MAX_ANN_TEXT] + "..."
return f"NDEF URI {uri}"
return f"NDEF WK type={rec_type!r} [{len(payload)}B]"
if tnf == _TNF_MEDIA:
mime = rec_type.decode("ascii", errors="replace")
if len(mime) > 30:
mime = mime[:30] + "..."
return f"NDEF MIME {mime} [{len(payload)}B]"
if tnf == _TNF_EXTERNAL:
ext_type = rec_type.decode("ascii", errors="replace")
return f"NDEF EXT {ext_type} [{len(payload)}B]"
if tnf == _TNF_EMPTY:
return "NDEF empty"
return f"NDEF TNF={tnf} [{len(payload)}B]"
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestDecodeNdefAnnotation -v`
Expected: All pass
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/hf_15.py tests/test_hf_15.py
git commit --no-gpg-sign -m "feat: NDEF TLV + record decoder for trace annotations"
```
---
### Task 2: Wire NDEF decode into response annotations
**Files:**
- Modify: `pm3py/hf_15.py`
- Modify: `tests/test_hf_15.py`
**Step 1: Write failing tests**
Append to `tests/test_hf_15.py`:
```python
class TestResponseNdefDecode:
def test_read_single_block_0_cc(self):
"""READ SINGLE BLOCK #0 response shows CC annotation."""
# Response: flags(1) + block_data(4)
data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01])
ann = decode_15693_response(data)
assert "CC" in ann
assert "MBREAD" in ann
def test_read_multiple_blocks_ndef(self):
"""READ MULTIPLE BLOCKS response with NDEF shows decoded text."""
# Response: flags(1) + blocks data
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
data = bytes([0x00]) + tlv + bytes(50)
ann = decode_15693_response(data)
assert '"test"' in ann
def test_read_multiple_with_cc_and_ndef(self):
"""READ MULTIPLE from block 0: CC + NDEF both decoded."""
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
msg = bytes([0xD1, 0x01, 0x07, 0x54, 0x02, 0x65, 0x6E,
0x74, 0x65, 0x73, 0x74])
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
data = bytes([0x00]) + cc + tlv + bytes(40)
ann = decode_15693_response(data)
assert "CC" in ann
assert '"test"' in ann
def test_generic_data_no_ndef(self):
"""Non-NDEF data still shows OK [NB]."""
data = bytes([0x00, 0xDE, 0xAD, 0xBE, 0xEF])
ann = decode_15693_response(data)
assert "OK [4B]" in ann
def test_inventory_not_affected(self):
"""Inventory response still decoded as inventory, not NDEF."""
data = bytes([0x00, 0x00]) + bytes(8)
ann = decode_15693_response(data)
assert "INVENTORY" in ann
```
**Step 2: Run tests to see them fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestResponseNdefDecode -v`
Expected: FAIL `"CC"` not in `"OK [4B]"`
**Step 3: Update decode_15693_response**
Replace the generic response handler section in `decode_15693_response` (the part after error check, starting at `data = payload[1:]`):
```python
def decode_15693_response(payload: bytes) -> str | None:
"""Decode an ISO 15693 response frame into annotation."""
if len(payload) < 1:
return None
flags = payload[0]
if flags & 0x01: # Error
if len(payload) >= 2:
code = payload[1]
desc = _15693_ERRORS.get(code, "")
return f"ERROR 0x{code:02X} {desc}" if desc else f"ERROR 0x{code:02X}"
return "ERROR"
data = payload[1:]
if len(data) == 0:
return "OK"
# Inventory response: dsfid(1) + uid(8) = 9 bytes
if len(data) == 9:
uid_msb = bytes(reversed(data[1:9]))
return f"OK INVENTORY UID={uid_msb.hex().upper()}"
# Try NDEF decode (CC block, NDEF TLV, or both)
ndef_ann = decode_ndef_annotation(data)
if ndef_ann:
return f"OK {ndef_ann}"
return f"OK [{len(data)}B]"
```
**Step 4: Run tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py -v`
Expected: All pass (including existing tests verify `test_decode_response_ok_data` still passes since it uses non-NDEF data)
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/hf_15.py tests/test_hf_15.py
git commit --no-gpg-sign -m "feat: wire NDEF decode into ISO 15693 response annotations"
```
---
### Task 3: Annotation overflow wrapping
**Files:**
- Modify: `pm3py/hf_15.py` (sniff formatter)
- Modify: `pm3py/sim/trace_fmt.py` (sim formatter)
- Modify: `tests/test_hf_15.py`
- Modify: `tests/test_sim_trace_fmt.py`
The current formatters already wrap hex to multiple lines when it's too wide. But annotations are either right-justified on the last hex line (short) or on their own line (wrapped hex). NDEF annotations can be long (e.g. `OK CC v1.0 MLEN=13 MBREAD | NDEF Text "en" "test"`). When the annotation overflows the available width, it should wrap to the next line at the annotation column.
**Step 1: Write failing tests**
In `tests/test_hf_15.py`, append:
```python
class TestSniffLineAnnotationOverflow:
def test_long_annotation_wraps(self):
"""Long NDEF annotation wraps to continuation line."""
# Build a response with CC + NDEF text that produces a long annotation
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
long_text = "A" * 30
from pm3py.sim.type5 import ndef_text
msg = ndef_text(long_text)
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
payload = bytes([0x00]) + cc + tlv
# Add CRC
payload += bytes([0xAA, 0xBB])
entry = {"is_response": True, "data": payload,
"timestamp": 0, "duration": 0, "parity": b""}
line = format_sniff_line(entry, width=80, is_tty=False)
lines = line.split("\n")
# Annotation should be on a separate continuation line
ann_lines = [l for l in lines if "NDEF" in l]
assert len(ann_lines) >= 1
def test_short_annotation_no_wrap(self):
"""Short annotation stays on same line as hex."""
data = bytes([0x00, 0xE1, 0x40, 0x0D, 0x01, 0xAA, 0xBB])
entry = {"is_response": True, "data": data,
"timestamp": 0, "duration": 0, "parity": b""}
line = format_sniff_line(entry, width=120, is_tty=False)
assert "CC" in line
# Should be single-line (plus possible hex wrap)
non_empty = [l for l in line.split("\n") if l.strip()]
assert len(non_empty) <= 2 # hex + annotation on same or next line
```
In `tests/test_sim_trace_fmt.py`, append to `TestTraceFormatterWrapping`:
```python
def test_long_annotation_wraps_to_next_line(self):
"""Long NDEF annotation wraps to continuation line."""
# Build response with CC + NDEF that gives a long annotation
cc = bytes([0xE1, 0x40, 0x0D, 0x01])
from pm3py.sim.type5 import ndef_text
msg = ndef_text("A" * 30)
tlv = bytes([0x03, len(msg)]) + msg + bytes([0xFE])
payload = bytes([0x00]) + cc + tlv
fmt = TraceFormatter(mode="sim", decoder=decode_15693, width=80)
result = self._strip_ansi(fmt.format(1, payload))
lines = result.strip().split("\n")
ann_lines = [l for l in lines if "NDEF" in l]
assert len(ann_lines) >= 1
```
**Step 2: Run tests to see them fail**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py::TestSniffLineAnnotationOverflow tests/test_sim_trace_fmt.py::TestTraceFormatterWrapping::test_long_annotation_wraps_to_next_line -v`
Expected: Might pass or fail depending on current wrapping behavior the annotation might already go on its own line if hex is wrapped. Run to check.
**Step 3: Update annotation wrapping in both formatters**
In `format_sniff_line()` in `hf_15.py`, replace the annotation handling in the multi-line section (the `if annotation:` block near the end):
```python
if annotation:
# Wrap annotation if it exceeds available width
ann_lines = _wrap_annotation(annotation, avail)
for al in ann_lines:
ann_pad = max(avail - len(al), 0)
parts.append(f"{pad}{' ' * ann_pad}{_color(ann_color, al, is_tty)}")
```
And add the helper (shared between both formatters):
```python
def _wrap_annotation(annotation: str, avail: int) -> list[str]:
"""Wrap annotation text to fit within avail columns.
Splits on ' | ' boundaries first, then by word if still too long.
"""
if len(annotation) <= avail:
return [annotation]
# Try splitting on pipe separator (NDEF uses " | " between parts)
if " | " in annotation:
parts = annotation.split(" | ")
lines = []
current = parts[0]
for part in parts[1:]:
candidate = f"{current} | {part}"
if len(candidate) <= avail:
current = candidate
else:
lines.append(current)
current = part
lines.append(current)
return lines
# Fall back to truncation
return [annotation[:avail - 3] + "..."]
```
In `trace_fmt.py`, apply the same pattern to the `TraceFormatter.format()` method's multi-line annotation section. Import `_wrap_annotation` from `hf_15` or duplicate the logic inline:
In the multi-line section of `TraceFormatter.format()`, replace:
```python
if annotation:
ann_pad = avail - len(annotation)
ann_pad = max(ann_pad, 0)
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, annotation)}")
```
With:
```python
if annotation:
ann_lines = self._wrap_annotation(annotation, avail)
for al in ann_lines:
ann_pad = max(avail - len(al), 0)
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
```
And add to `TraceFormatter`:
```python
def _wrap_annotation(self, annotation: str, avail: int) -> list[str]:
"""Wrap annotation to fit within avail columns."""
if len(annotation) <= avail:
return [annotation]
if " | " in annotation:
parts = annotation.split(" | ")
lines = []
current = parts[0]
for part in parts[1:]:
candidate = f"{current} | {part}"
if len(candidate) <= avail:
current = candidate
else:
lines.append(current)
current = part
lines.append(current)
return lines
return [annotation[:avail - 3] + "..."]
```
**Step 4: Run all tests**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/test_hf_15.py tests/test_sim_trace_fmt.py -v`
Expected: All pass
**Step 5: Commit**
```bash
cd /home/work/pm3py/.worktrees/sim-framework
git add pm3py/hf_15.py pm3py/sim/trace_fmt.py tests/test_hf_15.py tests/test_sim_trace_fmt.py
git commit --no-gpg-sign -m "feat: annotation overflow wrapping for NDEF decode in trace output"
```
---
### Task 4: Copy hf_15.py to main branch + final test
**Files:**
- Copy: `pm3py/hf_15.py` `/home/work/pm3py/pm3py/hf_15.py`
- Copy: `tests/test_hf_15.py` `/home/work/pm3py/tests/test_hf_15.py`
**Step 1: Copy files**
```bash
cp /home/work/pm3py/.worktrees/sim-framework/pm3py/hf_15.py /home/work/pm3py/pm3py/hf_15.py
cp /home/work/pm3py/.worktrees/sim-framework/tests/test_hf_15.py /home/work/pm3py/tests/test_hf_15.py
```
**Step 2: Run main branch tests**
Run: `cd /home/work/pm3py && python -m pytest tests/ -v`
Expected: All pass
**Step 3: Run worktree full test suite**
Run: `cd /home/work/pm3py/.worktrees/sim-framework && python -m pytest tests/ -v`
Expected: All pass