Scaffold local OBS streaming bridge

This commit is contained in:
Meta Vens 2026-05-18 10:56:01 +01:00
commit beac84ae23
18 changed files with 1159 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
config/localcast.json
logs/
*.log
*.tmp
__pycache__/

74
AGENTS.md Normal file
View file

@ -0,0 +1,74 @@
# AGENTS.md
## Purpose
LocalCastBridge is the local LAN streaming rig for making another Windows PC available inside OBS as video plus separately mixable audio sources, with video encoded by NVENC on the sender.
Keep this repo blunt. The goal is not to fork OBS for sport. Use existing OBS and FFmpeg surfaces until a real invariant is impossible without native code.
## Persistence Scaffolding
- Treat `state/map.yaml` as the canonical current map for this repo.
- Treat `state/scratch.md` as disposable scratch for one bounded subgoal.
- Treat `state/evidence.jsonl` as the distilled durable ledger of decisions, verifications, rejected paths, and scars that change future belief.
- Treat `state/branches.json` as the lightweight branch/hypothesis ledger.
- Treat `notes/fresh-workspace-handoff.md` as the short re-entry packet.
- Treat `notes/current-system-map.md` as the current source-grounded architecture and data-flow map.
- Treat `docs/implementation-plan.md` as the forward plan for what is implemented, temporary, and next.
- Update `state/map.yaml` when project understanding changes. Add evidence only when the lesson changes what the next agent should believe.
- Keep exact branch and HEAD snapshots out of handoff prose. Git commands own volatile truth.
## Persistence Commands
Use a real Python if the Windows Store alias gets cute. In this Codex workstation, the bundled runtime is:
`C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe`.
```powershell
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py add-evidence --type decision --status ok --note "..."
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_prepare_compaction.py
```
## Session Bootstrap And Re-entry Protocol
On a fresh session, do this before editing:
1. read:
- `state/map.yaml`
- `notes/fresh-workspace-handoff.md`
- `notes/current-system-map.md`
- `docs/implementation-plan.md`
2. run:
- `& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status`
3. restate the current mechanism, intended change, and next bounded move before broad edits
After compaction, resume, or suspicious continuity loss:
1. rerun `& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status`
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
3. treat the persisted next action as orientation, not permission to wander
When the user says to prepare for imminent compaction:
1. run `tools/localcast_prepare_compaction.py` before editing persistence surfaces
2. use its warnings as the checklist for map, handoff, scratch, evidence, and git hygiene
3. update only the state that actually changed
4. run `tools/localcast_prepare_compaction.py` again after edits
5. commit the completed persistence pass unless the work is deliberately mid-surgery
## Operating Discipline
- Before substantial edits, restate the current mechanism and intended change.
- Prefer one clear hypothesis per iteration.
- Verify against OBS ingest behavior and actual sender-side FFmpeg capability, not vibes wearing a lab coat.
- Do not add an OBS plugin, fork, registry, custom transport daemon, or compatibility layer until the script-based SRT design fails a concrete invariant.
- Keep video, audio-source isolation, transport, and OBS composition as separate responsibilities.
- If the diff grows while understanding shrinks, stop implementation and update the map.
## Product Truths
- V1 is a LAN bridge, not an internet relay.
- Sender-side video encoding must use NVENC when available.
- Receiver-side OBS should see stable source URLs that can be added as Media Sources.
- Audio must remain separately mixable in OBS. That means separate audio endpoints in v1, even if a later synchronized bundle becomes useful.
- The repo owns repeatable setup, scripts, config, and operating docs. FFmpeg and OBS own media work.

63
README.md Normal file
View file

