From aac293a053dabcdbc49111a1a0f1f128c251dff8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 8 Aug 2026 23:56:29 +0200 Subject: [PATCH] chore: adopt pql for work tracking and modernize agent config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates TODO.md into pql and removes it. Six tickets: the double- ingestion bug with its full investigation preserved, and an epic covering the four stub endpoints in src/main.py. Markdown TODO lists cannot express blocking, parentage or status, and nothing notices when they go stale. Tickets travel with the repo — .pql/changelog/ is committed and replayed by the git hooks, while the databases are ignored and rebuildable with `pql plan rebuild`. Replaces the feature-branch mandate with the workspace convention: linear history, no merge commits, work on main or a short-lived branch that is fast-forwarded away. tatlock remains the one repo that requires branches. Adds a committed .claude/settings.json. `pql init` writes one containing only allow rules, which is the wrong shape — an allowlist with no floor under it. Every git deny appears in both `git ` and `git * ` form; the second catches `git -C `, and without it the git denies would be decorative. .gitignore gains two entries. `.claude/settings.local.json` was only protected by a global gitignore on this machine, so the protection did not travel with the repo. The .pql rules ignore everything except the changelog, deliberately, since that file is what makes tickets portable. Co-Authored-By: Claude --- .claude/settings.json | 69 +++++++++ .gitattributes | 1 + .gitignore | 9 ++ .pql/changelog/0000-format.sql | 11 ++ .pql/changelog/ticket_deps/0000-schema.sql | 139 ++++++++++++++++++ .pql/changelog/ticket_history/0000-schema.sql | 139 ++++++++++++++++++ .pql/changelog/ticket_history/2026-08.sql | 43 ++++++ .pql/changelog/ticket_idmap/0000-schema.sql | 139 ++++++++++++++++++ .pql/changelog/ticket_idmap/2026-08.sql | 6 + .pql/changelog/ticket_labels/0000-schema.sql | 139 ++++++++++++++++++ .pql/changelog/tickets/0000-schema.sql | 139 ++++++++++++++++++ .pql/changelog/tickets/2026-08.sql | 49 ++++++ AGENTS.md | 12 +- TODO.md | 85 ----------- governance/README.md | 54 +++++++ 15 files changed, 948 insertions(+), 86 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .gitattributes create mode 100644 .pql/changelog/0000-format.sql create mode 100644 .pql/changelog/ticket_deps/0000-schema.sql create mode 100644 .pql/changelog/ticket_history/0000-schema.sql create mode 100644 .pql/changelog/ticket_history/2026-08.sql create mode 100644 .pql/changelog/ticket_idmap/0000-schema.sql create mode 100644 .pql/changelog/ticket_idmap/2026-08.sql create mode 100644 .pql/changelog/ticket_labels/0000-schema.sql create mode 100644 .pql/changelog/tickets/0000-schema.sql create mode 100644 .pql/changelog/tickets/2026-08.sql delete mode 100644 TODO.md create mode 100644 governance/README.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2b94b38 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,69 @@ +{ + "permissions": { + "allow": [ + "Bash(pql)", + "Bash(pql *)", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git branch:*)", + "Bash(pytest:*)", + "Bash(python -m pytest:*)", + "Bash(ruff check:*)", + "Bash(docker logs library-desk:*)", + "Bash(curl -s http://localhost:8089/*)" + ], + + "deny": [ + "Bash(sudo *)", + "Bash(su *)", + + "Bash(rm -rf /*)", + "Bash(rm -rf ~*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(chmod 777 *)", + "Bash(chmod -R 777 *)", + + "Bash(find * -exec*)", + "Bash(find * -delete*)", + + "Bash(git add -A*)", + "Bash(git * add -A*)", + "Bash(git add --all*)", + "Bash(git * add --all*)", + "Bash(git add .)", + "Bash(git * add .)", + "Bash(git push --force*)", + "Bash(git * push --force*)", + "Bash(git push -f*)", + "Bash(git * push -f*)", + "Bash(git reset --hard*)", + "Bash(git * reset --hard*)", + "Bash(git clean -fd*)", + "Bash(git * clean -fd*)", + "Bash(git clean -fdx*)", + "Bash(git * clean -fdx*)", + "Bash(git branch -D *)", + "Bash(git * branch -D *)", + "Bash(git checkout -- *)", + "Bash(git * checkout -- *)", + "Bash(git restore .*)", + "Bash(git * restore .*)", + "Bash(git commit --no-verify*)", + "Bash(git * commit --no-verify*)", + "Bash(git merge --no-ff*)", + "Bash(git * merge --no-ff*)", + + "Bash(redis-cli * FLUSHALL*)", + "Bash(redis-cli FLUSHALL*)", + "Bash(redis-cli * FLUSHDB*)", + "Bash(redis-cli FLUSHDB*)", + "Bash(dropdb *)", + "Bash(psql * -c DROP*)", + "Bash(psql * DROP DATABASE*)", + "Bash(psql * TRUNCATE*)" + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..788efb1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.pql/changelog/*.sql merge=union diff --git a/.gitignore b/.gitignore index 52ec92b..5c033e5 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,12 @@ Thumbs.db # profiling data .prof + +# Claude Code — local settings hold credentials. A global gitignore covers this +# on tower-of-joy, but that protection does not travel with the repo. +.claude/settings.local.json + +# pql — ignore everything except the changelog, which is the replication log of +# record and must be committed for tickets to travel with the repo. +.pql/* +!.pql/changelog/ diff --git a/.pql/changelog/0000-format.sql b/.pql/changelog/0000-format.sql new file mode 100644 index 0000000..e7d3357 --- /dev/null +++ b/.pql/changelog/0000-format.sql @@ -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 diff --git a/.pql/changelog/ticket_deps/0000-schema.sql b/.pql/changelog/ticket_deps/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_deps/0000-schema.sql @@ -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); diff --git a/.pql/changelog/ticket_history/0000-schema.sql b/.pql/changelog/ticket_history/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_history/0000-schema.sql @@ -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); diff --git a/.pql/changelog/ticket_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql new file mode 100644 index 0000000..ca02e72 --- /dev/null +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -0,0 +1,43 @@ +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'description', NULL, 'Dockerfile runs `uvicorn ... --workers 2`, and `startup_event` in src/main.py creates a +WikiChangeListener per worker. Each opens its own PostgreSQL LISTEN connection on +`wiki_page_changes`, and Postgres delivers NOTIFY to *every* listening session — so both +workers process the same event. + +Proven against production v1.9.1 on 2026-08-08: +- pg_stat_activity shows two library_desk_listener sessions, both LISTEN "wiki_page_changes" +- "Wiki.js change listener started successfully" is logged twice at startup +- a single pg_notify(''wiki_page_changes'', ''claude-probe-no-colons'') produced exactly two + "Invalid notification payload" lines, one per worker (that payload is rejected before any + ingestion runs, so it is a side-effect-free probe) + +The debounce in wiki_change_listener.py (self._recent_notifications) is an in-process dict +and cannot dedupe across workers. No Redis lock or other cross-process guard exists in the +process_wiki_page_change path in src/routers/webhooks.py. + +IMPACT: each edit runs ingestion twice — duplicate embedding generation against Ollama plus +duplicate Qdrant and Neo4j writes. On tower-of-joy, Ollama shares the GPU with +Speaches/Whisper, so the wasted work has a cost beyond CPU. + +NOT YET ESTABLISHED: whether this is merely wasteful or actually corrupting. Check whether +repeated ingestion of the same page creates duplicate Neo4j entities/relationships or +duplicate Qdrant points — that changes the severity. + +OPTIONS TO WEIGH: +- run the listener in one place only (single-worker sidecar, or elect an owner) so the + subscription is singular by construction +- keep per-worker listeners and add a short-TTL Redis claim lock keyed on page_id, so only + the first worker to claim a notification processes it. Redis is already a dependency — + the job manager uses it. + +Whatever approach is taken must preserve the reconnect supervision added in v1.9.1 and its +tests in tests/test_wiki_change_listener.py. + +Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:25', '2026-08-08 21:53:25.051', '2026-08-08 21:53:25.051', NULL, '14eeab6aa32e7b47edf3bdf1fdb572a8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.143', '2026-08-08 21:53:37.143', NULL, 'd793037ced050d17996991b71199c4e6', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.178', '2026-08-08 21:53:37.178', NULL, 'a9feaf6cf70f87d9f3e12e73311120d8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.179', '2026-08-08 21:53:37.179', NULL, 'dfbe9ea63d608c433b8af3291c6ade71', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.179', '2026-08-08 21:53:37.179', NULL, 'ecd603354edd2fc778a38e75782a10a1', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'description', NULL, 'Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. Needs: query existing documents by path, compare content hashes, return list of updates needed. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.284', '2026-08-08 21:53:37.284', NULL, 'f0dc060e8f3931fa30026d5476d6b906', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'description', NULL, 'Get processing status for a document. Needs a status tracking system (Redis or database) and per-document ingestion progress. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.421', '2026-08-08 21:53:37.421', NULL, '9ac8b8db277ef5f96fec157e6e1db2e7', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'description', NULL, 'Get indexing status for an entire repository. Needs repository-level statistics and tracking of which documents from a repo are indexed. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.573', '2026-08-08 21:53:37.573', NULL, 'cf4fca0273bf1de3fa9d2c63ff97323a', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'description', NULL, 'Check for duplicate or highly similar documents using vector similarity and graph analysis. Needs: get document embedding from Qdrant, find similar vectors above threshold, check graph relationships, return candidates with similarity scores. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.698', '2026-08-08 21:53:37.698', NULL, 'a96ed5423c39f04a515770ad6d60fb10', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/ticket_idmap/0000-schema.sql b/.pql/changelog/ticket_idmap/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_idmap/0000-schema.sql @@ -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); diff --git a/.pql/changelog/ticket_idmap/2026-08.sql b/.pql/changelog/ticket_idmap/2026-08.sql new file mode 100644 index 0000000..c889da5 --- /dev/null +++ b/.pql/changelog/ticket_idmap/2026-08.sql @@ -0,0 +1,6 @@ +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'T-1', '2026-08-08 21:53:14.322', '2026-08-08 21:53:14.322', NULL, 'f4caa0a362b236ef584eff529bfca422', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF8M3ZYNMK67A0VQYYQ7W', 'T-2', '2026-08-08 21:53:30.273', '2026-08-08 21:53:30.273', NULL, '413e76364f3d44f17cd86ef8badd107b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'T-3', '2026-08-08 21:53:30.408', '2026-08-08 21:53:30.408', NULL, '9bab8d22e7616f0376e7af232dc789a1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'T-4', '2026-08-08 21:53:30.525', '2026-08-08 21:53:30.525', NULL, '188f0b5550707f6c55ccdabf01762e6b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'T-5', '2026-08-08 21:53:30.651', '2026-08-08 21:53:30.651', NULL, '581e4c4fdba8fcfb414aa5212cb43995', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'T-6', '2026-08-08 21:53:30.767', '2026-08-08 21:53:30.767', NULL, 'fb194a09d4e8750537a61782e8d1eb12', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; diff --git a/.pql/changelog/ticket_labels/0000-schema.sql b/.pql/changelog/ticket_labels/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_labels/0000-schema.sql @@ -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); diff --git a/.pql/changelog/tickets/0000-schema.sql b/.pql/changelog/tickets/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/tickets/0000-schema.sql @@ -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); diff --git a/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql new file mode 100644 index 0000000..0f84b0a --- /dev/null +++ b/.pql/changelog/tickets/2026-08.sql @@ -0,0 +1,49 @@ +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'bug', NULL, 'Every wiki page change is ingested twice', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-08-08 21:53:14.322', '2026-08-08 21:53:14.322', NULL, 'e93392a341072352b147a3466ed3ab5b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'bug', NULL, 'Every wiki page change is ingested twice', 'Dockerfile runs `uvicorn ... --workers 2`, and `startup_event` in src/main.py creates a +WikiChangeListener per worker. Each opens its own PostgreSQL LISTEN connection on +`wiki_page_changes`, and Postgres delivers NOTIFY to *every* listening session — so both +workers process the same event. + +Proven against production v1.9.1 on 2026-08-08: +- pg_stat_activity shows two library_desk_listener sessions, both LISTEN "wiki_page_changes" +- "Wiki.js change listener started successfully" is logged twice at startup +- a single pg_notify(''wiki_page_changes'', ''claude-probe-no-colons'') produced exactly two + "Invalid notification payload" lines, one per worker (that payload is rejected before any + ingestion runs, so it is a side-effect-free probe) + +The debounce in wiki_change_listener.py (self._recent_notifications) is an in-process dict +and cannot dedupe across workers. No Redis lock or other cross-process guard exists in the +process_wiki_page_change path in src/routers/webhooks.py. + +IMPACT: each edit runs ingestion twice — duplicate embedding generation against Ollama plus +duplicate Qdrant and Neo4j writes. On tower-of-joy, Ollama shares the GPU with +Speaches/Whisper, so the wasted work has a cost beyond CPU. + +NOT YET ESTABLISHED: whether this is merely wasteful or actually corrupting. Check whether +repeated ingestion of the same page creates duplicate Neo4j entities/relationships or +duplicate Qdrant points — that changes the severity. + +OPTIONS TO WEIGH: +- run the listener in one place only (single-worker sidecar, or elect an owner) so the + subscription is singular by construction +- keep per-worker listeners and add a short-TTL Redis claim lock keyed on page_id, so only + the first worker to claim a notification processes it. Redis is already a dependency — + the job manager uses it. + +Whatever approach is taken must preserve the reconnect supervision added in v1.9.1 and its +tests in tests/test_wiki_change_listener.py. + +Migrated from TODO.md 2026-08-08.', 'backlog', 'high', NULL, NULL, NULL, '2026-08-08 21:53:14.322', '2026-08-08 21:53:25.051', NULL, '34ff99ff78472b24fc35096faa05c8df', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF8M3ZYNMK67A0VQYYQ7W', 'epic', NULL, 'Implement the stub endpoints in src/main.py', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.273', '2026-08-08 21:53:30.273', NULL, '84c45755f930b52d22238199335e6641', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'task', NULL, 'Implement POST /ingest/check-updates', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.407', '2026-08-08 21:53:30.407', NULL, '2811d4e9372b5c838f8d524a57fc0b7c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'task', NULL, 'Implement GET /ingest/status/{document_id}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.525', '2026-08-08 21:53:30.525', NULL, 'b9f5120a8a63b817dc815bda5615a75a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'task', NULL, 'Implement GET /ingest/repo-status/{repository}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.651', '2026-08-08 21:53:30.651', NULL, 'cb00e75f1f91e819f15f345eb18db460', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'task', NULL, 'Implement POST /deduplicate/check', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.767', '2026-08-08 21:53:30.767', NULL, '0300426ec940c0d43e36241d0b5968d1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /ingest/check-updates', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.407', '2026-08-08 21:53:37.143', NULL, '2401d236cc1b289afa88a791a69caeec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/status/{document_id}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.525', '2026-08-08 21:53:37.178', NULL, '36d4868e94a8f66a98fc370ac83b1e7c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/repo-status/{repository}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.651', '2026-08-08 21:53:37.179', NULL, '8b44d54948e21faf5a7fa6bf20f25281', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /deduplicate/check', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.767', '2026-08-08 21:53:37.179', NULL, 'd78fc0b019f24a626f6c9c800fc54023', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /ingest/check-updates', 'Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. Needs: query existing documents by path, compare content hashes, return list of updates needed. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.407', '2026-08-08 21:53:37.284', NULL, 'a4005f13ed2272d82bcd4b3172e9aa57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/status/{document_id}', 'Get processing status for a document. Needs a status tracking system (Redis or database) and per-document ingestion progress. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.525', '2026-08-08 21:53:37.421', NULL, '29670c509669343cababf536be083e6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/repo-status/{repository}', 'Get indexing status for an entire repository. Needs repository-level statistics and tracking of which documents from a repo are indexed. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.651', '2026-08-08 21:53:37.572', NULL, '76be99d15816829ef4cd210107630d76', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /deduplicate/check', 'Check for duplicate or highly similar documents using vector similarity and graph analysis. Needs: get document embedding from Qdrant, find similar vectors above threshold, check graph relationships, return candidates with similarity scores. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.767', '2026-08-08 21:53:37.698', NULL, '2643ba102e78a305f8b70e329cbba84d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/AGENTS.md b/AGENTS.md index 345a4ea..8984434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,18 @@ * **Act:** Execute the changes in small, atomic steps. * **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests? +### 📋 Work Tracking +* **Outstanding work lives in pql, not in a markdown file.** `TODO.md` was migrated + on 2026-08-08 and removed. +* `pql ticket list` — open work. `pql plan whatsnext` — the next unblocked item with + context. `pql ticket show T-N --with-children` — detail. +* File new work with `pql ticket new bug|task|story|epic "title"` rather than adding a + TODO section. Tickets travel with the repo: `.pql/changelog/` is committed and + replayed by the git hooks, while the databases are ignored and rebuildable + (`pql plan rebuild`). + ### 🛡️ Git Discipline -* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`. +* **History is linear — no merge commits.** Work goes onto `main` directly, or onto a short-lived branch that is fast-forwarded and deleted. Never create a merge commit. (This replaced a feature-branch mandate on 2026-08-08, to match the workspace convention; `tatlock` remains the one repo that requires branches.) * **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format. * `feat: add user login endpoint` * `fix: resolve database connection timeout` diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 2de5202..0000000 --- a/TODO.md +++ /dev/null @@ -1,85 +0,0 @@ -# TODO - -Outstanding work items for Library Desk. - -## Bugs - -### Every wiki page change is ingested twice - -`Dockerfile` runs `uvicorn ... --workers 2`, and `startup_event` in `src/main.py` -creates a `WikiChangeListener` per worker. Each opens its own PostgreSQL `LISTEN` -connection on `wiki_page_changes`, and Postgres delivers `NOTIFY` to *every* -listening session — so both workers process the same event. - -Proven against production v1.9.1 on 2026-08-08: - -- `pg_stat_activity` shows two `library_desk_listener` sessions, both - `LISTEN "wiki_page_changes"` -- "Wiki.js change listener started successfully" is logged twice at startup -- a single `pg_notify('wiki_page_changes', 'claude-probe-no-colons')` produced - exactly two "Invalid notification payload" lines, one per worker (that payload - is rejected before any ingestion runs, so it is a side-effect-free probe) - -The debounce in `wiki_change_listener.py` (`self._recent_notifications`) is an -in-process dict and cannot dedupe across workers. No Redis lock or other -cross-process guard exists in the `process_wiki_page_change` path in -`src/routers/webhooks.py`. - -**Impact:** each edit runs ingestion twice — duplicate embedding generation -against Ollama plus duplicate Qdrant and Neo4j writes. On tower-of-joy, Ollama -shares the GPU with Speaches/Whisper, so the wasted work has a cost beyond CPU. - -**Not yet established:** whether this is merely wasteful or actually corrupting. -Check whether repeated ingestion of the same page creates duplicate Neo4j -entities/relationships or duplicate Qdrant points — that changes the severity. - -**Options to weigh:** - -- run the listener in one place only (single-worker sidecar, or elect an owner) - so the subscription is singular by construction -- keep per-worker listeners and add a short-TTL Redis claim lock keyed on - `page_id`, so only the first worker to claim a notification processes it. - Redis is already a dependency — the job manager uses it. - -Whatever approach is taken must preserve the reconnect supervision added in -v1.9.1 and its tests in `tests/test_wiki_change_listener.py`. - -## Stub Endpoints to Implement - -The following endpoints in `src/main.py` return stub responses and need real implementations: - -### Ingestion Status Endpoints - -#### `POST /ingest/check-updates` -Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. - -**Implementation needed:** -1. Query existing documents by path -2. Compare content hashes -3. Return list of updates needed - -#### `GET /ingest/status/{document_id}` -Get processing status for a document. - -**Implementation needed:** -- Status tracking system (Redis or database) -- Track ingestion progress per document - -#### `GET /ingest/repo-status/{repository}` -Get indexing status for an entire repository. - -**Implementation needed:** -- Repository-level statistics -- Track which documents from a repo are indexed - -### Deduplication - -#### `POST /deduplicate/check` -Check for duplicate or highly similar documents using vector similarity and graph analysis. - -**Implementation needed:** -1. Get document embedding from Qdrant -2. Find similar vectors above threshold -3. Check graph relationships -4. Return candidates with similarity scores - diff --git a/governance/README.md b/governance/README.md new file mode 100644 index 0000000..0945720 --- /dev/null +++ b/governance/README.md @@ -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/.md` — confirmed design decisions +- `questions/.md` — open questions that may resolve into + decisions or rejected proposals +- `rejected/.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 +--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 + + + +## Decisions + +- _(none)_ + +## Open questions + +- _(none)_ + +## Rejected + +- _(none)_