Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36f0fe431 | ||
|
|
8bdf950fcf | ||
|
|
78d9b276ee | ||
|
|
3f8e4cf593 | ||
|
|
7846e48595 | ||
|
|
eca10a0dd0 | ||
|
|
5297249e58 | ||
|
|
2ef00c98de | ||
|
|
ff193203c9 | ||
|
|
8cd2c05a00 | ||
|
|
984e43aa8f | ||
|
|
2cdefefecb | ||
|
|
101816de71 | ||
|
|
b6fdd3313a | ||
|
|
67bee80dc8 | ||
|
|
7c7ce1541c | ||
|
|
014c86f51e | ||
|
|
c4770194d9 | ||
|
|
ccb46d45b4 | ||
|
|
9a62233775 | ||
|
|
24e9375095 |
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.pql/changelog/*.sql merge=union
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.schweitz.internal
|
||||
registry: git.schweitz.net
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
@@ -61,8 +61,8 @@ jobs:
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: |
|
||||
git.schweitz.internal/jpmschweitzer/desklock-gateway:latest
|
||||
git.schweitz.internal/jpmschweitzer/desklock-gateway:${{ github.ref_name }}
|
||||
git.schweitz.net/jpmschweitzer/desklock-gateway:latest
|
||||
git.schweitz.net/jpmschweitzer/desklock-gateway:${{ github.ref_name }}
|
||||
|
||||
- name: Trigger Watchtower update
|
||||
if: success()
|
||||
|
||||
Executable
+13
@@ -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
@@ -21,3 +21,17 @@ dist/
|
||||
.idea/
|
||||
*.swp
|
||||
.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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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).
|
||||
@@ -8,6 +8,35 @@ until the first tagged release.
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- A spoken volume command no longer leaves the face stuck in the thinking
|
||||
spinner — the feedback gong is a UI cue and no longer holds the busy state,
|
||||
so the return-to-idle isn't swallowed.
|
||||
- The butler filler line reads as one phrase instead of two — reworded to "Let
|
||||
me check on that, sir." to avoid a text-to-speech pause before "for you".
|
||||
|
||||
## [0.2.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
|
||||
command reference — see [AGENTS.md](AGENTS.md), and read it before starting work.
|
||||
- `firmware/` — ESP-IDF (C, LVGL 9) app for a Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C
|
||||
(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)
|
||||
plus a voice gateway container (`gateway/`, Python/FastAPI, port 8600) that bridges device
|
||||
audio to the Tatlock butler API. The device↔gateway WebSocket protocol lives in
|
||||
`docs/architecture.md` and must stay in sync with both implementations.
|
||||
**Never modify Tatlock from this repo.** desklock consumes Tatlock's public API only
|
||||
(`http://tatlock:8000` on `docker-dataplane`); this is a standing cross-repo rule, not
|
||||
local policy.
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
# Firmware (requires `source ~/esp-idf/export.sh` first; IDF ≥ 5.4)
|
||||
cd firmware && idf.py build
|
||||
idf.py -p /dev/ttyACM0 flash monitor
|
||||
**One Makefile at the root drives all three components.** There is deliberately no
|
||||
`gateway/Makefile` any more — `make test` meant "the gateway's tests" or "nothing"
|
||||
depending on which directory you were standing in, and now it means the same thing
|
||||
everywhere (workspace D-27).
|
||||
|
||||
# Gateway
|
||||
cd gateway && make setup # once
|
||||
make run # dev server :8600
|
||||
make test # pytest; single test: .venv/bin/pytest tests/test_health.py -k healthz
|
||||
make lint typecheck
|
||||
```bash
|
||||
make help # every target, self-documenting
|
||||
|
||||
make test # gateway pytest; reports firmware + sim as undetermined
|
||||
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
|
||||
`sg dialout -c …` (see AGENTS.md) — the login session predates dialout membership.
|
||||
- **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
|
||||
loop. Details in AGENTS.md.
|
||||
- **Wi-Fi = esp_hosted 2.x on BOTH chips** (host manifest + C6 slave, auto-OTA'd from
|
||||
`main/c6_slave.bin`). 1.x on IDF 5.5 gives working control RPC but a dead data path
|
||||
(the great July 14th debugging night). Boot-loop assert on
|
||||
`xTaskCreateStaticPinnedToCore` = internal-RAM famine. Details in AGENTS.md.
|
||||
- Gateway speech deps are optional extras; `make setup` alone runs the app and tests
|
||||
without GPU/ML packages. `make setup` uses `python3.12` (system python3 is 3.8).
|
||||
- Tatlock replies open with a `<think>` block — always strip via
|
||||
`tatlock.strip_reasoning()` before TTS or display.
|
||||
- Low power is a prime user requirement: see "Power management" in
|
||||
docs/architecture.md before touching the face/render loop.
|
||||
`make test` never reports green for the firmware. It has no suite, so it is
|
||||
**undetermined**, printed explicitly rather than skipped silently (workspace D-26).
|
||||
|
||||
## Liveness
|
||||
|
||||
- **Gateway (`desklock-gateway` container, port 8600):** confirmed live — `docker ps`
|
||||
shows the container running under that name (method: direct container inspection;
|
||||
blind spot: none relevant here, this confirms the process is up, not that every route
|
||||
behaves correctly — that would need a request against it, not checked this pass).
|
||||
- **Firmware / device:** liveness is **undetermined** and cannot be established the way
|
||||
the gateway's can. No `/dev/ttyACM0` was present in this session (checked: `ls
|
||||
/dev/ttyACM*` found nothing) and there is no remote telemetry — the device only proves
|
||||
itself alive over a physical USB serial connection or by joining the LAN and speaking
|
||||
the WebSocket protocol, neither of which this session had access to. Do not infer
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -38,8 +38,8 @@ happens on this server.
|
||||
▼ │ Speaches (container, GPU) │
|
||||
┌────────────────────┐ │ • STT: faster-whisper │
|
||||
│ Tatlock (butler) │ │ • TTS: Kokoro / Piper │
|
||||
│ tatlock.schweitz. │ │ also usable by Open WebUI, │
|
||||
│ internal :8000 │ │ Home Assistant, … │
|
||||
│ http://tatlock │ │ also usable by Open WebUI, │
|
||||
│ :8000 │ │ Home Assistant, … │
|
||||
└────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
Executable
+50
@@ -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
@@ -24,8 +24,8 @@ tower-of-joy. Everything is local — no audio, transcript, or reply ever leaves
|
||||
▼ │ Speaches (container, GPU) │
|
||||
┌────────────────────┐ │ • STT: faster-whisper │
|
||||
│ Tatlock (butler) │ │ • TTS: Kokoro / Piper │
|
||||
│ tatlock.schweitz. │ │ also usable by Open WebUI, │
|
||||
│ internal :8000 │ │ Home Assistant, … │
|
||||
│ container name: │ │ also usable by Open WebUI, │
|
||||
│ 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).
|
||||
3. **STT**: POST to Speaches `/v1/audio/transcriptions`.
|
||||
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.
|
||||
5. **TTS**: as Tatlock's token stream completes each sentence, POST it to Speaches
|
||||
`/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
|
||||
Tatlock internal route — do not expose through NPM without auth. Register in
|
||||
`CONTAINERS.md`.
|
||||
- **Measured** (live round trip through the gateway code, warm): STT ~0.3 s for a
|
||||
~3 s utterance; TTS ~1.9 s for a ~3 s sentence. Cold start after model TTL offload
|
||||
adds ~5–10 s to the first request.
|
||||
- **Measured** (live round trip, warm, 2026-08-07): STT ~0.30 s for a ~4.8 s utterance;
|
||||
TTS ~0.24 s for a ~4.5 s sentence (real-time factor ~0.05). The first call after an
|
||||
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
|
||||
instance serves the whole homelab. Open WebUI is currently configured with
|
||||
`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
|
||||
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
|
||||
2026-07). whisper `small` at int8 is <1 GB; Kokoro is a few hundred MB. Speaches'
|
||||
model TTL offload keeps idle pressure near zero. If VRAM contention ever bites,
|
||||
faster-whisper `small` on CPU is an acceptable fallback (int8, a few seconds per
|
||||
utterance).
|
||||
- **VRAM budget**: RTX 2080 Ti, 11,264 MiB, shared with Ollama. As of 2026-08-07 the
|
||||
steady state is ~4.9 GB used / ~5.9 GB free with everything resident: `gemma4:e2b`
|
||||
1.9 GB and `nomic-embed-text` 0.3 GB (both pinned), whisper `small` int8 <1 GB,
|
||||
Kokoro a few hundred MB. Speaches' model TTL offload keeps idle pressure near zero.
|
||||
**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`)
|
||||
|
||||
@@ -220,19 +226,22 @@ it in phase 5.
|
||||
|
||||
## Latency budget & streaming
|
||||
|
||||
Measured/known numbers that shape the design (Tatlock figures per tatlock CLAUDE.md,
|
||||
GPU-resident benchmarks of 2026-07-14, gemma4:e2b at ~100 tok/s):
|
||||
Measured 2026-08-07 against the deployed stack (`gemma4:e2b` at ~95 tok/s, GPU-resident):
|
||||
|
||||
| Stage | Cost |
|
||||
|-------|------|
|
||||
| STT (Speaches whisper `small`) | ~0.3 s warm (measured) |
|
||||
| TTS (Speaches Kokoro) | ~1.9 s per ~3 s sentence, warm (measured) |
|
||||
| Tatlock Steward analysis | ~6 s warm |
|
||||
| **Tatlock, full local flow** | **11–25 s end-to-end** (librarian-routed ~20–25 s) |
|
||||
| Tatlock cold start (>2 h idle) | +~8 s (`OLLAMA_KEEP_ALIVE=2h`) |
|
||||
| STT (Speaches whisper `small`) | ~0.30 s warm, for ~4.8 s of audio |
|
||||
| TTS (Speaches Kokoro) | ~0.24 s warm, for ~4.5 s of audio (RTF ~0.05) |
|
||||
| **Tatlock, full local flow** | **~10–13 s end-to-end** for simple turns |
|
||||
| Tatlock cold model load | +~36 s — avoided while the model is pinned |
|
||||
|
||||
(Older "~35 s Steward / ~2 min flow" figures were from a CPU-only driver-mismatch era —
|
||||
do not plan against them.)
|
||||
A Tatlock turn costs **3 sequential Ollama calls** (Steward routing → tool orchestration →
|
||||
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 "11–25 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.
|
||||
Constraints this imposes:
|
||||
@@ -241,11 +250,11 @@ Constraints this imposes:
|
||||
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
|
||||
streaming, first audio should land roughly at Steward-time + first-sentence-time,
|
||||
well under the 11–25 s full-flow figure. The WS protocol already supports this: one
|
||||
well under the ~10–13 s full-flow figure. The WS protocol already supports this: one
|
||||
`audio_start` … PCM … `audio_end` envelope with chunks arriving as they're
|
||||
synthesized — the device just plays a continuous stream.
|
||||
2. **The `thinking` face state is a first-class feature**, not decoration — it's what
|
||||
makes a 10–25 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.
|
||||
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
|
||||
@@ -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).
|
||||
- **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
|
||||
container.
|
||||
- Required repo/org secrets: `REGISTRY_USER`, `REGISTRY_PASSWORD`,
|
||||
|
||||
@@ -161,8 +161,10 @@ static void audio_task(void *arg)
|
||||
* returns, preventing a playback-boundary false wake. */
|
||||
if (job == JOB_GONG && s_gong != NULL) {
|
||||
/* Play in chunks so a new request (a fresh volume change) cuts the
|
||||
* current gong off and restarts, instead of queueing another 5 s. */
|
||||
s_playing = true;
|
||||
* current gong off and restarts, instead of queueing another 5 s.
|
||||
* Note: the gong is a UI cue, not "playback" — it deliberately does
|
||||
* NOT set s_playing, so a following state:idle isn't swallowed (that
|
||||
* guard is for reply audio) and it doesn't gate wake for 5 s. */
|
||||
uint32_t gen;
|
||||
do {
|
||||
gen = s_gong_gen;
|
||||
@@ -172,7 +174,6 @@ static void audio_task(void *arg)
|
||||
esp_codec_dev_write(s_spk, s_gong + off, n * sizeof(int16_t));
|
||||
}
|
||||
} while (s_gong_gen != gen); /* a new request arrived mid-play → restart */
|
||||
s_playing = false;
|
||||
s_gong_active = false;
|
||||
} else if (job == JOB_CHIME && s_chime != NULL) {
|
||||
s_playing = true;
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "desklock-gateway"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
description = "Voice gateway bridging the DeskLock device to the Tatlock butler (STT/chat/TTS)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -33,6 +33,53 @@ where = ["src"]
|
||||
line-length = 100
|
||||
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]
|
||||
asyncio_mode = "auto"
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings
|
||||
class Settings(BaseSettings):
|
||||
"""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"
|
||||
|
||||
# PCM rate of the device WebSocket contract (docs/architecture.md)
|
||||
@@ -20,7 +20,8 @@ class Settings(BaseSettings):
|
||||
tts_voice: str = "bm_george"
|
||||
|
||||
# Spoken immediately after a query is accepted, to fill the long Tatlock wait.
|
||||
filler_text: str = "Let me check on that for you, sir."
|
||||
# Avoid "for you" — Kokoro inserts an unnatural mid-phrase pause before it.
|
||||
filler_text: str = "Let me check on that, sir."
|
||||
|
||||
# embedded fallback only (requires the [speech] extra)
|
||||
embedded_stt_model: str = "small"
|
||||
|
||||
@@ -7,7 +7,7 @@ Protocol (see docs/architecture.md — keep in sync):
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -41,7 +41,7 @@ async def _ensure_filler() -> bytes | None:
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
|
||||
@@ -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)_
|
||||
Reference in New Issue
Block a user