@ -0,0 +1,63 @@
# LocalCastBridge
LocalCastBridge turns a Windows gaming/desktop PC into LAN sources for another machine's OBS scene.
V1 does not fork OBS. OBS already accepts SRT Media Sources, and FFmpeg can capture Windows desktop/audio while encoding video with `h264_nvenc`. The sane first machine is:
```mermaid
flowchart LR
A["Husband's PC"] --> B["FFmpeg capture scripts"]
B --> C["NVENC H.264 video over SRT"]
B --> D["Opus audio source 1 over SRT"]
B --> E["Opus audio source 2 over SRT"]
C --> F["OBS Media Source: video"]
D --> G["OBS Media Source: audio source 1"]
E --> H["OBS Media Source: audio source 2"]
```
The video stream is encoded on the sender. Audio sources are separate endpoints so OBS can mix, mute, filter, and monitor them independently.
## Current Status
This repo is the first coherent scaffold: architecture docs, persistence machinery, and Windows scripts for device discovery, config validation, and FFmpeg command generation. It is ready for a real two-PC smoke test once FFmpeg with SRT/NVENC support is installed on the sender.
## Quick Start
1. Install OBS on the receiver.
2. Install FFmpeg on the sender, with `srt` protocol and `h264_nvenc` encoder enabled.
3. Copy `config/localcast.example.json` to `config/localcast.json`.
4. On the sender, list capture devices:
```powershell
.\scripts\sender-discover.ps1
```
5. Edit `config/localcast.json` with the receiver IP and chosen DirectShow audio device names.
6. Dry-run the generated FFmpeg commands:
```powershell
.\scripts\sender-start.ps1 -Config .\config\localcast.json -DryRun
```
7. Start the sender:
```powershell
.\scripts\sender-start.ps1 -Config .\config\localcast.json
```
8. In OBS on the receiver, add one Media Source per URL from `docs/obs-receiver-setup.md`.
## Why SRT Media Sources First
OBS documents SRT playback through VLC Source or Media Source, and Media Source can listen on `srt://0.0.0.0:PORT?mode=listener&timeout=5000000` with `mpegts` input format. FFmpeg also documents SRT protocol support and Windows capture devices through `dshow`/`gdigrab`. That gives us a standard, inspectable path before native OBS code starts making expensive promises.
## Repo State
Rehydrate with the bundled Python from this Codex workstation, or any normal Python 3:
```powershell
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status
Get-Content .\state\map.yaml
Get-Content .\notes\fresh-workspace-handoff.md
Get-Content .\notes\current-system-map.md
```

View file

@ -0,0 +1,37 @@
{
"receiver": {
"host": "192.168.1.50",
"basePort": 5100,
"srtLatencyMicros": 120000,
"passphrase": ""
},
"video": {
"enabled": true,
"source": "desktop",
"width": 1920,
"height": 1080,
"framerate": 60,
"bitrate": "12000k",
"maxrate": "12000k",
"bufsize": "24000k",
"preset": "p5",
"tune": "ll",
"portOffset": 0
},
"audioSources": [
{
"name": "desktop",
"device": "CABLE Output (VB-Audio Virtual Cable)",
"portOffset": 1,
"codec": "aac",
"bitrate": "160k"
},
{
"name": "voice",
"device": "Microphone (USB Audio Device)",
"portOffset": 2,
"codec": "aac",
"bitrate": "128k"
}
]
}

48
docs/architecture.md Normal file
View file

@ -0,0 +1,48 @@
# Architecture
## Objective
Make one local Windows computer available as OBS sources on another computer, with sender-side NVENC video encoding and independently mixable audio sources.
## Current Mechanism
The repo owns configuration and scripts. FFmpeg owns capture, encoding, and SRT transport. OBS owns scene composition.
Inputs become outputs like this:
1. `config/localcast.json` declares the receiver IP, SRT ports, video capture shape, and named audio devices.
2. `scripts/sender-discover.ps1` asks FFmpeg what Windows capture devices and encoders exist.
3. `scripts/sender-start.ps1` validates the config and launches one FFmpeg process per source.
4. The video process captures the desktop and encodes H.264 using `h264_nvenc`.
5. Each audio process captures one DirectShow audio device and encodes Opus.
6. Each process sends MPEG-TS over SRT to a stable receiver URL.
7. OBS adds each URL as a Media Source.
## Invariants
- Video encoding happens on the sender.
- OBS receives ordinary media-source URLs; it does not need a plugin for v1.
- Audio remains separately mixable in OBS.
- Ports are stable and derived from one base port plus explicit offsets.
- Secrets, if used, stay in local config and are not committed.
## Intended Change
The first implementation creates the repeatable operating spine. Future native code is only justified if the SRT Media Source path fails one of the invariants above.
## Cut Line
The following do not currently earn their keep:
- OBS fork
- OBS plugin
- custom receiver daemon
- synchronized multi-track container that hides audio sources from OBS mixing
- homegrown capture stack over Windows APIs
## Source Notes
- OBS SRT guide: `https://obsproject.com/kb/srt-protocol-streaming-guide`
- FFmpeg devices docs: `https://www.ffmpeg.org/ffmpeg-devices.html`
- FFmpeg protocols docs: `https://ffmpeg.org/ffmpeg-protocols.html`

View file

@ -0,0 +1,26 @@
# Implementation Plan
## Implemented
- Repo-local persistence machinery.
- First architecture map.
- Example config for one video source plus two audio sources.
- Sender device discovery script.
- Sender launch script with dry-run mode and per-source FFmpeg commands.
- OBS receiver setup notes.
## Temporary
- Audio and video are separate SRT endpoints. This preserves OBS mixing authority but may need latency tuning.
- Audio defaults to AAC inside MPEG-TS for compatibility; test Opus later only if there is a concrete reason.
- Desktop capture uses `gdigrab` first because it is broadly available. `ddagrab` is a candidate once the installed FFmpeg build is confirmed.
- The scripts assume Windows sender and OBS receiver on the same LAN.
## Next
1. Run `scripts/sender-discover.ps1` on the sender and record the actual device names.
2. Smoke-test one video stream into OBS.
3. Smoke-test each audio source independently.
4. Tune SRT latency and FFmpeg buffering for the local network.
5. Decide whether a small OBS scene/source generator is worth adding.
6. Reconsider plugin/fork only if standard OBS Media Source cannot preserve the required behavior.

