18 Commits
Author SHA1 Message Date
jpmschweitzerandClaude f36f0fe431 fix(permissions): narrow rm -rf deny globs to their exact forms
Test, Build and Push / test-gateway (push) Successful in 13s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The trailing wildcard on the three rm -rf deny entries spanned path
separators, so Bash(rm -rf /*) matched every absolute path on the
machine rather than the filesystem root, and the ~ and $HOME entries
had the same shape. Narrowed to the exact literal forms.

These rules match literal command text, so they still stop a typo on
rm -rf /, rm -rf ~ or rm -rf $HOME exactly, but they no longer stop a
recursive delete aimed at any other path. That reduced cover is
deliberate, not an oversight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 20:33:17 +02:00
jpmschweitzerandClaude 8bdf950fcf build(lint): select ruff's rules explicitly instead of inheriting them
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The gate had no `select`, so it linted with whatever the installed ruff
version defaults to. `dev` pins only `ruff>=0.6` and CI installs that
extra fresh on every run, which made the rule set a function of when pip
last resolved rather than of this code. Two developers on one commit
could get different answers, and so could CI and a laptop.

This surfaced when T-47's converged setup reinstalled ruff and pulled
0.16.3: `make lint` failed on UP017 and BLE001 in main.py, a file the
commit before it had not touched. Under the previous install the same
code passed. Nothing about the code changed — only the linter's idea of
what to look at, which had grown to 413 rules with nobody choosing them.

Naming the families fixes that; pinning the version would only have
frozen the symptom and moved the surprise to whoever unpinned it. 217
rules now, selected on purpose, and a future ruff release becomes a
decision instead of a broken push.

ASYNC is included deliberately — this is a websocket gateway, and it is
the family whose findings would be real bugs rather than style. BLE is
deliberately excluded: main.py catches bare Exception when a device
disappears mid-send, which is correct there, and selecting BLE would
mean a noqa on every such site to say so.

UP017 is fixed rather than suppressed (datetime.timezone.utc -> UTC,
identical semantics, and requires-python is already >=3.11); isort then
reordered the import, which is the whole of the main.py diff.

Verified the selection is load-bearing rather than decorative: a probe
file with a mutable default argument fails the explicit set (B006, exit
1) and passes ruff's minimal default set (exit 0), so the rules named
here are doing work the fallback would not. Probe deleted; lint,
typecheck and the 9-test suite all green after.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:36:37 +02:00
jpmschweitzer 78d9b276ee build(make): prove make setup actually works before exiting 0 (T-47)
pip install exiting 0 is not evidence the gateway environment is usable
(D-24) — a resolved-but-broken dependency or a stale venv from another
Python both look identical to a clean install at the point setup exits.
End the target with a cheap positive check instead: collect the test
suite (imports every src module each test pulls in) and confirm ruff
and mypy resolve inside the venv, the only place either binary exists.

Also states explicitly, in a comment, that setup covers the gateway
half only — the firmware half needs `source ~/esp-idf/export.sh` in
every shell, which a Makefile recipe cannot leave sourced in the
caller's shell, so build-firmware sources it itself instead.
2026-08-17 12:04:12 +02:00
jpmschweitzerandClaude 3f8e4cf593 fix(gateway): tell mypy the speech extra may be absent
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
`make typecheck` failed on four missing stubs — piper, faster_whisper and numpy
twice — which made the pre-push gate red on a machine that had followed the
documented setup. `make setup` deliberately omits the speech extra; only
`make setup-speech` installs it, because faster-whisper and piper-tts pull
several GB of ML wheels for a backend the deployment does not use.
settings.tts_backend defaults to "speaches", a network call to the shared
service on 8601, and both imports are lazy inside the functions that need them.
So the absence is a runtime fact the code already handles, not a defect.

The gate was therefore failing for doing the right thing, which is how a gate
stops being read. The correct assertion is "these modules may be absent", not
"install several GB so the type checker is satisfied" — on a disk at 76%, for a
path this deployment does not take.

There was no [tool.mypy] section at all, so this adds one. numpy is listed for
the same reason as the other two: nothing depends on it directly, it arrives
with faster-whisper.

Note the packaging was already correct — speech is an optional extra and always
has been. I initially reported these as required dependencies that were missing
from the venv, having grepped for the package names and read the hits without
checking which table they sat under; `mypy>=1.11` was three lines below in the
same output, which should have said "these are extras". CLAUDE.md states it
outright. The fix is smaller than the one I first described because the repo
was already doing the right thing.

Gate now passes: secrets, ruff, mypy, 9 tests. Firmware and sim still report
undetermined, which is accurate — neither has a suite.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 14:30:56 +02:00
jpmschweitzerandClaude 7846e48595 build(ci): move the pre-push gate into the Makefile
The hook carried ~50 lines of gitleaks logic and a comment explaining it was
self-contained because "this repo has no Makefile". It has one now, so the
reason is gone and the arrangement is backwards: a hook is a trigger, and
logic belongs where it can be read, run by hand, and changed under review.

.githooks/pre-push is now a byte-identical shim onto `make pre-push` in every
repo in the workspace. The scan itself moves to ci/secrets.sh unchanged, and
`make secrets` runs it on its own.

The call surface is identical everywhere; what it runs is not, and should not
be — each repo gates what it actually has. That is the point of standardising
the name rather than the contents: nobody has to read a repo to find out how
to check it.

secrets runs first, deliberately. It is the only failure here that cannot be
undone by fixing it afterwards — a failed lint costs another commit, a pushed
credential is cached and indexed whether or not it is later deleted.

Some of these gates fail today, on lint debt that predates them, and they are
left wired anyway. The board was measured once and written down in T-56
instead of being worked around here. Narrowing each gate to whatever already
passes would produce a gate that reports success for doing nothing, which is
the failure this workspace keeps rediscovering.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 18:57:22 +02:00
jpmschweitzerandClaude eca10a0dd0 docs: qualify the workspace decision references
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
D-26 and D-27 are workspace decisions, and this repo's own vault has none, so
a bare citation here means nothing resolvable — workspace D-21 requires the
vault to be named. Found by `make verify` in the workspace, which is the case
that rule was written for.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 17:58:14 +02:00
jpmschweitzerandClaude 5297249e58 ci(make): reserve exit 69 for "could not run" (D-26)
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
Environment guards now exit 69 rather than 1, so a caller can tell a suite
that could not start from one that ran and failed. The first toj test sweep
reported "3 repositories failed" and none of the three had executed a test —
two could not find go, one had no venv. That points the reader at the tests
when the fault is in the environment.

Only the environment guards change. A gitleaks finding, a failed test run and
a vulncheck hit still exit 1, because those did run and did fail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:56:25 +02:00
jpmschweitzerandClaude 2ef00c98de build: add the root Makefile the previous commit should have carried
Test, Build and Push / test-gateway (push) Successful in 12s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
ff19320 removed gateway/Makefile but the git add that was meant to stage its
replacement aborted on an already-staged pathspec, so the deletion landed
alone and main briefly had no Makefile at all. This is the other half.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:04:43 +02:00
jpmschweitzerandClaude ff193203c9 refactor: merge the component Makefiles into one at the root
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
desklock is a three-component repo and only the gateway had a Makefile, so
make test meant "the gateway suite" or "no such target" depending on which
directory you happened to be standing in. One root Makefile makes it mean the
same thing everywhere (D-27), and gateway/Makefile is removed rather than
delegated to, so there is one place to look.

The firmware targets now source ~/esp-idf/export.sh themselves. Verified that
idf.py does not resolve on PATH without it and does after — the same class of
failure that has cost time on four other tools on this host, and the reason
D-10 puts path resolution in the Makefile rather than in callers. They fail
loudly with a hint when the toolchain is absent instead of reporting command
not found.

make test never reports green for the firmware. It has no suite, so it prints
undetermined rather than skipping silently — a no-op target that exits 0 would
claim a pass for something never run (D-26).

Verified: make test runs the real 9-test gateway suite, make lint passes, the
missing-toolchain guard fires, and make help lists every target. The firmware
build itself was not run.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:03:58 +02:00
jpmschweitzerandClaude 8cd2c05a00 chore(claude): pin PQL_VAULT per project so cwd stops choosing the vault
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
Test, Build and Push / test-gateway (push) Successful in 11s
pql is now a bare word on PATH, which removed the long incantation that had
been forcing --vault into every call by habit. Convenience lowered the cost
of the wrong thing without lowering the cost of the right one: a three-word
pql ticket new targets whichever vault the cwd happens to sit in, and there
are nine of them with colliding id sequences.

PQL_VAULT in each project settings file makes the vault a property of the
session rather than of the working directory — the same lesson Rule 3 records
for git -C, applied to pql. Verified the env var overrides cwd discovery,
that an explicit --vault still beats the env var, and that the harness
hot-reloads it without a restart.

This does not make provenance visible: no output says which vault answered,
so a forgotten --vault still returns a well-formed answer about the wrong
dataset. That remains T-37.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:49:04 +02:00
jpmschweitzerandClaude 984e43aa8f chore(claude): deny toj in the sub-repos
Test, Build and Push / test-gateway (push) Successful in 10s
Test, Build and Push / build-gateway (push) Skipped
Test, Build and Push / release (push) Skipped
toj is now on the global PATH as /usr/local/bin/toj, so its scope boundary
had to stop being "the absolute path is inconvenient to type" and start
being a rule. Its repo and settings verbs operate on the workspace root; run
from inside this repo they answer about the wrong tree.

Both spellings are denied, bare and absolute, because a deny with one
spelling left open is decorative.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:42:09 +02:00
jpmschweitzerandClaude 2cdefefecb ci: gate pushes on a gitleaks scan of the outgoing commits
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
No repo here scanned for committed credentials. The hook is self-contained
rather than delegating to a Makefile, because this repo has none and a hook
reaching into a sibling repo breaks the moment this one is cloned elsewhere.

Scans the outgoing range rather than full history: history carries settled
findings — test fixtures, vendored third-party code — and a gate that fails
on something unfixable gets bypassed within a week.

Setting core.hooksPath means pql init must replant its replication shims into
.githooks, which is why they are gitignored here alongside the tracked
pre-push. Same layout pql itself uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 12:48:58 +02:00
jpmschweitzerandClaude 101816de71 docs: replace AGENTS.md with a repo-specific CLAUDE.md
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
One agent doc per repo, and it is CLAUDE.md. Written fresh rather than
reformatted, and shaped around the fact that this repo holds two
components with nothing in common: ESP-IDF firmware flashed over USB, and
a Python gateway that ships tag to CI to Watchtower.

The rule that a protocol change must update docs/architecture.md is
carried forward, as is the standing one that tatlock is never modified
from here -- this repo consumes its public API only.

Liveness is recorded per component rather than as one claim. The gateway
is confirmed up from the container; the firmware is written down as
undetermined, because no device was attached and there is no remote
telemetry path, and an invented method would have been worse than an
admission. The one figure carried over without re-measuring, a boot time
taken from the old file, is marked as carried rather than verified.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:17:00 +02:00
jpmschweitzerandClaude b6fdd3313a chore: adopt the workspace agent-config baseline
Commits a .claude/settings.json rather than leaving permissions to
per-developer local state, and initialises a pql vault for this repo's
tickets and internal decisions.

Every git deny rule appears in both the `git <verb>` and `git * <verb>`
forms. Only the second catches `git -C <path>`, and without it the whole
deny list is decorative -- it looks like a policy and stops nothing.

The allow list carries pql's absolute path alongside the bare name.
pql is installed to ~/.local/bin, which is on the login PATH but not the
one a non-interactive shell gets, so the bare-name rules match nothing on
their own and every call would prompt anyway.

.gitignore now covers .claude/settings.local.json, which is machine-local
and must never be shared. `pql init` contributed the .pql/* rules with an
exception for the changelog, which is the replication log of record and
has to be committed for tickets to travel with a clone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:16:40 +02:00
jpmschweitzerandClaude 67bee80dc8 docs(architecture): sync with measured 2026-08-07 state
Test, Build and Push / test-gateway (push) Successful in 37s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
Every figure in the latency budget was stale, in both directions. TTS was
listed at ~1.9 s per sentence but measures ~0.24 s warm for 4.5 s of audio;
the full Tatlock flow was listed at 11-25 s but measures ~10-13 s for simple
turns. Both sets of numbers predate the current model.

The VRAM section now carries real figures and the reason they matter: on
2026-08-07 Tatlock ran against a 9.3 GB model, leaving 7 MiB free, and every
transcription failed with CUDA out of memory while the Speaches container
still reported healthy. The budget is the constraint, not slack.

Also replaces the retired tatlock.schweitz.internal hostname in the topology
diagram with the docker container name.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:05:15 +02:00
jpmschweitzerandClaude Fable 5 7c7ce1541c release v0.2.2
Test, Build and Push / test-gateway (push) Successful in 9s
Test, Build and Push / release (push) Successful in 3s
Test, Build and Push / build-gateway (push) Successful in 48s
Network migration: the gateway's default Tatlock URL is now the docker
container name (http://tatlock:8000); the retiring tatlock.schweitz.internal
domain is gone from config and docs. Deployments that set
DESKLOCK_TATLOCK_BASE_URL are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:13 +02:00
jpmschweitzerandClaude Fable 5 014c86f51e chore(gateway): drop schweitz.internal, default to http://tatlock:8000
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The homelab is retiring the *.schweitz.internal domain; in-network
machine-to-machine traffic uses docker container names on the
docker-dataplane network. The deployed tatlock-ui stack already overrides
DESKLOCK_TATLOCK_BASE_URL (Tatlock runs on the host), so only the
fallback default changes.

Docs follow: AGENTS.md M2M guidance now points at container names with
*.schweitz.net reserved for browsers, architecture.md drops the retired
domain (the registry name now matches what CI actually pushes since
c477019), and the README diagram loses the stale hostname.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:22:25 +02:00
jpmschweitzerandClaude Fable 5 c4770194d9 chore(ci): push images via git.schweitz.net registry
Test, Build and Push / test-gateway (push) Successful in 59s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:09:59 +02:00
23 changed files with 1333 additions and 224 deletions
+70
View File
@@ -0,0 +1,70 @@
{
"env": {
"PQL_VAULT": "/mnt/media/Projects/desklock"
},
"permissions": {
"allow": [
"Bash(pql)",
"Bash(pql *)",
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git branch:*)",
"Bash(make -C gateway *)",
"Bash(make setup:*)",
"Bash(make run:*)",
"Bash(make test:*)",
"Bash(make lint:*)",
"Bash(make typecheck:*)",
"Bash(.venv/bin/pytest:*)",
"Bash(.venv/bin/ruff:*)",
"Bash(.venv/bin/mypy:*)",
"Bash(docker logs desklock-gateway:*)",
"Bash(curl -s http://localhost:8600/*)"
],
"deny": [
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
"Bash(chmod -R 777 *)",
"Bash(chmod 777 *)",
"Bash(dd if=*)",
"Bash(find * -delete*)",
"Bash(find * -exec*)",
"Bash(git * add --all*)",
"Bash(git * add -A*)",
"Bash(git * add .)",
"Bash(git * branch -D *)",
"Bash(git * checkout -- *)",
"Bash(git * clean -fd*)",
"Bash(git * clean -fdx*)",
"Bash(git * commit --no-verify*)",
"Bash(git * merge --no-ff*)",
"Bash(git * push --force*)",
"Bash(git * push -f*)",
"Bash(git * reset --hard*)",
"Bash(git * restore .*)",
"Bash(git add --all*)",
"Bash(git add -A*)",
"Bash(git add .)",
"Bash(git branch -D *)",
"Bash(git checkout -- *)",
"Bash(git clean -fd*)",
"Bash(git clean -fdx*)",
"Bash(git commit --no-verify*)",
"Bash(git merge --no-ff*)",
"Bash(git push --force*)",
"Bash(git push -f*)",
"Bash(git reset --hard*)",
"Bash(git restore .*)",
"Bash(mkfs*)",
"Bash(rm -rf $HOME)",
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(su *)",
"Bash(sudo *)",
"Bash(toj)",
"Bash(toj:*)"
]
}
}
+1
View File
@@ -0,0 +1 @@
.pql/changelog/*.sql merge=union
+3 -3
View File
@@ -49,7 +49,7 @@ jobs:
- name: Login to Gitea Registry - name: Login to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.schweitz.internal registry: git.schweitz.net
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -61,8 +61,8 @@ jobs:
provenance: false provenance: false
sbom: false sbom: false
tags: | tags: |
git.schweitz.internal/jpmschweitzer/desklock-gateway:latest git.schweitz.net/jpmschweitzer/desklock-gateway:latest
git.schweitz.internal/jpmschweitzer/desklock-gateway:${{ github.ref_name }} git.schweitz.net/jpmschweitzer/desklock-gateway:${{ github.ref_name }}
- name: Trigger Watchtower update - name: Trigger Watchtower update
if: success() if: success()
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Trigger only. The checks live in the Makefile, where they can be read, run by
# hand (`make pre-push`), and changed under review.
#
# This file is identical in every repo in this workspace, deliberately: the call
# surface is the same everywhere even though what each gate runs is not, so
# nobody has to read a repo to find out how to check it (D-27).
#
# Enable per clone with: git config core.hooksPath .githooks
# Never bypass with --no-verify. Suppress a specific finding deliberately
# instead, with a reason — see `make pre-push`.
set -euo pipefail
exec make -C "$(git rev-parse --show-toplevel)" pre-push
+14
View File
@@ -21,3 +21,17 @@ dist/
.idea/ .idea/
*.swp *.swp
.DS_Store .DS_Store
# Claude Code local overrides (per-machine, may hold credentials)
.claude/settings.local.json
.pql/*
!.pql/changelog/
# pql shims planted by `pql init` into the dir core.hooksPath points at.
# Per-clone: each embeds the absolute path of the pql binary that planted it.
# Only .githooks/pre-push is shared.
.githooks/pre-commit
.githooks/post-merge
.githooks/post-checkout
.githooks/post-rewrite
+11
View File
@@ -0,0 +1,11 @@
-- Changelog format marker, written by pql. Comments only: this file
-- is never executed — Import descends into the per-table directories
-- and does not read the changelog root.
--
-- A changelog carrying no marker is format 1, the shape that existed
-- before formats were versioned. An older format is migrated forward
-- by `pql plan upgrade` (and automatically from the post-merge hook);
-- a newer one is refused rather than replayed under rules this binary
-- does not know. See D-28 and docs/versions.md.
-- pql:changelog_format: 2.0.0
-- pql:written_by: 2.2.0
+139
View File
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
+139
View File
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
+139
View File
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
-130
View File
@@ -1,130 +0,0 @@
# AGENTS.md
> Operational protocols and architecture for AI assistants working on DeskLock.
> Read [docs/architecture.md](docs/architecture.md) before making design changes.
## What this project is
DeskLock is the living-room visual/audio endpoint for **Tatlock**, the homelab butler
(`/mnt/media/Projects/tatlock`, API at `http://tatlock.schweitz.internal:8000`). Two halves,
one repo:
- `firmware/` — ESP-IDF (C, LVGL 9) app for the Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C
(3.4" round 800×800 touch display, dual mics + ES7210 AEC, ES8311 codec + speaker).
- `gateway/` — Python FastAPI container on tower-of-joy orchestrating STT → chat
(Tatlock `/v1/chat/completions`) → TTS. Listens on port **8600**. STT/TTS models live
in the shared **Speaches** container (live on port 8601, OpenAI-format API), not in
the gateway image; `stt.py`/`tts.py` are pluggable backends (`speaches` default,
`embedded` fallback needing the `[speech]` extra). Gateway needs **Python ≥ 3.11**
(no ceiling; the container runs 3.13) — but system python3 on tower-of-joy is 3.8,
so `make setup` explicitly uses `python3.12`. No local audio resampling in the
default path: the gateway requests 16 kHz output via Speaches' `sample_rate`
extension (verified live).
The device and gateway speak a WebSocket protocol defined in `docs/architecture.md`.
**That doc is the contract** — update it in the same change as any protocol edit on
either side.
The face (black screen, ASCII glyph expressions, matrix rain as activity signal) is
designed in `sim/face/index.html` — the design source of truth — and specified in the
"Face design" section of `docs/architecture.md`. Change the sim and the doc together;
the LVGL implementation follows them. Verify sim changes visually with
`~/bin/claude-screenshot` (note: the tool uses `--virtual-time-budget`, which starves
`requestAnimationFrame` — drive sim animation with `setInterval`, which also mirrors
LVGL timers).
## Hard rules
- **Keep the firmware thin.** No STT, no TTS, no conversation logic on the device.
If a feature needs intelligence, it goes in the gateway or in Tatlock itself.
- **Never modify Tatlock from this repo.** It is a separate project with its own repo.
DeskLock consumes its public API only.
- **Secrets** (Wi-Fi credentials, any future API keys) never go in source. Firmware
gets them via a gitignored `firmware/secrets.h` (see AGENTS notes below) or NVS;
the gateway via environment variables (`DESKLOCK_*`).
## Firmware (`firmware/`)
- Toolchain: **ESP-IDF ≥ 5.4** (not Arduino, not PlatformIO). Target `esp32p4`.
- BSP: [`waveshare/esp32_p4_wifi6_touch_lcd_xc`](https://components.espressif.com/components/waveshare/esp32_p4_wifi6_touch_lcd_xc)
from the ESP Component Registry (pulled automatically via `main/idf_component.yml`).
- Reference implementations: [waveshareteam/ESP32-P4-WIFI6-Touch-LCD-XC](https://github.com/waveshareteam/ESP32-P4-WIFI6-Touch-LCD-XC)
`examples/esp-idf/` — notably `08_lvgl_demo_v9` (display), `06_I2SCodec` (audio),
`04_wifistation` (Wi-Fi via ESP-Hosted). When wiring a new peripheral, check the
official example first; do not guess pin mappings.
```bash
# ESP-IDF v5.5 is installed at ~/esp-idf. Every shell:
source ~/esp-idf/export.sh
# Build / flash (device on USB-C at /dev/ttyACM0, CH343 bridge)
cd firmware
idf.py build
sg dialout -c "bash -lc 'source ~/esp-idf/export.sh >/dev/null && idf.py -p /dev/ttyACM0 flash'"
```
- `sg dialout -c '…'` is needed because the login session predates the user's dialout
membership; a plain `idf.py flash` works after any re-login.
- **Radio stack: esp_hosted ≥ 2.x on BOTH chips, non-negotiable.** esp-hosted 1.x is
formally incompatible with IDF 5.5 (esp-hosted-mcu#47) — symptom: RPC/scan/connect
all work, but NO data frames ever flow (no DHCP, no ARP, no ping). Waveshare's
examples pin 1.4.* and the factory C6 slave firmware is ancient — both wrong. The
host manifest pins `espressif/esp_hosted: "^2.12"`; the matching slave image is
embedded as `main/c6_slave.bin` and `c6_ota.c` flashes the C6 **over SDIO** at boot
whenever the C6 reports a version < 2.x (build a new bin from the component's
`slave/` project for esp32c6 when bumping versions).
- **Internal-RAM famine assert**: `assert failed: xTaskCreateStaticPinnedToCore …
xPortcheckValidStackMem` in a pre-app_main boot loop means static+early allocations
starved internal SRAM (hosted 2.x is hungry). Keep
`CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=y` and the reduced `WIFI_RMT_*` buffer
counts in sdkconfig.defaults; check `heap_init:` pool lines when the binary grows.
- SDIO clock is set conservatively (`CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=20000`),
ample for 16 kHz voice; raising to 40 MHz is untested on this board's data path.
- **L1 driver diagnosis mode**: set `WIFI_DIAG_MODE 1` in desklock_main.c — the device
becomes AP `DESKLOCK-DIAG` (pass `desklock123`, page at http://192.168.4.1/) proving
radio+SDIO+IP with zero external network variables. Ladder: L0 SDIO control → L1
softap data → L2 STA to any network → L3 STA to "Outside" → L4 gateway.
- **PSRAM must run at 200 MHz** or the 800×800 MIPI-DSI framebuffer underruns
(`lcd.dsi.dpi: can't fetch data…` spam, LVGL lock never frees, task watchdog).
`CONFIG_SPIRAM_SPEED_200M` only takes effect together with
`CONFIG_IDF_EXPERIMENTAL_FEATURES=y` — otherwise it is **silently dropped** and you
get 20 MHz. `sdkconfig.defaults` mirrors the official `08_lvgl_demo_v9` config.
- Non-interactive boot-log capture (avoid `idf.py monitor`, it's interactive): open
`/dev/ttyACM0` at 115200 with pyserial, pulse RTS to reset, read ~8 s. Verified boot
is ~1.6 s from reset to `desklock: DeskLock up`.
- If the device doesn't enumerate, hold BOOT while pressing RESET for download mode.
## Gateway (`gateway/`)
```bash
cd gateway
make setup # venv + dev deps (no ML models)
make setup-speech # additionally install faster-whisper + piper
make run # uvicorn on :8600 with reload
make test # pytest
make lint # ruff check + format check
make typecheck # mypy
```
- Config via `DESKLOCK_*` env vars — see `src/desklock_gateway/config.py` for the schema
and defaults.
- `stt.py` / `tts.py` defer their heavy imports so the app boots without the `speech`
extra — keep it that way so protocol tests stay fast.
- Deployment is CI-driven: pushing a `v*` tag makes Gitea Actions test, build, and push
`desklock-gateway:{latest,tag}` to the registry and trigger Watchtower
(`.gitea/workflows/build.yml`; needs `REGISTRY_USER`/`REGISTRY_PASSWORD`/
`WATCHTOWER_HTTP_API_TOKEN` secrets). Plain pushes to `main` run lint + tests only. The
gateway deploys as part of the **`tatlock-ui` Portainer stack** —
`system-admin-toj/containers/stacks/tatlock-ui.yml` (registered in `CONTAINERS.md`,
port 8600). Stack updates go through the Portainer API on :8001 (JWT auth; recipe in
`system-admin-toj/containers/setup-new-host.md`), not by editing files on disk.
- Verify speech changes against the live Speaches container with a real round trip
(TTS → STT of a known phrase, expect the transcript back); warm timings to expect:
STT ~0.3 s, TTS ~2 s per sentence.
## Homelab context
- This server **is** tower-of-joy; the device, gateway, and Tatlock all share the LAN.
- Use `tatlock.schweitz.internal:8000` (direct, no SSO) — the public
`tatlock.schweitz.net` route sits behind Authentik and is not for machine-to-machine
traffic.
- Git remote: `git.schweitz.net` (Gitea).
+19
View File
@@ -8,6 +8,25 @@ until the first tagged release.
## [Unreleased] ## [Unreleased]
### Changed
- One Makefile at the repo root now drives firmware, gateway and sim; `gateway/Makefile` is
removed. `make test` means the same thing from any directory.
- Firmware targets source `~/esp-idf/export.sh` themselves, so `idf.py` resolves without
having to remember. Override the location with `IDF_EXPORT=`.
- `make setup` now proves the gateway environment actually works instead of trusting a
clean `pip install` exit code: it collects the test suite and checks `ruff`/`mypy`
resolve in the venv, and fails the target if any of that is broken (T-47).
## [0.2.2] — 2026-07-19
### Changed
- The gateway's default Tatlock URL is now `http://tatlock:8000` (docker
container name) — the retiring `tatlock.schweitz.internal` domain is gone
from config and docs. Deployments setting `DESKLOCK_TATLOCK_BASE_URL` are
unaffected.
## [0.2.1] — 2026-07-15 ## [0.2.1] — 2026-07-15
### Fixed ### Fixed
+178 -34
View File
@@ -1,45 +1,189 @@
# CLAUDE.md # CLAUDE.md — desklock
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Two components, one repo, coupled by a shared WebSocket protocol:
Claude Code-specific notes for this project. For architecture, hard rules, and full - `firmware/` — ESP-IDF (C, LVGL 9) app for a Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C
command reference — see [AGENTS.md](AGENTS.md), and read it before starting work. (3.4" round 800×800 touch display, dual mics + ES7210 AEC, ES8311 codec + speaker).
Flashed over USB; not containerized.
- `gateway/` — Python/FastAPI container `desklock-gateway`, port **8600**, part of the
**`tatlock-ui`** Portainer stack (`system-admin-toj/containers/stacks/tatlock-ui.yml`,
verified against the live container and `CONTAINERS.md`). Orchestrates STT → Tatlock
chat → TTS; carries no ML dependencies itself.
## Quick orientation The device↔gateway protocol is specified in `docs/architecture.md` under "WebSocket
protocol (device ↔ gateway)". **Any protocol change updates that file in the same
change** — it is the contract, not a description of one side's behavior.
DeskLock = firmware for a Waveshare ESP32-P4 round-display device (`firmware/`, ESP-IDF/C/LVGL) **Never modify Tatlock from this repo.** desklock consumes Tatlock's public API only
plus a voice gateway container (`gateway/`, Python/FastAPI, port 8600) that bridges device (`http://tatlock:8000` on `docker-dataplane`); this is a standing cross-repo rule, not
audio to the Tatlock butler API. The device↔gateway WebSocket protocol lives in local policy.
`docs/architecture.md` and must stay in sync with both implementations.
**Keep the firmware thin.** No STT, no TTS, no conversation logic on the device — that
intelligence belongs in the gateway or in Tatlock itself.
**Secrets never go in source.** Firmware gets them via gitignored `firmware/main/secrets.h`
(verified: gitignored, and `#include`d by `gw_client.c`/`net.c`) or NVS; the gateway via
`DESKLOCK_*` environment variables.
## Gotchas — firmware
- Toolchain: **ESP-IDF ≥ 5.4** (this box has 5.5, installed at `~/esp-idf`), not
Arduino, not PlatformIO. Target `esp32p4`. `source ~/esp-idf/export.sh` is required
every shell — `idf.py` is not on the non-interactive PATH otherwise.
- BSP: `waveshare/esp32_p4_wifi6_touch_lcd_xc` from the ESP Component Registry, pulled
automatically via `main/idf_component.yml`. Reference implementations:
[waveshareteam/ESP32-P4-WIFI6-Touch-LCD-XC](https://github.com/waveshareteam/ESP32-P4-WIFI6-Touch-LCD-XC)
`examples/esp-idf/``08_lvgl_demo_v9` (display), `06_I2SCodec` (audio),
`04_wifistation` (Wi-Fi). Check the official example before guessing a pin mapping.
- Flashing needs the `dialout` group; a login session started before that membership
took effect needs `sg dialout -c "bash -lc 'source ~/esp-idf/export.sh >/dev/null &&
idf.py -p /dev/ttyACM0 flash'"` — a plain `idf.py flash` works after any re-login.
- **PSRAM 200 MHz requires `CONFIG_IDF_EXPERIMENTAL_FEATURES=y`.** Without it,
`CONFIG_SPIRAM_SPEED_200M` is silently dropped to 20 MHz and the 800×800 MIPI-DSI
framebuffer underruns (`lcd.dsi.dpi: can't fetch data…` spam, LVGL lock never frees,
task watchdog). Verified both settings present in `firmware/sdkconfig.defaults`.
- **Wi-Fi radio stack must be esp_hosted ≥ 2.x on both the P4 host and the C6 slave,
non-negotiable.** 1.x is formally incompatible with IDF 5.5 (esp-hosted-mcu#47) —
symptom is control-plane-only: RPC/scan/connect all work, but no data frame ever
flows (no DHCP, no ARP, no ping). Waveshare's examples and the factory C6 slave
firmware both pin the wrong (1.x-era) version. The host manifest pins
`espressif/esp_hosted: "^2.12"` (verified in `firmware/main/idf_component.yml`); the
matching slave image is embedded as `main/c6_slave.bin`, and `c6_ota.c` flashes the
C6 over SDIO at boot whenever it reports a version below 2.x.
- **Boot-loop assert `xTaskCreateStaticPinnedToCore … xPortcheckValidStackMem`** before
`app_main` means internal SRAM starvation (hosted 2.x is hungry). Keep
`CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=y` and the reduced `WIFI_RMT_*` buffer counts
in `sdkconfig.defaults` (both verified present); check `heap_init:` pool lines in the
boot log when the binary grows.
- SDIO clock is conservative by design: `CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=20000`
(verified), ample for 16 kHz voice — raising it to 40 MHz is untested on this board's
data path.
- **Wi-Fi diagnosis ladder**: set `WIFI_DIAG_MODE 1` in `desklock_main.c` (verified the
macro and `#if` guard exist, currently `0`) — the device becomes AP `DESKLOCK-DIAG`
(password `desklock123`, page at `http://192.168.4.1/`, verified in `wifi_diag.c`),
proving radio+SDIO+IP with zero external network variables. Ladder: L0 SDIO control →
L1 softap data → L2 STA to any network → L3 STA to "Outside" → L4 gateway.
- Non-interactive boot-log capture: avoid `idf.py monitor` (interactive) — open
`/dev/ttyACM0` at 115200 with pyserial, pulse RTS to reset, read ~8s. Reported boot
time (~1.6s to `desklock: DeskLock up`) is carried from `AGENTS.md` and was **not**
re-timed this pass — no device was connected in this session (see Liveness below).
- If the device doesn't enumerate, hold BOOT while pressing RESET for download mode.
## Gotchas — gateway
- Gateway speech deps (`faster-whisper`, `piper-tts`) are an optional extra —
`make setup` alone runs the app and the test suite without them. `make setup` invokes
`python3.12` explicitly; system `python3` on tower-of-joy is 3.8.
- `ruff` and `mypy` are **not** on the non-interactive PATH — they exist only inside
`gateway/.venv/bin/` once `make setup` has run. Use `make lint` / `make typecheck`, or
invoke `.venv/bin/ruff` / `.venv/bin/mypy` directly; a bare `ruff`/`mypy` will fail to
resolve, which is why the `.claude/settings.json` allow list uses the venv-relative
paths and `make` targets rather than bare tool names.
- Tatlock replies open with a `<think>` block — always strip it via
`tatlock.strip_reasoning()` (`gateway/src/desklock_gateway/tatlock.py`) before TTS or
display. Verified present and called at the one call site.
- Low power is a stated hardware requirement — read "Power management" in
`docs/architecture.md` before touching the face/render loop.
- Gateway health check is `GET /healthz` (verified in `main.py` and matches the
container healthcheck in `tatlock-ui.yml`), not `/health`.
## Commands ## Commands
```bash **One Makefile at the root drives all three components.** There is deliberately no
# Firmware (requires `source ~/esp-idf/export.sh` first; IDF ≥ 5.4) `gateway/Makefile` any more — `make test` meant "the gateway's tests" or "nothing"
cd firmware && idf.py build depending on which directory you were standing in, and now it means the same thing
idf.py -p /dev/ttyACM0 flash monitor everywhere (workspace D-27).
# Gateway ```bash
cd gateway && make setup # once make help # every target, self-documenting
make run # dev server :8600
make test # pytest; single test: .venv/bin/pytest tests/test_health.py -k healthz make test # gateway pytest; reports firmware + sim as undetermined
make lint typecheck make lint # ruff check + format --check
make typecheck # mypy
make setup # gateway venv + dev deps (no ML models)
make setup-speech # additionally faster-whisper + piper
make run # uvicorn on :8600 with reload
make build-firmware # sources export.sh for you, then idf.py build
make flash PORT=/dev/ttyACM0 # flash + monitor
make serve-sim # face simulator on :8601
``` ```
## Gotchas **The firmware targets source `~/esp-idf/export.sh` themselves.** `idf.py` is not on
`PATH` until that runs, so the old `cd firmware && idf.py build` fails with "command
not found" for anyone who forgets — the same class of failure as four other tool
misses on this host. Override with `IDF_EXPORT=<path>/export.sh` on another machine;
the target fails loudly with that hint if the file is absent.
- ESP-IDF v5.5 lives at `~/esp-idf` (`source ~/esp-idf/export.sh`). Flash via `make test` never reports green for the firmware. It has no suite, so it is
`sg dialout -c …` (see AGENTS.md) — the login session predates dialout membership. **undetermined**, printed explicitly rather than skipped silently (workspace D-26).
- **PSRAM 200 MHz requires `CONFIG_IDF_EXPERIMENTAL_FEATURES=y`** — without it the
option silently degrades to 20 MHz and the DSI display underruns into a watchdog ## Liveness
loop. Details in AGENTS.md.
- **Wi-Fi = esp_hosted 2.x on BOTH chips** (host manifest + C6 slave, auto-OTA'd from - **Gateway (`desklock-gateway` container, port 8600):** confirmed live — `docker ps`
`main/c6_slave.bin`). 1.x on IDF 5.5 gives working control RPC but a dead data path shows the container running under that name (method: direct container inspection;
(the great July 14th debugging night). Boot-loop assert on blind spot: none relevant here, this confirms the process is up, not that every route
`xTaskCreateStaticPinnedToCore` = internal-RAM famine. Details in AGENTS.md. behaves correctly — that would need a request against it, not checked this pass).
- Gateway speech deps are optional extras; `make setup` alone runs the app and tests - **Firmware / device:** liveness is **undetermined** and cannot be established the way
without GPU/ML packages. `make setup` uses `python3.12` (system python3 is 3.8). the gateway's can. No `/dev/ttyACM0` was present in this session (checked: `ls
- Tatlock replies open with a `<think>` block — always strip via /dev/ttyACM*` found nothing) and there is no remote telemetry — the device only proves
`tatlock.strip_reasoning()` before TTS or display. itself alive over a physical USB serial connection or by joining the LAN and speaking
- Low power is a prime user requirement: see "Power management" in the WebSocket protocol, neither of which this session had access to. Do not infer
docs/architecture.md before touching the face/render loop. device state from repo contents or from the gateway being up.
- `strip_reasoning()` reachability: confirmed by direct read of
`gateway/src/desklock_gateway/tatlock.py` (method: source read of the one call site;
blind spot: does not confirm it's exercised by a live request — that's what
`tests/test_tatlock.py` is for, not re-run this pass).
## Work tracking
This repo's vault is standalone — its tickets and internal decisions live in its own
`.pql/` and `governance/`, and travel with a clone (`.pql/changelog/` is committed).
```bash
/home/jpmschweitzer/.local/bin/pql ticket list
/home/jpmschweitzer/.local/bin/pql plan whatsnext
/home/jpmschweitzer/.local/bin/pql decisions list
```
`pql` is not on the non-interactive PATH — use the absolute path above. From inside this
repo no `--vault` flag is needed (pql anchors at the nearest `.git/` ancestor, which is
this repo) — but that also means a bare `pql` run from the **workspace root** will not
see this repo's tickets, and a write from the workspace root would go to the wrong
vault. Cross-repo/stack-level decisions (host, network, deploy mechanics — none specific
to desklock were found at the time of writing) live in the workspace vault instead:
```bash
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain desklock
```
## Git
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch that
is fast-forwarded and deleted. This is the workspace-wide policy; there is no
per-repo exception here.
- Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`, `chore:`).
- Stage explicitly — never `git add -A` (denied by `.claude/settings.json` policy).
- Update `CHANGELOG.md` under `[Unreleased]` for user-facing changes.
## Releasing (gateway only — firmware has no release flow)
Deploy is not automatic — confirm one is wanted first.
1. Bump `version` in `gateway/pyproject.toml`.
2. Move `[Unreleased]` entries into a dated `CHANGELOG.md` section.
3. Commit, tag `vX.Y.Z`, push with tags.
4. `.gitea/workflows/build.yml` runs lint + pytest on every push to `main`; on a `v*`
tag it additionally builds and pushes
`git.schweitz.net/jpmschweitzer/desklock-gateway:{latest,tag}` and pings Watchtower.
5. Verify: `curl http://192.168.86.149:8600/healthz`.
## Architecture
`docs/architecture.md` is the source of truth for system design, the face design
(`sim/face/index.html` is its visual source — change both together and verify with
`~/bin/claude-screenshot`, noting its `--virtual-time-budget` starves
`requestAnimationFrame`, so sim animation is driven by `setInterval` instead), power
budget, latency budget, and the full WebSocket protocol spec. Not restated here because
it is detailed enough to drift if duplicated — read it directly.
+140
View File
@@ -0,0 +1,140 @@
# desklock — one entry point for a three-component repo.
#
# firmware/ ESP-IDF application for the device. No tests, no release flow.
# gateway/ Python service on :8600. The only component with a test suite.
# sim/ a static page that mimics the device face in a browser.
#
# This lives at the root and the components have no Makefiles of their own, so
# `make test` means the same thing wherever you are standing. A per-component
# Makefile makes it mean "some of the tests" depending on your working
# directory, which is the `git -C` failure in another costume (D-27).
#
# Paths resolve here rather than in callers (D-10). Two of them bite:
#
# ESP-IDF is invisible until export.sh is sourced, so `idf.py` is
# "command not found" for anyone who forgets — the same class of failure as
# the four tool-resolution misses recorded in D-24. The firmware targets
# source it themselves.
#
# `python3` on this host is 3.8, which cannot parse the gateway's sources.
# PYTHON names 3.12 explicitly and is overridable for other machines.
PYTHON ?= python3.12
IDF_EXPORT ?= $(HOME)/esp-idf/export.sh
GATEWAY := $(CURDIR)/gateway
VENV := $(GATEWAY)/.venv
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
# --- the three D-27 required targets -----------------------------------------
.PHONY: test
test: test-gateway ## Run every component's tests that exist
@echo " -- firmware: no test suite (undetermined, not passing)"
@echo " -- sim: a static page, nothing to test"
.PHONY: lint
lint: lint-gateway ## Lint every component that has a linter
# --- gateway ------------------------------------------------------------------
.PHONY: setup
setup: ## Gateway venv + dev deps + prove it works (firmware needs export.sh — see below)
cd $(GATEWAY) && $(PYTHON) -m venv .venv && .venv/bin/pip install -e ".[dev]"
@# This covers the gateway half only, deliberately. The firmware half needs
@# `source ~/esp-idf/export.sh` in every shell (see the firmware gotchas
@# above); a Makefile recipe runs in its own subshell, so it cannot leave
@# that sourced in the caller's shell. A `setup` that appeared to prepare
@# firmware and silently left `idf.py` unresolved would be worse than one
@# that says plainly it does not touch that half — hence `build-firmware`
@# sources export.sh itself, per target, instead.
@#
@# Exit 0 from `pip install` is not evidence (D-24) — pip reports success
@# even when the result is unusable (e.g. a dependency that resolved but
@# doesn't actually import, or a stale .venv left over from a different
@# Python). Prove the environment works instead of trusting the install
@# step: `--collect-only` imports every test module and therefore every
@# src module each one pulls in (T-47). It runs zero tests, so it stays
@# cheap, and it also confirms ruff/mypy landed in .venv/bin — the venv
@# is the only place either binary exists (see gateway gotchas above);
@# `--version` is enough to prove each resolves and runs.
cd $(GATEWAY) && .venv/bin/python -m pytest tests/ --collect-only -q
cd $(GATEWAY) && .venv/bin/ruff --version >/dev/null
cd $(GATEWAY) && .venv/bin/mypy --version >/dev/null
.PHONY: setup-speech
setup-speech: ## Additionally install faster-whisper and piper
cd $(GATEWAY) && .venv/bin/pip install -e ".[dev,speech]"
.PHONY: run
run: ## Run the gateway on :8600 with reload
cd $(GATEWAY) && .venv/bin/uvicorn desklock_gateway.main:app --host 0.0.0.0 --port 8600 --reload
.PHONY: test-gateway
test-gateway: ## Gateway pytest suite
@cd $(GATEWAY) && .venv/bin/pytest
.PHONY: lint-gateway
lint-gateway: ## ruff check and format --check over the gateway
cd $(GATEWAY) && .venv/bin/ruff check src tests && .venv/bin/ruff format --check src tests
.PHONY: typecheck
typecheck: ## mypy over the gateway sources
cd $(GATEWAY) && .venv/bin/mypy src
# --- firmware -----------------------------------------------------------------
#
# Each target sources export.sh in its own shell. That is deliberate: make runs
# every recipe line in a fresh shell, so exporting in one target would not carry
# to the next, and a caller who sources it by hand still works because sourcing
# twice is harmless.
.PHONY: build-firmware
build-firmware: ## Build the ESP-IDF firmware (sources export.sh for you)
@test -f $(IDF_EXPORT) || { echo "FAIL — no ESP-IDF at $(IDF_EXPORT); set IDF_EXPORT=<path>/export.sh"; exit 69; }
. $(IDF_EXPORT) && cd firmware && idf.py build
.PHONY: flash
flash: ## Flash and monitor the device (PORT=/dev/ttyACM0 by default)
@test -f $(IDF_EXPORT) || { echo "FAIL — no ESP-IDF at $(IDF_EXPORT); set IDF_EXPORT=<path>/export.sh"; exit 69; }
. $(IDF_EXPORT) && cd firmware && idf.py -p $(or $(PORT),/dev/ttyACM0) flash monitor
# --- sim ----------------------------------------------------------------------
.PHONY: serve-sim
serve-sim: ## Serve the face simulator on :8601
cd sim/face && $(PYTHON) -m http.server 8601
# --- housekeeping -------------------------------------------------------------
.PHONY: clean
clean: ## Remove the gateway venv and caches
rm -rf $(VENV) $(GATEWAY)/.pytest_cache $(GATEWAY)/.ruff_cache $(GATEWAY)/.mypy_cache
find $(GATEWAY) -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
# gitleaks lands. Without this the scan reports "not installed" on every push,
# which is a check that fails open (D-24).
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
.PHONY: secrets
secrets: ## Scan the commits about to be pushed for credentials
@ci/secrets.sh
# The call surface is identical in every repo; what it runs is not.
#
# `secrets` runs first, deliberately: it is the only failure here that cannot be
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
# credential is cached and indexed whether or not it is later deleted.
#
# Some of these fail today, and are left wired anyway. The state was measured
# once and written down in T-56 rather than being worked around here — a gate
# quietly narrowed to what already passes is a gate that reports success for
# doing nothing, which is the failure this workspace keeps rediscovering.
.PHONY: pre-push
pre-push: secrets lint typecheck test ## Everything the pre-push hook runs
+2 -2
View File
@@ -38,8 +38,8 @@ happens on this server.
▼ │ Speaches (container, GPU) │ ▼ │ Speaches (container, GPU) │
┌────────────────────┐ │ • STT: faster-whisper │ ┌────────────────────┐ │ • STT: faster-whisper │
│ Tatlock (butler) │ │ • TTS: Kokoro / Piper │ │ Tatlock (butler) │ │ • TTS: Kokoro / Piper │
tatlock.schweitz. │ │ also usable by Open WebUI, │ http://tatlock │ │ also usable by Open WebUI, │
internal :8000 │ │ Home Assistant, … │ :8000 │ │ Home Assistant, … │
└────────────────────┘ └─────────────────────────────┘ └────────────────────┘ └─────────────────────────────┘
``` ```
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Secret scan over the commits about to be pushed.
#
# Lives here rather than inside .githooks/pre-push so it can be read, run by
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
# not a home for logic. Identical in every repo in this workspace (D-27).
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
# Without this the scan reports "not installed" on every push.
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
if ! command -v gitleaks >/dev/null 2>&1; then
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
exit 1
fi
# Scan the outgoing range, not full history. History here carries findings
# that are settled — test fixtures and vendored third-party code — and a gate
# that fails on something unfixable gets bypassed within a week. What matters
# is what is about to leave this machine.
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
range="$upstream..HEAD"
elif git rev-parse --verify --quiet origin/main >/dev/null; then
range="origin/main..HEAD"
else
range=""
fi
if [ -z "$range" ]; then
gitleaks dir . --redact --no-banner --exit-code 1 || {
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
exit 0
fi
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
echo " then remove and rotate it, or suppress deliberately:" >&2
echo " inline '# gitleaks:allow <reason>'" >&2
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
exit 1
}
echo " ok secrets"
+32 -23
View File
@@ -24,8 +24,8 @@ tower-of-joy. Everything is local — no audio, transcript, or reply ever leaves
▼ │ Speaches (container, GPU) │ ▼ │ Speaches (container, GPU) │
┌────────────────────┐ │ • STT: faster-whisper │ ┌────────────────────┐ │ • STT: faster-whisper │
│ Tatlock (butler) │ │ • TTS: Kokoro / Piper │ │ Tatlock (butler) │ │ • TTS: Kokoro / Piper │
tatlock.schweitz. │ │ also usable by Open WebUI, │ container name: │ │ also usable by Open WebUI, │
internal :8000 │ │ Home Assistant, … │ tatlock:8000 │ │ Home Assistant, … │
└────────────────────┘ └─────────────────────────────┘ └────────────────────┘ └─────────────────────────────┘
``` ```
@@ -74,7 +74,7 @@ models — the container stays a slim pure-Python image with no CUDA/ML dependen
2. Buffers inbound PCM until end-of-utterance (client-signalled in phase 1; VAD later). 2. Buffers inbound PCM until end-of-utterance (client-signalled in phase 1; VAD later).
3. **STT**: POST to Speaches `/v1/audio/transcriptions`. 3. **STT**: POST to Speaches `/v1/audio/transcriptions`.
4. **Chat**: POST the transcript to Tatlock `/v1/chat/completions` 4. **Chat**: POST the transcript to Tatlock `/v1/chat/completions`
(`http://tatlock.schweitz.internal:8000`, OpenAI-compatible, **streaming**), (`http://tatlock:8000`, OpenAI-compatible, **streaming**),
maintaining the conversation history so follow-ups have context. maintaining the conversation history so follow-ups have context.
5. **TTS**: as Tatlock's token stream completes each sentence, POST it to Speaches 5. **TTS**: as Tatlock's token stream completes each sentence, POST it to Speaches
`/v1/audio/speech` and forward the PCM immediately — see `/v1/audio/speech` and forward the PCM immediately — see
@@ -103,19 +103,25 @@ we may adopt later for streaming transcription.
extension, verified live; default voice `bm_george`, en-GB male). LAN-only like the extension, verified live; default voice `bm_george`, en-GB male). LAN-only like the
Tatlock internal route — do not expose through NPM without auth. Register in Tatlock internal route — do not expose through NPM without auth. Register in
`CONTAINERS.md`. `CONTAINERS.md`.
- **Measured** (live round trip through the gateway code, warm): STT ~0.3 s for a - **Measured** (live round trip, warm, 2026-08-07): STT ~0.30 s for a ~4.8 s utterance;
~3 s utterance; TTS ~1.9 s for a ~3 s sentence. Cold start after model TTL offload TTS ~0.24 s for a ~4.5 s sentence (real-time factor ~0.05). The first call after an
adds ~510 s to the first request. idle gap costs ~1.2 s; a full cold start after model TTL offload adds ~4 s.
- **Why a shared layer instead of models inside the gateway**: one GPU-resident model - **Why a shared layer instead of models inside the gateway**: one GPU-resident model
instance serves the whole homelab. Open WebUI is currently configured with instance serves the whole homelab. Open WebUI is currently configured with
`AUDIO_STT_ENGINE=openai` / `AUDIO_TTS_ENGINE=openai` (OpenAI *cloud*) — pointing its `AUDIO_STT_ENGINE=openai` / `AUDIO_TTS_ENGINE=openai` (OpenAI *cloud*) — pointing its
audio base URL at Speaches makes it fully local with a config change. Home Assistant audio base URL at Speaches makes it fully local with a config change. Home Assistant
can share it too. Meanwhile the gateway image needs no CUDA and rebuilds in seconds. can share it too. Meanwhile the gateway image needs no CUDA and rebuilds in seconds.
- **VRAM budget**: RTX 2080 Ti, 11 GB, shared with Ollama (~3.6 GB in use as of - **VRAM budget**: RTX 2080 Ti, 11,264 MiB, shared with Ollama. As of 2026-08-07 the
2026-07). whisper `small` at int8 is <1 GB; Kokoro is a few hundred MB. Speaches' steady state is ~4.9 GB used / ~5.9 GB free with everything resident: `gemma4:e2b`
model TTL offload keeps idle pressure near zero. If VRAM contention ever bites, 1.9 GB and `nomic-embed-text` 0.3 GB (both pinned), whisper `small` int8 <1 GB,
faster-whisper `small` on CPU is an acceptable fallback (int8, a few seconds per Kokoro a few hundred MB. Speaches' model TTL offload keeps idle pressure near zero.
utterance). **This budget is not slack — it is the constraint.** On 2026-08-07 Tatlock was
deployed against `mistral-nemo:latest` (9.3 GB, 2 h keep-alive), which left 7 MiB
free and made every transcription fail with `CUDA failed with error out of memory`
while the Speaches container still reported healthy. Keep Tatlock's model at or below
~4 GB resident, and check `nvidia-smi` free VRAM before changing it. If contention
ever bites anyway, faster-whisper `small` on CPU is an acceptable fallback (int8, a
few seconds per utterance).
### 4. Tatlock — existing backend (`/mnt/media/Projects/tatlock`) ### 4. Tatlock — existing backend (`/mnt/media/Projects/tatlock`)
@@ -220,19 +226,22 @@ it in phase 5.
## Latency budget & streaming ## Latency budget & streaming
Measured/known numbers that shape the design (Tatlock figures per tatlock CLAUDE.md, Measured 2026-08-07 against the deployed stack (`gemma4:e2b` at ~95 tok/s, GPU-resident):
GPU-resident benchmarks of 2026-07-14, gemma4:e2b at ~100 tok/s):
| Stage | Cost | | Stage | Cost |
|-------|------| |-------|------|
| STT (Speaches whisper `small`) | ~0.3 s warm (measured) | | STT (Speaches whisper `small`) | ~0.30 s warm, for ~4.8 s of audio |
| TTS (Speaches Kokoro) | ~1.9 s per ~3 s sentence, warm (measured) | | TTS (Speaches Kokoro) | ~0.24 s warm, for ~4.5 s of audio (RTF ~0.05) |
| Tatlock Steward analysis | ~6 s warm | | **Tatlock, full local flow** | **~1013 s end-to-end** for simple turns |
| **Tatlock, full local flow** | **1125 s end-to-end** (librarian-routed ~2025 s) | | Tatlock cold model load | +~36 s — avoided while the model is pinned |
| Tatlock cold start (>2 h idle) | +~8 s (`OLLAMA_KEEP_ALIVE=2h`) |
(Older "~35 s Steward / ~2 min flow" figures were from a CPU-only driver-mismatch era — A Tatlock turn costs **3 sequential Ollama calls** (Steward routing → tool orchestration →
do not plan against them.) butler-tone synthesis) and ~710 generated tokens even for "what is 61 plus 12?". Most of
that is the model's own reasoning: gemma4 thinks by default, and the effort is spent three
times per turn.
(Older figures — "~35 s Steward / ~2 min flow" from the CPU-only era, and "1125 s full
flow" from 2026-07-14 — are superseded. Do not plan against them.)
Speech is not the bottleneck — **Tatlock is**, by one to two orders of magnitude. Speech is not the bottleneck — **Tatlock is**, by one to two orders of magnitude.
Constraints this imposes: Constraints this imposes:
@@ -241,11 +250,11 @@ Constraints this imposes:
sentence-by-sentence**, forwarding audio as each sentence is ready. The device starts sentence-by-sentence**, forwarding audio as each sentence is ready. The device starts
speaking after the first sentence instead of waiting for the full reply — with speaking after the first sentence instead of waiting for the full reply — with
streaming, first audio should land roughly at Steward-time + first-sentence-time, streaming, first audio should land roughly at Steward-time + first-sentence-time,
well under the 1125 s full-flow figure. The WS protocol already supports this: one well under the ~1013 s full-flow figure. The WS protocol already supports this: one
`audio_start` … PCM … `audio_end` envelope with chunks arriving as they're `audio_start` … PCM … `audio_end` envelope with chunks arriving as they're
synthesized — the device just plays a continuous stream. synthesized — the device just plays a continuous stream.
2. **The `thinking` face state is a first-class feature**, not decoration — it's what 2. **The `thinking` face state is a first-class feature**, not decoration — it's what
makes a 1025 s Tatlock turn feel intentional instead of broken. Consider progress makes a ~10 s Tatlock turn feel intentional instead of broken. Consider progress
cues (e.g. surface Tatlock's reasoning summaries on-screen) later. cues (e.g. surface Tatlock's reasoning summaries on-screen) later.
3. A **fast lane** may eventually be needed: MultiNet on-device commands for instant 3. A **fast lane** may eventually be needed: MultiNet on-device commands for instant
home-automation phrases, and/or a low-latency intent path in Tatlock itself. Out of home-automation phrases, and/or a low-latency intent path in Tatlock itself. Out of
@@ -335,7 +344,7 @@ Gitea Actions (`.gitea/workflows/build.yml`), following the tatlock/tatlock-ui p
- **Every push to `main`**: lint + tests for the gateway (Python 3.12). - **Every push to `main`**: lint + tests for the gateway (Python 3.12).
- **Version tags (`v0.1.0`, …)**: tests, then build `gateway/` into - **Version tags (`v0.1.0`, …)**: tests, then build `gateway/` into
`git.schweitz.internal/jpmschweitzer/desklock-gateway:{latest,tag}`, push to the `git.schweitz.net/jpmschweitzer/desklock-gateway:{latest,tag}`, push to the
Gitea registry, create a release, and trigger Watchtower to roll the running Gitea registry, create a release, and trigger Watchtower to roll the running
container. container.
- Required repo/org secrets: `REGISTRY_USER`, `REGISTRY_PASSWORD`, - Required repo/org secrets: `REGISTRY_USER`, `REGISTRY_PASSWORD`,
-28
View File
@@ -1,28 +0,0 @@
.PHONY: setup run test lint typecheck clean
# any Python >= 3.11 works; system python3 on tower-of-joy is 3.8, hence explicit
PYTHON ?= python3.12
setup:
$(PYTHON) -m venv .venv
.venv/bin/pip install -e ".[dev]"
setup-speech:
.venv/bin/pip install -e ".[dev,speech]"
run:
.venv/bin/uvicorn desklock_gateway.main:app --host 0.0.0.0 --port 8600 --reload
test:
.venv/bin/pytest
lint:
.venv/bin/ruff check src tests
.venv/bin/ruff format --check src tests
typecheck:
.venv/bin/mypy src
clean:
rm -rf .venv .pytest_cache .ruff_cache .mypy_cache
find . -type d -name __pycache__ -exec rm -rf {} +
+48 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "desklock-gateway" name = "desklock-gateway"
version = "0.2.1" version = "0.2.2"
description = "Voice gateway bridging the DeskLock device to the Tatlock butler (STT/chat/TTS)" description = "Voice gateway bridging the DeskLock device to the Tatlock butler (STT/chat/TTS)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -33,6 +33,53 @@ where = ["src"]
line-length = 100 line-length = 100
src = ["src"] src = ["src"]
# Selected explicitly, because the default set is not a constant.
#
# With no `select` here, ruff lints with whatever its installed version
# defaults to — 413 rules under 0.16.3. `dev` pins only `ruff>=0.6`, and CI
# installs that extra fresh on every run, so the gate's scope was a function of
# when pip last resolved rather than of this code. Two findings appeared here
# the first time a converged environment ran the gate, in a file nobody had
# touched (T-47).
#
# That is the failure this repo keeps meeting from the other side: a check
# whose result depends on something other than the thing it checks. Pinning the
# ruff version would freeze the symptom; naming the rules fixes it, and makes
# a future ruff release a decision rather than a surprise.
#
# ASYNC is here on purpose — this is a websocket gateway, and it is the one
# family whose findings would be genuine bugs rather than style.
# BLE (blind except) is deliberately absent: main.py catches bare Exception
# when a device disappears mid-send, which is correct there and would need a
# noqa on every occurrence to say so.
[tool.ruff.lint]
select = ["E", "W", "F", "I", "UP", "B", "ASYNC", "SIM", "C4"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
[tool.mypy]
python_version = "3.11"
# The speech extra is deliberately absent from a default setup. `make setup`
# installs the gateway without it; `make setup-speech` adds faster-whisper and
# piper-tts, which pull several GB of ML wheels for a backend the deployment
# does not use — settings.tts_backend defaults to "speaches", a network call to
# the shared service on 8601. Both imports are lazy, inside the functions that
# need them, so their absence is a runtime fact rather than a defect.
#
# numpy is here for the same reason: nothing depends on it directly, it arrives
# with faster-whisper.
#
# Without these overrides, `make typecheck` fails on a machine that followed the
# documented setup — the pre-push gate turning red for doing the right thing,
# which is how a gate stops being read (T-56). The right assertion is "these
# modules may be absent", not "install several GB so the type checker is happy".
[[tool.mypy.overrides]]
module = [
"piper", "piper.*",
"faster_whisper", "faster_whisper.*",
"numpy", "numpy.*",
]
ignore_missing_imports = true
+1 -1
View File
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
"""Gateway configuration, overridable via DESKLOCK_* environment variables.""" """Gateway configuration, overridable via DESKLOCK_* environment variables."""
tatlock_base_url: str = "http://tatlock.schweitz.internal:8000" tatlock_base_url: str = "http://tatlock:8000"
tatlock_model: str = "Tatlock" tatlock_model: str = "Tatlock"
# PCM rate of the device WebSocket contract (docs/architecture.md) # PCM rate of the device WebSocket contract (docs/architecture.md)
+2 -2
View File
@@ -7,7 +7,7 @@ Protocol (see docs/architecture.md — keep in sync):
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import UTC, datetime
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -41,7 +41,7 @@ async def _ensure_filler() -> bytes | None:
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
@app.get("/healthz") @app.get("/healthz")
+54
View File
@@ -0,0 +1,54 @@
# Decisions, Questions, Rejected
This directory holds structured planning records that pql parses
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
a markdown file. Files live in three per-type subdirectories:
- `decisions/<domain>.md` — confirmed design decisions
- `questions/<domain>.md` — open questions that may resolve into
decisions or rejected proposals
- `rejected/<domain>.md` — rejected proposals (kept for the audit
trail)
The parser infers domain from the filename stem and record type
from the parent subdirectory.
D-records that propose implementation work link to `initiative`-type
tickets via `decision_ref`. Run `pql decisions show <id>
--with-tickets` to inspect implementation status.
## Recommended domains
Start with this canonical set; create files as records land in
each domain:
- **architecture** — structural commitments (storage, layering,
languages, libraries)
- **process** — team workflow (commits, branches, releases, reviews)
- **design** — user-facing surface (UX, UI, public APIs)
- **coding-conventions** — team-internal code shape (style, lint,
file layout)
- **testing** — quality strategy (coverage, layers, gates)
You might also want, project-permitting:
- `accessibility` — if you ship user-facing software
- `security` — if you handle user data or network surfaces
- `licensing` — if you release open-source or commercial
- `documentation` — if user-docs are non-trivial
- `deployment` — if shipping is non-trivial
- `performance` — if you have perf budgets / SLOs
<!-- pql:records (auto-generated; do not edit manually) -->
## Decisions
- _(none)_
## Open questions
- _(none)_
## Rejected
- _(none)_