Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36f0fe431 | ||
|
|
8bdf950fcf | ||
|
|
78d9b276ee | ||
|
|
3f8e4cf593 | ||
|
|
7846e48595 | ||
|
|
eca10a0dd0 | ||
|
|
5297249e58 | ||
|
|
2ef00c98de | ||
|
|
ff193203c9 | ||
|
|
8cd2c05a00 | ||
|
|
984e43aa8f | ||
|
|
2cdefefecb | ||
|
|
101816de71 | ||
|
|
b6fdd3313a | ||
|
|
67bee80dc8 |
@@ -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
|
||||||
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/
|
.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
|
||||||
|
|||||||
@@ -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,131 +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:8000` on `docker-dataplane`).
|
|
||||||
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.
|
|
||||||
- Machine-to-machine traffic uses container names on the `docker-dataplane` network —
|
|
||||||
`http://tatlock:8000` (the deployed stack overrides `DESKLOCK_TATLOCK_BASE_URL` to
|
|
||||||
the host LAN IP while Tatlock runs on the host). Browser-facing URLs use
|
|
||||||
`https://<name>.schweitz.net` (NPM, Authentik SSO) — never for M2M traffic.
|
|
||||||
- Git remote: `git.schweitz.net` (Gitea).
|
|
||||||
@@ -8,6 +8,16 @@ 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
|
## [0.2.2] — 2026-07-19
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
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"
|
||||||
+30
-21
@@ -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, … │
|
||||||
└────────────────────┘ └─────────────────────────────┘
|
└────────────────────┘ └─────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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 ~5–10 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** | **~10–13 s end-to-end** for simple turns |
|
||||||
| **Tatlock, full local flow** | **11–25 s end-to-end** (librarian-routed ~20–25 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 "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.
|
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 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
|
`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 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.
|
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
|
||||||
|
|||||||
@@ -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 {} +
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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