View file

@ -0,0 +1,42 @@
# OBS Receiver Setup
Add each stream as a separate OBS Media Source.
For every source:
1. Add `Sources > + > Media Source`.
2. Uncheck `Local File`.
3. Set `Input` to the relevant SRT URL.
4. Set `Input Format` to `mpegts`.
5. Enable `Restart playback when source becomes active`.
## Default URLs
These match `config/localcast.example.json` when the receiver is listening locally:
```text
video: srt://0.0.0.0:5100?mode=listener&latency=120000&timeout=5000000
desktop: srt://0.0.0.0:5101?mode=listener&latency=120000&timeout=5000000
voice: srt://0.0.0.0:5102?mode=listener&latency=120000&timeout=5000000
```
If using a passphrase, append:
```text
&passphrase=YOUR_SECRET&pbkeylen=16
```
OBS should be open before the sender starts if OBS is the SRT listener. If a source gets wedged, deactivate and reactivate the Media Source before changing ports; port churn is a symptom, not a configuration strategy.
## Source Layout
Recommended OBS scene:
```text
Husband PC - Video Media Source, monitor off
Husband PC - Desktop Media Source, audio only, monitor as needed
Husband PC - Voice Media Source, audio only, monitor as needed
```
Mute the video source if it carries no audio. Keep audio filters on the separate audio sources.

View file

@ -0,0 +1,36 @@
# Current System Map
LocalCastBridge is intentionally thin.
```mermaid
flowchart TD
A["config/localcast.json"] --> B["sender-start.ps1"]
C["FFmpeg on sender"] --> D["Windows desktop capture"]
C --> E["DirectShow audio capture"]
D --> F["h264_nvenc encode"]
E --> G["AAC encode"]
F --> H["SRT MPEG-TS video endpoint"]
G --> I["SRT MPEG-TS audio endpoint(s)"]
H --> J["OBS Media Source"]
I --> K["OBS Media Source per audio source"]
J --> L["OBS scene composition"]
K --> L
```
## Ownership
- Config owns source identity, receiver address, ports, and encoding knobs.
- FFmpeg owns capture, encoding, and network transport.
- OBS owns source activation, layout, filters, monitoring, and recording/streaming.
- This repo owns repeatability and memory.
## Why Not Plugin First
An OBS plugin would be justified if OBS could not ingest stable LAN streams, or if a plugin were needed to expose independent audio controls. V1 gets independent audio controls by making each source an OBS Media Source. That is boring. Boring is allowed to win when it is correct.
## Known Risks
- Windows audio source names vary by driver and localization.
- Some FFmpeg builds omit SRT or NVENC.
- Separate endpoints can drift; local latency should be tuned before adding a synchronization layer.
- OBS SRT reconnection behavior can be fussy. Use stable ports and source reactivation before treating port changes as a fix.

View file

@ -0,0 +1,37 @@
# Fresh Workspace Handoff
This is the short re-entry packet for `E:\Projects\LocalCastBridge`.
Do not trust this file for the exact live HEAD. Exact branch, HEAD, and dirty state are volatile. Always ask git.
## Rehydrate
```powershell
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status
Get-Content .\state\map.yaml
Get-Content .\notes\fresh-workspace-handoff.md
Get-Content .\notes\current-system-map.md
Get-Content .\docs\implementation-plan.md
git status --short --branch
git log --oneline -5
Get-Content .\state\evidence.jsonl -Tail 8
```
## Current Shape
- V1 uses FFmpeg plus OBS Media Source over SRT.
- Sender-side video encoding is `h264_nvenc`.
- Audio sources are separate SRT endpoints so OBS can mix them separately.
- The repo contains scripts and docs, not a native OBS plugin.
## Current Pressure
The useful next work is real hardware validation:
- confirm the sender FFmpeg build has `srt` and `h264_nvenc`
- capture actual audio device names
- tune latency once streams are live in OBS
## Immediate Re-entry Instruction
Do not continue implementation automatically from a rehydrate-only request. Rehydrate, then follow the user's next instruction.

View file

@ -0,0 +1,33 @@
param(
[string]$FfmpegPath = "ffmpeg"
)
$ErrorActionPreference = "Stop"
function Invoke-Checked {
param(
[string]$Label,
[string[]]$Arguments
)
Write-Host ""
Write-Host "== $Label =="
& $FfmpegPath @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Warning "$Label exited with code $LASTEXITCODE"
}
}
Write-Host "FFmpeg binary: $FfmpegPath"
Invoke-Checked -Label "Version" -Arguments @("-hide_banner", "-version")
Invoke-Checked -Label "SRT protocol check" -Arguments @("-hide_banner", "-protocols")
Invoke-Checked -Label "NVENC encoder check" -Arguments @("-hide_banner", "-encoders")
Invoke-Checked -Label "DirectShow devices" -Arguments @("-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy")
Write-Host ""
Write-Host "Look for:"
Write-Host "- protocol list contains srt"
Write-Host "- encoder list contains h264_nvenc"
Write-Host "- audio device names you can copy into config/localcast.json"

132
scripts/sender-start.ps1 Normal file
View file

@ -0,0 +1,132 @@
param(
[string]$Config = ".\config\localcast.json",
[string]$FfmpegPath = "ffmpeg",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
function Resolve-ConfigPath {
param([string]$Path)
$resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop
return $resolved.Path
}
function Assert-Value {
param(
[object]$Value,
[string]$Message
)
if ($null -eq $Value -or "$Value".Trim().Length -eq 0) {
throw $Message
}
}
function Get-SrtUrl {
param(
[object]$Receiver,
[int]$PortOffset
)
$port = [int]$Receiver.basePort + $PortOffset
$query = "mode=caller&latency=$($Receiver.srtLatencyMicros)"
if ($Receiver.passphrase -and "$($Receiver.passphrase)".Length -gt 0) {
$escaped = [uri]::EscapeDataString("$($Receiver.passphrase)")
$query = "$query&passphrase=$escaped&pbkeylen=16"
}
return "srt://$($Receiver.host):$($port)?$query"
}
function Format-CommandLine {
param(
[string]$Executable,
[string[]]$Arguments
)
$parts = @($Executable)
foreach ($arg in $Arguments) {
if ($arg -match '[\s"&?=]') {
$escaped = $arg.Replace('"', '\"')
$parts += '"' + $escaped + '"'
} else {
$parts += $arg
}
}
return ($parts -join " ")
}
function Start-SourceProcess {
param(
[string]$Name,
[string[]]$Arguments
)
$line = Format-CommandLine -Executable $FfmpegPath -Arguments $Arguments
if ($DryRun) {
Write-Host ""
Write-Host "[$Name]"
Write-Host $line
return
}
New-Item -ItemType Directory -Force ".\logs" | Out-Null
$stdout = Join-Path ".\logs" "$Name.out.log"
$stderr = Join-Path ".\logs" "$Name.err.log"
$process = Start-Process -FilePath $FfmpegPath -ArgumentList $Arguments -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden
Write-Host "$Name started. PID=$($process.Id) stdout=$stdout stderr=$stderr"
}
$configPath = Resolve-ConfigPath -Path $Config
$settings = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
Assert-Value -Value $settings.receiver.host -Message "receiver.host is required"
Assert-Value -Value $settings.receiver.basePort -Message "receiver.basePort is required"
if (-not $settings.receiver.srtLatencyMicros) {
$settings.receiver | Add-Member -NotePropertyName srtLatencyMicros -NotePropertyValue 120000
}
if ($settings.video -and $settings.video.enabled) {
$videoUrl = Get-SrtUrl -Receiver $settings.receiver -PortOffset ([int]$settings.video.portOffset)
$videoArgs = @(
"-hide_banner",
"-loglevel", "info",
"-f", "gdigrab",
"-framerate", "$($settings.video.framerate)",
"-video_size", "$($settings.video.width)x$($settings.video.height)",
"-i", "$($settings.video.source)",
"-an",
"-c:v", "h264_nvenc",
"-preset", "$($settings.video.preset)",
"-tune", "$($settings.video.tune)",
"-b:v", "$($settings.video.bitrate)",
"-maxrate", "$($settings.video.maxrate)",
"-bufsize", "$($settings.video.bufsize)",
"-pix_fmt", "yuv420p",
"-g", "$([int]$settings.video.framerate * 2)",
"-f", "mpegts",
$videoUrl
)
Start-SourceProcess -Name "video" -Arguments $videoArgs
}
foreach ($audio in @($settings.audioSources)) {
Assert-Value -Value $audio.name -Message "audioSources[].name is required"
Assert-Value -Value $audio.device -Message "audioSources[].device is required"
$audioUrl = Get-SrtUrl -Receiver $settings.receiver -PortOffset ([int]$audio.portOffset)
$audioArgs = @(
"-hide_banner",
"-loglevel", "info",
"-f", "dshow",
"-i", "audio=$($audio.device)",
"-vn",
"-c:a", "$(if ($audio.codec) { $audio.codec } else { "aac" })",
"-b:a", "$($audio.bitrate)",
"-f", "mpegts",
$audioUrl
)
Start-SourceProcess -Name "audio-$($audio.name)" -Arguments $audioArgs
}
if ($DryRun) {
Write-Host ""
Write-Host "Dry run complete. Start OBS Media Sources in listener mode before running without -DryRun."
}

28
scripts/sender-stop.ps1 Normal file
View file

@ -0,0 +1,28 @@
param(
[string]$LogDirectory = ".\logs"
)
$ErrorActionPreference = "Stop"
$logs = Resolve-Path -LiteralPath $LogDirectory -ErrorAction SilentlyContinue
if (-not $logs) {
Write-Host "No log directory found at $LogDirectory"
return
}
$ffmpegProcesses = Get-CimInstance Win32_Process |
Where-Object {
$_.Name -ieq "ffmpeg.exe" -and
$_.CommandLine -like "*srt://*"
}
if (-not $ffmpegProcesses) {
Write-Host "No LocalCastBridge-looking ffmpeg SRT processes found."
return
}
foreach ($process in $ffmpegProcesses) {
Stop-Process -Id $process.ProcessId -Force
Write-Host "Stopped ffmpeg PID=$($process.ProcessId)"
}

4
state/branches.json Normal file
View file

@ -0,0 +1,4 @@
{
"branches": []
}

3
state/evidence.jsonl Normal file
View file

@ -0,0 +1,3 @@
{"ts":"2026-05-18T00:00:00+00:00","type":"decision","status":"ok","note":"Use FFmpeg SRT endpoints plus OBS Media Source for v1; OBS plugin or fork is not justified until standard ingest fails a concrete invariant."}
{"ts": "2026-05-18T09:55:45+00:00", "type": "verification", "status": "ok", "note": "State helper and sender dry-run produce valid repo status and SRT URLs with explicit ports for the example config."}

55
state/map.yaml Normal file
View file

@ -0,0 +1,55 @@
workspace:
name: LocalCastBridge
status: active
focus: local-obs-lan-source
premise: >
A second Windows PC should appear inside OBS as video plus separately
mixable audio sources. Use ordinary media transport first; native OBS code
only earns its keep after standard OBS/FFmpeg surfaces fail a real invariant.
objective:
primary: >
Provide a repeatable local streaming environment where the sender captures
desktop/video and audio sources, encodes video with NVENC, and delivers
stable OBS Media Source URLs to the receiver over LAN.
current: >
Establish the script-and-doc spine for a two-PC smoke test.
constraints:
- Sender is Windows.
- Receiver uses OBS.
- Video encode must happen on the sender using NVENC when available.
- Audio sources must remain separately controllable in OBS.
- V1 is LAN-only.
- Persistent state must clarify rehydrate, not become transcript tax.
invariants:
- OBS composition authority stays in OBS.
- FFmpeg owns capture, encoding, and transport.
- One stable port equals one OBS source endpoint.
- Video endpoint uses H.264 over MPEG-TS/SRT.
- Audio endpoints default to AAC over MPEG-TS/SRT for broad OBS/FFmpeg compatibility.
- Local secrets remain untracked.
accepted_design:
- `config/localcast.example.json` defines receiver address, base port, video encode settings, and audio sources.
- `scripts/sender-discover.ps1` lists FFmpeg devices, protocols, and encoders.
- `scripts/sender-start.ps1` launches one FFmpeg process per enabled source.
- OBS receives each source through Media Source URLs documented in `docs/obs-receiver-setup.md`.
- Persistence follows the active repo pattern: map, scratch, evidence, branches, handoff, system map, implementation plan, state helpers.
active_subgoals:
- Validate the actual sender FFmpeg build and capture device names.
- Smoke-test the video endpoint in OBS.
- Smoke-test each audio endpoint in OBS and tune latency.
- Decide whether an OBS source generator or plugin investigation is justified.
current_status:
summary: >
First repo scaffold is in place with persistence machinery, architecture
docs, example config, and sender scripts for FFmpeg/SRT/NVENC local OBS
source delivery.
next_action: >
Run sender discovery on the husband's PC, fill config/localcast.json with
actual receiver IP and audio device names, then dry-run sender-start before
opening live streams into OBS.

11
state/scratch.md Normal file
View file

@ -0,0 +1,11 @@
# Scratch
## Current Subgoal
No active scratch subgoal.
## Notes
- Keep scratch short and disposable.
- Promote only durable lessons into `state/evidence.jsonl` or the map.

View file

@ -0,0 +1,332 @@
from __future__ import annotations
import argparse
import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
STATE_DIR = ROOT / "state"
NOTES_DIR = ROOT / "notes"
MAP_PATH = STATE_DIR / "map.yaml"
SCRATCH_PATH = STATE_DIR / "scratch.md"
BRANCHES_PATH = STATE_DIR / "branches.json"
EVIDENCE_PATH = STATE_DIR / "evidence.jsonl"
HANDOFF_PATH = NOTES_DIR / "fresh-workspace-handoff.md"
PLAN_PATH = ROOT / "docs" / "implementation-plan.md"
SYSTEM_MAP_PATH = NOTES_DIR / "current-system-map.md"
AGENTS_PATH = ROOT / "AGENTS.md"
@dataclass
class Finding:
level: str
message: str
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def run_git(args: list[str]) -> str:
completed = subprocess.run(
["git", *args],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip()
def extract_map_field(name: str) -> str | None:
prefix = f" {name}:"
lines = read_text(MAP_PATH).splitlines()
for index, line in enumerate(lines):
if line.startswith(prefix):
value = line.split(":", 1)[1].strip()
if value != ">":
return value
block: list[str] = []
for candidate in lines[index + 1 :]:
if candidate.startswith(" "):
block.append(candidate.strip())
continue
if candidate.strip() == "":
block.append("")
continue
break
return " ".join(part for part in block if part).strip()
return None
def extract_active_subgoals() -> list[str]:
lines = read_text(MAP_PATH).splitlines()
results: list[str] = []
in_section = False
for line in lines:
if not in_section:
if line.strip() == "active_subgoals:":
in_section = True
continue
if line.startswith(" - "):
results.append(line[4:].strip())
continue
if line and not line.startswith(" "):
break
return results
def current_scratch_subgoal() -> str | None:
lines = read_text(SCRATCH_PATH).splitlines()
for index, line in enumerate(lines):
if line.strip() != "## Current Subgoal":
continue
for candidate in lines[index + 1 :]:
stripped = candidate.strip()
if stripped:
return stripped
return None
def load_evidence() -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for line_number, line in enumerate(read_text(EVIDENCE_PATH).splitlines(), start=1):
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"evidence line {line_number} is invalid JSON: {exc}") from exc
if not isinstance(record, dict):
raise ValueError(f"evidence line {line_number} is not a JSON object")
records.append(record)
return records
def load_branches() -> dict[str, Any]:
data = json.loads(read_text(BRANCHES_PATH))
if not isinstance(data, dict):
raise ValueError("branches.json must be a JSON object")
branches = data.get("branches")
if not isinstance(branches, list):
raise ValueError("branches.json must contain a branches list")
return data
def add_required_file_checks(findings: list[Finding]) -> None:
for path in [
MAP_PATH,
SCRATCH_PATH,
BRANCHES_PATH,
EVIDENCE_PATH,
HANDOFF_PATH,
PLAN_PATH,
SYSTEM_MAP_PATH,
AGENTS_PATH,
]:
if path.exists():
findings.append(Finding("ok", f"found {path.relative_to(ROOT)}"))
else:
findings.append(Finding("error", f"missing {path.relative_to(ROOT)}"))
def add_content_checks(findings: list[Finding]) -> list[dict[str, Any]]:
summary = extract_map_field("summary")
next_action = extract_map_field("next_action")
if summary:
findings.append(Finding("ok", "state/map.yaml has current_status.summary"))
else:
findings.append(Finding("error", "state/map.yaml is missing current_status.summary"))
if next_action:
findings.append(Finding("ok", "state/map.yaml has current_status.next_action"))
else:
findings.append(Finding("error", "state/map.yaml is missing current_status.next_action"))
subgoals = extract_active_subgoals()
if subgoals:
findings.append(Finding("ok", f"state/map.yaml has {len(subgoals)} active subgoal(s)"))
else:
findings.append(Finding("warn", "state/map.yaml has no active_subgoals entries"))
scratch_subgoal = current_scratch_subgoal()
if scratch_subgoal == "No active scratch subgoal.":
findings.append(Finding("ok", "state/scratch.md has no stale active scratch subgoal"))
elif scratch_subgoal:
findings.append(Finding("warn", f"state/scratch.md has active scratch subgoal: {scratch_subgoal}"))
else:
findings.append(Finding("warn", "state/scratch.md has no Current Subgoal value"))
evidence = load_evidence()
findings.append(Finding("ok", f"state/evidence.jsonl parses ({len(evidence)} record(s))"))
if EVIDENCE_PATH.exists() and EVIDENCE_PATH.stat().st_size > 25_000:
findings.append(Finding("warn", "state/evidence.jsonl is larger than 25 KB; consider distillation"))
branches = load_branches()
active = [
branch
for branch in branches.get("branches", [])
if isinstance(branch, dict) and branch.get("status") == "active"
]
findings.append(Finding("ok", f"state/branches.json parses ({len(active)} active branch(es))"))
return evidence
def add_handoff_checks(findings: list[Finding]) -> None:
text = read_text(HANDOFF_PATH)
stale_branch = re.search(r"Current branch before .*ahead \d+", text)
stale_head = re.search(r"Current HEAD before .*\b[0-9a-f]{7,40}\b", text)
if stale_branch or stale_head:
findings.append(Finding("error", "handoff embeds an exact branch or HEAD snapshot; use git commands instead"))
else:
findings.append(Finding("ok", "handoff avoids exact branch/HEAD snapshots"))
required_phrases = [
"Do not continue implementation automatically from a rehydrate-only request.",
"Do not trust this file for the exact live HEAD.",
"Immediate Re-entry Instruction",
]
for phrase in required_phrases:
if phrase in text:
findings.append(Finding("ok", f"handoff contains: {phrase}"))
else:
findings.append(Finding("warn", f"handoff missing: {phrase}"))
def add_agents_checks(findings: list[Finding]) -> None:
text = read_text(AGENTS_PATH)
if "localcast_prepare_compaction.py" in text:
findings.append(Finding("ok", "AGENTS.md tells agents to use the compaction helper"))
else:
findings.append(Finding("error", "AGENTS.md does not mention tools/localcast_prepare_compaction.py"))
if "prepare for imminent compaction" in text.lower():
findings.append(Finding("ok", "AGENTS.md names the imminent-compaction trigger"))
else:
findings.append(Finding("warn", "AGENTS.md does not name the imminent-compaction trigger phrase"))
def add_git_checks(findings: list[Finding]) -> tuple[str, str]:
status = run_git(["status", "--short", "--branch"])
try:
log = run_git(["log", "--oneline", "-5"])
except subprocess.CalledProcessError:
log = "(no commits yet)"
dirty_lines = [line for line in status.splitlines()[1:] if line.strip()]
if dirty_lines:
findings.append(Finding("warn", "git worktree has uncommitted changes; commit or explain before compaction"))
else:
findings.append(Finding("ok", "git worktree is clean"))
return status, log
def render_report(findings: list[Finding], status: str, log: str, evidence: list[dict[str, Any]]) -> str:
summary = extract_map_field("summary") or "(missing)"
next_action = extract_map_field("next_action") or "(missing)"
subgoals = extract_active_subgoals()
counts = {
"ok": sum(1 for finding in findings if finding.level == "ok"),
"warn": sum(1 for finding in findings if finding.level == "warn"),
"error": sum(1 for finding in findings if finding.level == "error"),
}
lines = [
"LocalCastBridge pre-compaction persistence check",
f"Workspace: {ROOT}",
f"Findings: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error",
"",
"Git status:",
status,
"",
"Recent commits:",
log,
"",
f"Summary: {summary}",
f"Next action: {next_action}",
]
if subgoals:
lines.append("Active subgoals:")
lines.extend(f"- {item}" for item in subgoals)
if evidence:
latest = evidence[-1]
lines.extend(
[
"",
"Latest distilled evidence:",
f"- {latest.get('ts', '(missing ts)')} {latest.get('type', '(missing type)')}/{latest.get('status', '(missing status)')}: {latest.get('note', '(missing note)')}",
]
)
lines.append("")
lines.append("Findings:")
for finding in findings:
lines.append(f"[{finding.level.upper()}] {finding.message}")
lines.extend(
[
"",
"Pre-compaction checklist:",
"- Update state/map.yaml only if current understanding changed.",
"- Refresh notes/fresh-workspace-handoff.md if re-entry instructions changed.",
"- Add distilled evidence only for a belief-changing lesson, verification, rejected path, or scar.",
"- Keep exact branch/HEAD out of handoff prose; git commands own volatile truth.",
"- Commit completed persistence changes, or state why the worktree must stay dirty.",
"- Re-run this helper after edits before yielding to compaction.",
]
)
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Audit LocalCastBridge persistent state before imminent compaction."
)
parser.add_argument(
"--strict",
action="store_true",
help="Exit nonzero on warnings as well as errors.",
)
return parser
def main() -> int:
args = build_parser().parse_args()
findings: list[Finding] = []
evidence: list[dict[str, Any]] = []
add_required_file_checks(findings)
try:
evidence = add_content_checks(findings)
except (OSError, ValueError, json.JSONDecodeError) as exc:
findings.append(Finding("error", f"state content check failed: {exc}"))
try:
add_handoff_checks(findings)
except OSError as exc:
findings.append(Finding("error", f"handoff check failed: {exc}"))
try:
add_agents_checks(findings)
except OSError as exc:
findings.append(Finding("error", f"AGENTS check failed: {exc}"))
try:
status, log = add_git_checks(findings)
except (OSError, subprocess.CalledProcessError) as exc:
findings.append(Finding("error", f"git check failed: {exc}"))
status = "(git status unavailable)"
log = "(git log unavailable)"
print(render_report(findings, status, log, evidence))
has_error = any(finding.level == "error" for finding in findings)
has_warning = any(finding.level == "warn" for finding in findings)
if has_error or (args.strict and has_warning):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

192
tools/localcast_state.py Normal file
View file

@ -0,0 +1,192 @@
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
STATE_DIR = ROOT / "state"
MAP_PATH = STATE_DIR / "map.yaml"
BRANCHES_PATH = STATE_DIR / "branches.json"
EVIDENCE_PATH = STATE_DIR / "evidence.jsonl"
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def write_text(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def load_branches() -> dict[str, Any]:
return json.loads(read_text(BRANCHES_PATH))
def save_branches(data: dict[str, Any]) -> None:
write_text(BRANCHES_PATH, json.dumps(data, indent=2) + "\n")
def append_evidence(record: dict[str, Any]) -> None:
with EVIDENCE_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=True) + "\n")
def utc_stamp() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def extract_map_field(name: str) -> str | None:
prefix = f" {name}:"
lines = read_text(MAP_PATH).splitlines()
for index, line in enumerate(lines):
if line.startswith(prefix):
value = line.split(":", 1)[1].strip()
if value != ">":
return value
block: list[str] = []
for candidate in lines[index + 1 :]:
if candidate.startswith(" "):
block.append(candidate.strip())
continue
if candidate.strip() == "":
block.append("")
continue
break
return " ".join(part for part in block if part).strip()
return None
def extract_active_subgoals() -> list[str]:
lines = read_text(MAP_PATH).splitlines()
results: list[str] = []
in_section = False
for line in lines:
if not in_section:
if line.strip() == "active_subgoals:":
in_section = True
continue
if line.startswith(" - "):
results.append(line[4:].strip())
continue
if line and not line.startswith(" "):
break
return results
def cmd_status(_: argparse.Namespace) -> int:
branches = load_branches().get("branches", [])
active = [branch for branch in branches if branch.get("status") == "active"]
summary = extract_map_field("summary") or "(missing)"
next_action = extract_map_field("next_action") or "(missing)"
subgoals = extract_active_subgoals()
print(f"Workspace: {ROOT}")
print(f"Summary: {summary}")
print(f"Next action: {next_action}")
print(f"Active branches: {len(active)} / {len(branches)}")
if subgoals:
print("Active subgoals:")
for item in subgoals:
print(f"- {item}")
return 0
def cmd_add_evidence(args: argparse.Namespace) -> int:
record: dict[str, Any] = {
"ts": utc_stamp(),
"type": args.type,
"status": args.status,
"note": args.note,
}
if args.branch:
record["branch"] = args.branch
append_evidence(record)
print("Appended evidence record.")
return 0
def cmd_add_branch(args: argparse.Namespace) -> int:
data = load_branches()
branches = data.setdefault("branches", [])
if any(branch.get("id") == args.id for branch in branches):
raise SystemExit(f"Branch '{args.id}' already exists.")
branches.append(
{
"id": args.id,
"hypothesis": args.hypothesis,
"status": "active",
"artifacts": args.artifact or [],
"notes": args.note or "",
}
)
save_branches(data)
print(f"Added branch '{args.id}'.")
return 0
def cmd_close_branch(args: argparse.Namespace) -> int:
data = load_branches()
for branch in data.get("branches", []):
if branch.get("id") != args.id:
continue
branch["status"] = args.status
if args.note:
branch["notes"] = args.note
save_branches(data)
print(f"Updated branch '{args.id}' to status '{args.status}'.")
return 0
raise SystemExit(f"Branch '{args.id}' was not found.")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect and update LocalCastBridge persistence state files."
)
subparsers = parser.add_subparsers(dest="command", required=True)
status_parser = subparsers.add_parser("status", help="Show a compact state summary.")
status_parser.set_defaults(func=cmd_status)
evidence_parser = subparsers.add_parser(
"add-evidence", help="Append one distilled JSONL evidence record."
)
evidence_parser.add_argument("--type", required=True)
evidence_parser.add_argument("--status", required=True)
evidence_parser.add_argument("--note", required=True)
evidence_parser.add_argument("--branch")
evidence_parser.set_defaults(func=cmd_add_evidence)
add_branch_parser = subparsers.add_parser(
"add-branch", help="Create a new active branch entry."
)
add_branch_parser.add_argument("--id", required=True)
add_branch_parser.add_argument("--hypothesis", required=True)
add_branch_parser.add_argument("--artifact", action="append")
add_branch_parser.add_argument("--note")
add_branch_parser.set_defaults(func=cmd_add_branch)
close_branch_parser = subparsers.add_parser(
"close-branch", help="Close an existing branch."
)
close_branch_parser.add_argument("--id", required=True)
close_branch_parser.add_argument(
"--status", required=True, choices=["accepted", "rejected", "archived"]
)
close_branch_parser.add_argument("--note")
close_branch_parser.set_defaults(func=cmd_close_branch)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())