feat(devpulse): compass curation v2 Track 1 — supersedes links + atomic archive, write-time FTS conflict advisory, note command, --include-archived, score code-removal, review-per-prep (DPLAN-0246/FPLAN-0331). 435 green, seedgo 31/31
This commit is contained in:
@@ -55,7 +55,12 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
||||
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
||||
- Close any that were already processed but not formally closed
|
||||
|
||||
## 5. Loose Ends
|
||||
## 5. Compass Review (Devpulse only)
|
||||
|
||||
- Run ONE `drone @devpulse compass review` — it serves the oldest-unreviewed entry. Judge it: still true → confirm; superseded → archive it and note what replaced it; wrong → fix or archive.
|
||||
- One entry per prep, every prep. This is the curation cadence — review only works if it actually runs (DPLAN-0246: all 127 entries sat unreviewed because nothing invoked it).
|
||||
|
||||
## 6. Loose Ends
|
||||
|
||||
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
||||
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
||||
@@ -71,5 +76,6 @@ Prep complete:
|
||||
- Plans: [which ones updated]
|
||||
- Git: [branch, uncommitted count, suggestion]
|
||||
- Inbox: [count, action taken]
|
||||
- Compass: [entry #N reviewed — verdict]
|
||||
- Loose ends: [any flagged]
|
||||
```
|
||||
|
||||
@@ -13,6 +13,23 @@ PyPI version — not the changelog header.
|
||||
|
||||
### Added
|
||||
|
||||
- **Compass curation v2 Track 1 (DPLAN-0246/FPLAN-0331): supersedes links +
|
||||
write-time conflict check.** A correcting compass entry now archives and
|
||||
links what it replaces in one transaction (`compass add --supersedes N`);
|
||||
query renders both directions ("supersedes #N" / "ARCHIVED — superseded by
|
||||
#M") so a retracted decision can never masquerade as current truth. Every
|
||||
`compass add` FTS-checks the new text against active entries and prints a
|
||||
non-blocking "possible conflict with #X" advisory — flag-and-ask, no LLM, no
|
||||
auto-resolve (boardroom ruling). New `compass note <id>` command (FTS
|
||||
re-index proven by test), `--include-archived` query flag (the avoid-list is
|
||||
finally searchable), dead `score` column removed from all code surfaces
|
||||
(kept inert on disk — zero migration risk). Idempotent PRAGMA-checked
|
||||
migration ran clean on the production store (128 rows, no loss); the four
|
||||
fresh-eyes-audit archive pairs got their links backfilled. /prep now runs
|
||||
one `compass review` per session — curation living in a path that already
|
||||
runs, the lesson of all three compass eras. 435 devpulse tests green,
|
||||
seedgo 31/31 on both touched modules.
|
||||
|
||||
- **Close pipeline completes itself (DPLAN-0245): auto-vectorization +
|
||||
crash-safe registry writes + drone timeout policy.** Closing a plan now
|
||||
produces all side effects from one command — `post_close_runner` invokes
|
||||
|
||||
@@ -23,9 +23,11 @@ from aipass.devpulse.apps.handlers.compass.store import (
|
||||
VALID_STATUSES,
|
||||
add_decision,
|
||||
archive,
|
||||
find_conflicts,
|
||||
query_decisions,
|
||||
rate,
|
||||
review,
|
||||
set_note,
|
||||
stats,
|
||||
)
|
||||
|
||||
@@ -36,8 +38,10 @@ __all__ = [
|
||||
"VALID_STATUSES",
|
||||
"add_decision",
|
||||
"archive",
|
||||
"find_conflicts",
|
||||
"query_decisions",
|
||||
"rate",
|
||||
"review",
|
||||
"set_note",
|
||||
"stats",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ command, and maintenance UX are later phases.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -57,6 +58,9 @@ VALID_SOURCES = ("devpulse", "user")
|
||||
VALID_STATUSES = ("active", "archived")
|
||||
|
||||
# Columns we return / surface from the decisions table (everything useful).
|
||||
# NOTE: ``score`` is deliberately absent — it is code-invisible (DPLAN-0246
|
||||
# seedgo ruling). The column stays physically on disk as inert NULL, but no
|
||||
# Python surface (SELECTs, returned dicts) touches it.
|
||||
_DECISION_COLUMNS = (
|
||||
"id",
|
||||
"created",
|
||||
@@ -66,10 +70,10 @@ _DECISION_COLUMNS = (
|
||||
"note",
|
||||
"tags",
|
||||
"source",
|
||||
"score",
|
||||
"status",
|
||||
"last_reviewed",
|
||||
"times_surfaced",
|
||||
"supersedes",
|
||||
)
|
||||
|
||||
_SCHEMA = """
|
||||
@@ -85,7 +89,8 @@ CREATE TABLE IF NOT EXISTS decisions (
|
||||
score INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')),
|
||||
last_reviewed TEXT,
|
||||
times_surfaced INTEGER NOT NULL DEFAULT 0
|
||||
times_surfaced INTEGER NOT NULL DEFAULT 0,
|
||||
supersedes INTEGER
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
|
||||
@@ -133,6 +138,42 @@ def _verify_fts5(conn: sqlite3.Connection) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply idempotent schema migrations to an already-open DB.
|
||||
|
||||
Adds the ``supersedes`` column to DBs created before it existed. Guarded by
|
||||
a ``PRAGMA table_info`` pre-check so it is safe to run on every connect —
|
||||
never a blind ``ALTER`` (DPLAN-0246 seedgo ruling: idempotent migration).
|
||||
Fresh DBs already carry the column from ``_SCHEMA``; the pre-check makes
|
||||
this a no-op for them.
|
||||
"""
|
||||
cols = {row["name"] for row in conn.execute("PRAGMA table_info(decisions)")}
|
||||
if "supersedes" not in cols:
|
||||
conn.execute("ALTER TABLE decisions ADD COLUMN supersedes INTEGER")
|
||||
conn.commit()
|
||||
logger.info("[compass] migration: added supersedes column")
|
||||
|
||||
|
||||
# FTS5 MATCH treats characters like " * ( ) : - ^ and the words AND/OR/NOT as
|
||||
# syntax. Untrusted text (a decision's own words) can therefore crash MATCH.
|
||||
# We defuse it by extracting bare word tokens and OR-ing them as quoted string
|
||||
# literals — no operator can survive, and quoting a bareword is exact.
|
||||
_FTS_WORD = re.compile(r"\w+", re.UNICODE)
|
||||
|
||||
|
||||
def _sanitize_fts_query(text: Optional[str]) -> Optional[str]:
|
||||
"""Turn arbitrary text into a safe FTS5 MATCH expression, or None.
|
||||
|
||||
Returns an ``OR`` of the text's word tokens, each quoted as a string
|
||||
literal so FTS5 syntax characters can never reach the parser. Returns None
|
||||
when there are no usable tokens (caller should skip the search).
|
||||
"""
|
||||
tokens = _FTS_WORD.findall(text or "")
|
||||
if not tokens:
|
||||
return None
|
||||
return " OR ".join(f'"{t}"' for t in tokens)
|
||||
|
||||
|
||||
def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
||||
"""Resolve the effective DB path, defaulting to the branch-root location."""
|
||||
return Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
||||
@@ -141,8 +182,9 @@ def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
||||
def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
||||
"""Open (and lazily initialise) the compass DB.
|
||||
|
||||
Creates parent directories on first use, verifies FTS5, ensures schema.
|
||||
Rows come back as ``sqlite3.Row`` so we can build clean dicts.
|
||||
Creates parent directories on first use, verifies FTS5, ensures schema, and
|
||||
runs idempotent migrations. Rows come back as ``sqlite3.Row`` so we can
|
||||
build clean dicts.
|
||||
"""
|
||||
path = _resolve_db_path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -151,6 +193,7 @@ def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
_verify_fts5(conn)
|
||||
conn.executescript(_SCHEMA)
|
||||
_migrate(conn)
|
||||
return conn
|
||||
|
||||
|
||||
@@ -168,6 +211,7 @@ def add_decision(
|
||||
source: str = "devpulse",
|
||||
db_path: Optional[Path | str] = None,
|
||||
created: Optional[str] = None,
|
||||
supersedes: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Add a rated decision and return its new id.
|
||||
|
||||
@@ -181,12 +225,16 @@ def add_decision(
|
||||
db_path: Optional DB path override (tests pass a temp path).
|
||||
created: Optional ISO date override; defaults to today. This is the
|
||||
ONLY place a "today" date is stamped.
|
||||
supersedes: Optional id of the decision this entry corrects. When set,
|
||||
the new entry links to it AND that entry is archived — atomically,
|
||||
in one transaction. Errors cleanly (no write) if the id is unknown.
|
||||
|
||||
Returns:
|
||||
The new row's integer id.
|
||||
|
||||
Raises:
|
||||
ValueError: On empty context/decision or invalid rating/source.
|
||||
ValueError: On empty context/decision, invalid rating/source, or a
|
||||
``supersedes`` target that does not exist.
|
||||
"""
|
||||
if not context or not context.strip():
|
||||
raise ValueError("context must be a non-empty string")
|
||||
@@ -201,22 +249,46 @@ def add_decision(
|
||||
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
# Validate the supersede target BEFORE any write so a bad id never
|
||||
# leaves a partial insert behind.
|
||||
if supersedes is not None:
|
||||
target = conn.execute("SELECT 1 FROM decisions WHERE id = ?", (supersedes,)).fetchone()
|
||||
if target is None:
|
||||
raise ValueError(f"cannot supersede #{supersedes}: no decision with that id")
|
||||
|
||||
# Insert + archive-the-target in ONE transaction (commit once at the
|
||||
# end); any failure rolls the whole thing back — never half-applied.
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO decisions (created, context, decision, rating, note, tags, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO decisions (created, context, decision, rating, note, tags, source, supersedes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(stamp, context.strip(), decision.strip(), rating, note, tags, source),
|
||||
(stamp, context.strip(), decision.strip(), rating, note, tags, source, supersedes),
|
||||
)
|
||||
conn.commit()
|
||||
if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT
|
||||
raise RuntimeError("compass: INSERT did not return a rowid")
|
||||
new_id = int(cur.lastrowid)
|
||||
|
||||
if supersedes is not None:
|
||||
conn.execute("UPDATE decisions SET status = 'archived' WHERE id = ?", (supersedes,))
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] added decision id=%s rating=%s source=%s", new_id, rating, source)
|
||||
json_handler.log_operation("compass_add", {"id": new_id, "rating": rating, "source": source})
|
||||
logger.info(
|
||||
"[compass] added decision id=%s rating=%s source=%s supersedes=%s",
|
||||
new_id,
|
||||
rating,
|
||||
source,
|
||||
supersedes,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"compass_add", {"id": new_id, "rating": rating, "source": source, "supersedes": supersedes}
|
||||
)
|
||||
return new_id
|
||||
|
||||
|
||||
@@ -224,21 +296,28 @@ def query_decisions(
|
||||
query: str,
|
||||
rating: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
include_archived: bool = False,
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> list[dict]:
|
||||
"""Search active decisions, ranked by FTS5 BM25 relevance.
|
||||
"""Search decisions, ranked by FTS5 BM25 relevance.
|
||||
|
||||
Increments ``times_surfaced`` for every returned row.
|
||||
Increments ``times_surfaced`` for every returned row. Each result dict
|
||||
carries a computed ``superseded_by`` field: the id of the row that
|
||||
supersedes this one, or None. Combined with the ``supersedes`` column this
|
||||
lets callers render both pointer directions.
|
||||
|
||||
Args:
|
||||
query: FTS5 match query (keywords).
|
||||
rating: Optional exact rating filter (one of VALID_RATINGS).
|
||||
limit: Max rows to return (default 5).
|
||||
include_archived: When True, lift the ``status = 'active'`` filter so
|
||||
archived rows (the avoid-list) are searchable too. Default False —
|
||||
unchanged active-only behaviour.
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
A list of decision dicts (most relevant first). Each dict includes the
|
||||
rating and all useful fields.
|
||||
rating, all useful fields, and the computed ``superseded_by`` pointer.
|
||||
|
||||
Raises:
|
||||
ValueError: On empty query, bad rating filter, or non-positive limit.
|
||||
@@ -256,9 +335,10 @@ def query_decisions(
|
||||
FROM decisions_fts f
|
||||
JOIN decisions d ON d.id = f.rowid
|
||||
WHERE decisions_fts MATCH ?
|
||||
AND d.status = 'active'
|
||||
"""
|
||||
params: list = [query.strip()]
|
||||
if not include_archived:
|
||||
sql += " AND d.status = 'active'"
|
||||
if rating is not None:
|
||||
sql += " AND d.rating = ?"
|
||||
params.append(rating)
|
||||
@@ -269,24 +349,119 @@ def query_decisions(
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
results = [_row_to_dict(r) for r in rows]
|
||||
for r in results:
|
||||
r["superseded_by"] = None
|
||||
ids = [r["id"] for r in results]
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
# Reverse-lookup: which returned rows are pointed AT by a superseder?
|
||||
successors: dict = {}
|
||||
for row in conn.execute(
|
||||
f"SELECT id, supersedes FROM decisions WHERE supersedes IN ({placeholders})",
|
||||
ids,
|
||||
):
|
||||
successors[row["supersedes"]] = row["id"]
|
||||
conn.execute(
|
||||
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
conn.commit()
|
||||
# Reflect the increment in the returned dicts without a re-query.
|
||||
# Reflect the increment + attach the reverse pointer without re-query.
|
||||
for r in results:
|
||||
r["times_surfaced"] = (r["times_surfaced"] or 0) + 1
|
||||
r["superseded_by"] = successors.get(r["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] query %r rating=%s -> %d hit(s)", query, rating, len(results))
|
||||
logger.info(
|
||||
"[compass] query %r rating=%s include_archived=%s -> %d hit(s)",
|
||||
query,
|
||||
rating,
|
||||
include_archived,
|
||||
len(results),
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def find_conflicts(
|
||||
context: str,
|
||||
decision: str,
|
||||
limit: int = 3,
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> list[dict]:
|
||||
"""Return ACTIVE decisions whose text overlaps a would-be new entry.
|
||||
|
||||
A write-time, side-effect-free advisory helper: it does NOT increment
|
||||
``times_surfaced`` and never writes. The combined ``context + decision``
|
||||
text is sanitised (:func:`_sanitize_fts_query`) so no FTS5 syntax character
|
||||
can crash the MATCH. Returns up to ``limit`` active hits by BM25 relevance,
|
||||
or an empty list when the text has no usable tokens / nothing overlaps.
|
||||
|
||||
Args:
|
||||
context: The would-be new entry's context.
|
||||
decision: The would-be new entry's decision.
|
||||
limit: Max advisory hits to return (default 3).
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
A list of active decision dicts (most relevant first), possibly empty.
|
||||
"""
|
||||
match = _sanitize_fts_query(f"{context or ''} {decision or ''}")
|
||||
if match is None:
|
||||
return []
|
||||
|
||||
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||
sql = f"""
|
||||
SELECT {select_cols}
|
||||
FROM decisions_fts f
|
||||
JOIN decisions d ON d.id = f.rowid
|
||||
WHERE decisions_fts MATCH ?
|
||||
AND d.status = 'active'
|
||||
ORDER BY bm25(decisions_fts) ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(sql, (match, limit)).fetchall()
|
||||
results = [_row_to_dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] conflict-check -> %d active hit(s)", len(results))
|
||||
return results
|
||||
|
||||
|
||||
def set_note(
|
||||
decision_id: int,
|
||||
note: Optional[str],
|
||||
db_path: Optional[Path | str] = None,
|
||||
) -> bool:
|
||||
"""Set (replace) the note on an existing decision.
|
||||
|
||||
The FTS5 external-content ``decisions_au`` trigger re-indexes the row on
|
||||
UPDATE, so the new note is immediately searchable.
|
||||
|
||||
Args:
|
||||
decision_id: Target decision id.
|
||||
note: The note text to store (may be empty to clear).
|
||||
db_path: Optional DB path override.
|
||||
|
||||
Returns:
|
||||
True if a row was updated, False if no such id.
|
||||
"""
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("UPDATE decisions SET note = ? WHERE id = ?", (note, decision_id))
|
||||
conn.commit()
|
||||
changed = cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("[compass] note id=%s (changed=%s)", decision_id, changed)
|
||||
json_handler.log_operation("compass_note", {"id": decision_id, "changed": changed})
|
||||
return changed
|
||||
|
||||
|
||||
def stats(db_path: Optional[Path | str] = None) -> dict:
|
||||
"""Return decision counts by rating, by status, and the total.
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@ This module is the thin command layer (FPLAN P2). It parses args, calls the
|
||||
No business logic lives here — that's the handler's job.
|
||||
|
||||
Subcommands:
|
||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]
|
||||
query "question" [--rating R] [--limit N]
|
||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]
|
||||
query "question" [--rating R] [--limit N] [--include-archived]
|
||||
stats
|
||||
rate <id> <rating>
|
||||
archive <id>
|
||||
note <id> "text"
|
||||
review
|
||||
|
||||
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
||||
@@ -40,7 +41,7 @@ from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
console = err_console
|
||||
|
||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "review")
|
||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "note", "review")
|
||||
|
||||
# Console colour per rating — the rating is the signal, so make it pop.
|
||||
_RATING_STYLE = {
|
||||
@@ -59,6 +60,7 @@ HELP_TEXT = """\
|
||||
compass stats Counts by rating/status
|
||||
compass rate <id> <rating> Re-rate a decision
|
||||
compass archive <id> Archive a decision
|
||||
compass note <id> "text" Set a decision's note
|
||||
compass review Surface one to review
|
||||
compass --help Show this help
|
||||
|
||||
@@ -70,19 +72,43 @@ HELP_TEXT = """\
|
||||
--note "..." Optional human observation.
|
||||
--tags a,b,c Optional comma-separated tags.
|
||||
--source S Optional. devpulse (default) or user.
|
||||
--supersedes N Optional. Archive decision #N and link this entry as its
|
||||
correction (atomic). At add time, overlapping active
|
||||
entries are shown as a non-blocking advisory.
|
||||
|
||||
[bold]Options (query):[/bold]
|
||||
--rating R Optional exact-rating filter.
|
||||
--limit N Optional max results (default 5).
|
||||
--include-archived Also search archived (avoid-list) entries; archived hits
|
||||
show their status + supersession pointer.
|
||||
|
||||
[bold]Options (all subcommands):[/bold]
|
||||
--db PATH Use an alternate SQLite store (testing / power use).
|
||||
|
||||
[bold]Examples:[/bold]
|
||||
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
||||
drone @devpulse compass add "auth fork" "switch to sessions" --rating good --supersedes 4
|
||||
drone @devpulse compass query "auth" --rating good --limit 3
|
||||
drone @devpulse compass query "auth" --include-archived
|
||||
drone @devpulse compass stats
|
||||
drone @devpulse compass rate 4 bad
|
||||
drone @devpulse compass archive 4
|
||||
drone @devpulse compass note 4 "revisited — this held up"
|
||||
drone @devpulse compass review
|
||||
|
||||
See DPLAN-0212 (design) and the compass handler (apps/handlers/compass/).
|
||||
See DPLAN-0212 / DPLAN-0246 (design) and the compass handler (apps/handlers/compass/).
|
||||
"""
|
||||
|
||||
|
||||
_NOTE_HELP_TEXT = """\
|
||||
[bold]compass note[/bold] — set (replace) a decision's note
|
||||
|
||||
Usage:
|
||||
compass note <id> "text" Set the note on decision #<id>
|
||||
compass note --help Show this help
|
||||
|
||||
The note is re-indexed for search immediately — the FTS5 mirror stays in sync,
|
||||
so the new note text is findable by 'compass query' right away.
|
||||
"""
|
||||
|
||||
|
||||
@@ -93,7 +119,7 @@ def print_introspection() -> None:
|
||||
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
||||
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, review[/cyan]")
|
||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, note, review[/cyan]")
|
||||
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -141,6 +167,8 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
return _handle_rate(sub_args)
|
||||
if subcommand == "archive":
|
||||
return _handle_archive(sub_args)
|
||||
if subcommand == "note":
|
||||
return _handle_note(sub_args)
|
||||
if subcommand == "review":
|
||||
return _handle_review(sub_args)
|
||||
|
||||
@@ -179,6 +207,18 @@ def _extract_db_path(args: List[str]) -> tuple[List[str], Optional[str]]:
|
||||
return _extract_flag(args, "--db")
|
||||
|
||||
|
||||
def _extract_bool_flag(args: List[str], flag: str) -> tuple[List[str], bool]:
|
||||
"""Pull a valueless boolean ``--flag`` out of args.
|
||||
|
||||
Returns the remaining args (every occurrence of the flag removed) and True
|
||||
if the flag was present, else False. Unlike ``_extract_flag`` this consumes
|
||||
no following value.
|
||||
"""
|
||||
if flag in args:
|
||||
return [a for a in args if a != flag], True
|
||||
return args, False
|
||||
|
||||
|
||||
def _rating_tag(rating: str) -> str:
|
||||
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
||||
style = _RATING_STYLE.get(rating, "bold white")
|
||||
@@ -198,13 +238,16 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
rest, note = _extract_flag(rest, "--note")
|
||||
rest, tags = _extract_flag(rest, "--tags")
|
||||
rest, source = _extract_flag(rest, "--source")
|
||||
rest, supersedes_raw = _extract_flag(rest, "--supersedes")
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add arg-parse error: %s", exc)
|
||||
error(str(exc), suggestion="Use 'compass --help' for usage")
|
||||
return True
|
||||
|
||||
if len(rest) < 2:
|
||||
error('Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]')
|
||||
error(
|
||||
'Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]'
|
||||
)
|
||||
return True
|
||||
if rating is None:
|
||||
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
||||
@@ -213,6 +256,34 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
context = rest[0]
|
||||
decision = rest[1]
|
||||
|
||||
supersedes: Optional[int] = None
|
||||
if supersedes_raw is not None:
|
||||
try:
|
||||
supersedes = int(supersedes_raw)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add bad --supersedes %r: %s", supersedes_raw, exc)
|
||||
error(f"--supersedes must be an integer, got {supersedes_raw!r}")
|
||||
return True
|
||||
|
||||
# Write-time conflict check — a NON-BLOCKING advisory (DPLAN-0246). Skipped
|
||||
# when the writer already chose to supersede, and never allowed to block or
|
||||
# crash the add. Only shown when NOT already superseding.
|
||||
if supersedes is None:
|
||||
try:
|
||||
conflicts = compass.find_conflicts(context, decision, db_path=db_path)
|
||||
except Exception as exc: # advisory must never break a write
|
||||
logger.warning("[compass] conflict-check failed (non-blocking): %s", exc)
|
||||
conflicts = []
|
||||
for c in conflicts:
|
||||
cid = c.get("id")
|
||||
excerpt = (c.get("context") or "").strip()
|
||||
if len(excerpt) > 80:
|
||||
excerpt = excerpt[:77] + "..."
|
||||
console.print(
|
||||
f"[yellow]possible conflict with #{cid}[/yellow]: {excerpt} "
|
||||
f"[dim]— supersede? (--supersedes {cid})[/dim]"
|
||||
)
|
||||
|
||||
try:
|
||||
new_id = compass.add_decision(
|
||||
context,
|
||||
@@ -222,6 +293,7 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
tags=tags,
|
||||
source=source if source is not None else "devpulse",
|
||||
db_path=db_path,
|
||||
supersedes=supersedes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] add rejected: %s", exc)
|
||||
@@ -235,6 +307,8 @@ def _handle_add(sub_args: List[str]) -> bool:
|
||||
console.print(f" [cyan]note:[/cyan] {note}")
|
||||
if tags:
|
||||
console.print(f" [cyan]tags:[/cyan] {tags}")
|
||||
if supersedes is not None:
|
||||
console.print(f" [magenta]supersedes #{supersedes}[/magenta] [dim](archived)[/dim]")
|
||||
return True
|
||||
|
||||
|
||||
@@ -242,6 +316,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
||||
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
rest, include_archived = _extract_bool_flag(rest, "--include-archived")
|
||||
rest, rating = _extract_flag(rest, "--rating")
|
||||
rest, limit_raw = _extract_flag(rest, "--limit")
|
||||
except ValueError as exc:
|
||||
@@ -250,7 +325,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
if not rest:
|
||||
error('Usage: compass query "question" [--rating R] [--limit N]')
|
||||
error('Usage: compass query "question" [--rating R] [--limit N] [--include-archived]')
|
||||
return True
|
||||
|
||||
query_text = rest[0]
|
||||
@@ -265,7 +340,13 @@ def _handle_query(sub_args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
try:
|
||||
results = compass.query_decisions(query_text, rating=rating, limit=limit, db_path=db_path)
|
||||
results = compass.query_decisions(
|
||||
query_text,
|
||||
rating=rating,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
db_path=db_path,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] query rejected: %s", exc)
|
||||
error(str(exc))
|
||||
@@ -294,6 +375,16 @@ def _render_query_results(query_text: str, rating: Optional[str], results: List[
|
||||
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
||||
if r.get("tags"):
|
||||
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
||||
# Supersession pointers — an archived hit must never masquerade as
|
||||
# current truth, so flag its status + who replaced it (DPLAN-0246).
|
||||
if r.get("status") == "archived":
|
||||
superseded_by = r.get("superseded_by")
|
||||
if superseded_by:
|
||||
console.print(f" [bold yellow]ARCHIVED[/bold yellow] — superseded by #{superseded_by}")
|
||||
else:
|
||||
console.print(" [bold yellow]ARCHIVED[/bold yellow] (avoid-list)")
|
||||
if r.get("supersedes"):
|
||||
console.print(f" [magenta]supersedes #{r['supersedes']}[/magenta]")
|
||||
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
||||
console.print(f" [dim]{meta}[/dim]")
|
||||
console.print()
|
||||
@@ -389,6 +480,44 @@ def _handle_archive(sub_args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _handle_note(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass note <id> "text"`` — set a decision's note.
|
||||
|
||||
Follows the subcommand-help convention: ``compass note --help`` prints the
|
||||
per-subcommand help block; malformed input shows the Usage line.
|
||||
"""
|
||||
try:
|
||||
rest, db_path = _extract_db_path(sub_args)
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] note arg-parse error: %s", exc)
|
||||
error(str(exc))
|
||||
return True
|
||||
|
||||
if rest and rest[0] in ("--help", "-h", "help"):
|
||||
console.print(_NOTE_HELP_TEXT)
|
||||
return True
|
||||
|
||||
if len(rest) < 2:
|
||||
error('Usage: compass note <id> "text"')
|
||||
return True
|
||||
|
||||
try:
|
||||
decision_id = int(rest[0])
|
||||
except ValueError as exc:
|
||||
logger.warning("[compass] note bad id %r: %s", rest[0], exc)
|
||||
error(f"<id> must be an integer, got {rest[0]!r}")
|
||||
return True
|
||||
|
||||
note_text = rest[1]
|
||||
changed = compass.set_note(decision_id, note_text, db_path=db_path)
|
||||
if changed:
|
||||
console.print(f"[green]Note set[/green] on [bold]#{decision_id}[/bold]")
|
||||
console.print(f" [cyan]note:[/cyan] {note_text}")
|
||||
else:
|
||||
warning(f"No decision with id {decision_id} — nothing changed.")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_review(sub_args: List[str]) -> bool:
|
||||
"""Dispatch ``compass review`` — surface one active decision to review."""
|
||||
try:
|
||||
|
||||
@@ -275,3 +275,176 @@ def test_flag_without_value_errors(capsys, db):
|
||||
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "rating" in out and "value" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# supersedes — atomic archive + link, both pointer directions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_supersedes_archives_and_links(capsys, db):
|
||||
"""add --supersedes N archives #N, links the new row, shows 'supersedes #N'."""
|
||||
old = _add(capsys, db, "old ctx sessions", "use sessions", "good")
|
||||
capsys.readouterr()
|
||||
assert (
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
[
|
||||
"add",
|
||||
"new ctx jwt",
|
||||
"switch to jwt",
|
||||
"--rating",
|
||||
"good",
|
||||
"--supersedes",
|
||||
str(old),
|
||||
"--db",
|
||||
db,
|
||||
],
|
||||
)
|
||||
is True
|
||||
)
|
||||
out = _output(capsys)
|
||||
assert f"supersedes #{old}" in out
|
||||
|
||||
# The archived row is gone from the default (active-only) query...
|
||||
q = _query_out(capsys, db, "sessions")
|
||||
assert "0 result(s)" in q
|
||||
|
||||
# ...but --include-archived surfaces it WITH its status + forward pointer.
|
||||
q2 = _query_out(capsys, db, "sessions", "--include-archived")
|
||||
assert "ARCHIVED" in q2.upper()
|
||||
assert "superseded by #" in q2.lower()
|
||||
|
||||
|
||||
def test_add_supersedes_bad_id_errors_no_write(capsys, db):
|
||||
"""add --supersedes to a missing id errors and writes nothing."""
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "ctx", "dec", "--rating", "good", "--supersedes", "9999", "--db", db],
|
||||
)
|
||||
out = _output(capsys).lower()
|
||||
assert "9999" in out
|
||||
assert "total decisions: 0" in _stats_out(capsys, db).lower()
|
||||
|
||||
|
||||
def test_add_supersedes_non_integer_errors(capsys, db):
|
||||
"""A non-integer --supersedes fails loud."""
|
||||
assert (
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "ctx", "dec", "--rating", "good", "--supersedes", "abc", "--db", db],
|
||||
)
|
||||
is True
|
||||
)
|
||||
out = _output(capsys).lower()
|
||||
assert "supersedes" in out and "integer" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write-time conflict advisory — non-blocking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_conflict_advisory_prints_but_does_not_block(capsys, db):
|
||||
"""An overlapping active row triggers an advisory; the add still succeeds."""
|
||||
_add(capsys, db, "caching layer strategy", "add redis caching", "good")
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
["add", "caching approach again", "another caching layer", "--rating", "good", "--db", db],
|
||||
)
|
||||
out = _output(capsys)
|
||||
assert "possible conflict" in out.lower()
|
||||
assert "--supersedes" in out # advisory hints the fix
|
||||
assert "Added decision" in out # NON-BLOCKING: still added
|
||||
|
||||
|
||||
def test_add_no_conflict_on_empty_store(capsys, db):
|
||||
"""First add on an empty store prints no advisory."""
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command("compass", ["add", "unique ctx", "unique dec", "--rating", "good", "--db", db])
|
||||
out = _output(capsys).lower()
|
||||
assert "possible conflict" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# note — set a note, prove it is immediately searchable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_note_command_sets_and_is_searchable(capsys, db):
|
||||
"""note <id> "text" sets the note; a later query finds the new note text."""
|
||||
did = _add(capsys, db, "note cmd ctx", "note cmd dec", "good")
|
||||
capsys.readouterr()
|
||||
assert compass_cmd.handle_command("compass", ["note", str(did), "findme pterodactyl", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "note set" in out
|
||||
|
||||
q = _query_out(capsys, db, "pterodactyl")
|
||||
assert "1 result(s)" in q
|
||||
|
||||
|
||||
def test_note_help(capsys):
|
||||
"""compass note --help prints per-subcommand usage."""
|
||||
assert compass_cmd.handle_command("compass", ["note", "--help"]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "note" in out and "usage" in out
|
||||
|
||||
|
||||
def test_note_missing_id_warns(capsys, db):
|
||||
"""note on a non-existent id reports nothing changed, does not crash."""
|
||||
assert compass_cmd.handle_command("compass", ["note", "999", "text", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "999" in out and ("nothing changed" in out or "no decision" in out)
|
||||
|
||||
|
||||
def test_note_missing_args_shows_usage(capsys, db):
|
||||
"""note with too few args shows usage."""
|
||||
assert compass_cmd.handle_command("compass", ["note", "5", "--db", db]) is True
|
||||
out = _output(capsys).lower()
|
||||
assert "usage" in out
|
||||
|
||||
|
||||
def test_help_and_introspection_list_note(capsys):
|
||||
"""Both --help and bare introspection advertise the note subcommand."""
|
||||
compass_cmd.handle_command("compass", ["--help"])
|
||||
assert "note" in _output(capsys).lower()
|
||||
compass_cmd.handle_command("compass", [])
|
||||
assert "note" in _output(capsys).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --include-archived — archived hits must show status + supersession pointer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_include_archived_shows_archived_pointer(capsys, db):
|
||||
"""--include-archived surfaces an archived row flagged with its successor."""
|
||||
old = _add(capsys, db, "archived-visible ctx", "the old choice", "bad")
|
||||
capsys.readouterr()
|
||||
compass_cmd.handle_command(
|
||||
"compass",
|
||||
[
|
||||
"add",
|
||||
"replacement ctx",
|
||||
"the new choice",
|
||||
"--rating",
|
||||
"good",
|
||||
"--supersedes",
|
||||
str(old),
|
||||
"--db",
|
||||
db,
|
||||
],
|
||||
)
|
||||
capsys.readouterr()
|
||||
|
||||
# Default query hides the archived row.
|
||||
q = _query_out(capsys, db, "old choice")
|
||||
assert "0 result(s)" in q
|
||||
|
||||
# With the flag it appears, unmistakably marked archived + superseded.
|
||||
q2 = _query_out(capsys, db, "old choice", "--include-archived")
|
||||
assert "1 result(s)" in q2
|
||||
assert "archived" in q2.lower()
|
||||
assert "superseded by #" in q2.lower()
|
||||
|
||||
@@ -283,3 +283,161 @@ class TestInputValidation:
|
||||
compass.add_decision("ctx", "dec", "good", db_path=db)
|
||||
with pytest.raises(ValueError):
|
||||
compass.query_decisions("ctx", limit=0, db_path=db)
|
||||
|
||||
|
||||
class TestSupersedes:
|
||||
"""supersedes column, atomic archive+link, idempotent migration (DPLAN-0246)."""
|
||||
|
||||
def test_add_with_supersedes_archives_and_links(self, db):
|
||||
"""--supersedes links the corrector AND archives the target, atomically."""
|
||||
old = compass.add_decision("old auth ctx", "use sessions", "good", db_path=db)
|
||||
new = compass.add_decision("new auth ctx", "switch to JWT", "good", db_path=db, supersedes=old)
|
||||
# The corrector row links back to what it replaced.
|
||||
hit = compass.query_decisions("JWT", db_path=db)[0]
|
||||
assert hit["supersedes"] == old
|
||||
# The old entry is archived → gone from the active query.
|
||||
assert compass.query_decisions("sessions", db_path=db) == []
|
||||
# With include_archived it reappears, pointing FORWARD to its successor.
|
||||
arch = compass.query_decisions("sessions", include_archived=True, db_path=db)[0]
|
||||
assert arch["status"] == "archived"
|
||||
assert arch["superseded_by"] == new
|
||||
|
||||
def test_supersedes_nonexistent_raises_no_partial_write(self, db):
|
||||
"""A bad --supersedes id errors cleanly and leaves NO partial write."""
|
||||
with pytest.raises(ValueError):
|
||||
compass.add_decision("ctx", "dec", "good", db_path=db, supersedes=9999)
|
||||
assert compass.stats(db_path=db)["total"] == 0
|
||||
|
||||
def test_supersedes_defaults_none(self, db):
|
||||
"""A plain add has supersedes=None and superseded_by=None."""
|
||||
compass.add_decision("plain ctx", "plain dec", "good", db_path=db)
|
||||
hit = compass.query_decisions("plain", db_path=db)[0]
|
||||
assert hit["supersedes"] is None
|
||||
assert hit["superseded_by"] is None
|
||||
|
||||
def test_active_hit_shows_its_supersedes_pointer(self, db):
|
||||
"""An ACTIVE corrector still exposes its supersedes pointer on query."""
|
||||
old = compass.add_decision("legacy topic zzz", "old way", "bad", db_path=db)
|
||||
compass.add_decision("current topic zzz", "new way", "good", db_path=db, supersedes=old)
|
||||
hit = compass.query_decisions("current", db_path=db)[0]
|
||||
assert hit["supersedes"] == old
|
||||
assert hit["superseded_by"] is None # nothing supersedes the corrector
|
||||
|
||||
def test_migration_idempotent_repeated_connects(self, db):
|
||||
"""Re-opening the DB re-runs migration harmlessly; column stays present."""
|
||||
compass.add_decision("ctx one", "dec one", "good", db_path=db)
|
||||
for _ in range(3):
|
||||
conn = store._connect(db)
|
||||
try:
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||
finally:
|
||||
conn.close()
|
||||
assert "supersedes" in cols
|
||||
|
||||
def test_migration_adds_column_to_legacy_db(self, db):
|
||||
"""A pre-supersedes DB gets the column added in via _connect migration."""
|
||||
# Build a legacy `decisions` table WITHOUT supersedes, as older builds had.
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute(
|
||||
"CREATE TABLE decisions ("
|
||||
"id INTEGER PRIMARY KEY, created TEXT, context TEXT NOT NULL, "
|
||||
"decision TEXT NOT NULL, rating TEXT NOT NULL, note TEXT, tags TEXT, "
|
||||
"source TEXT, score INTEGER, status TEXT DEFAULT 'active', "
|
||||
"last_reviewed TEXT, times_surfaced INTEGER DEFAULT 0)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Legacy table lacks the column...
|
||||
conn = sqlite3.connect(str(db))
|
||||
pre = {r[1] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||
conn.close()
|
||||
assert "supersedes" not in pre
|
||||
# ...a store connect migrates it in (CREATE IF NOT EXISTS is a no-op here).
|
||||
conn = store._connect(db)
|
||||
try:
|
||||
post = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||
finally:
|
||||
conn.close()
|
||||
assert "supersedes" in post
|
||||
|
||||
|
||||
class TestFindConflicts:
|
||||
"""Write-time conflict check: FTS over ACTIVE rows, sanitized, side-effect-free."""
|
||||
|
||||
def test_finds_overlapping_active_row(self, db):
|
||||
"""A would-be entry surfaces an existing active row it overlaps."""
|
||||
compass.add_decision("caching strategy for the API", "add a redis caching layer", "good", db_path=db)
|
||||
hits = compass.find_conflicts("caching approach", "use a caching layer", db_path=db)
|
||||
assert len(hits) >= 1
|
||||
assert any("caching" in h["context"] for h in hits)
|
||||
|
||||
def test_ignores_archived_rows(self, db):
|
||||
"""Conflict check searches active rows only — archived never surfaces."""
|
||||
did = compass.add_decision("archived topic xyzzy", "some decision", "good", db_path=db)
|
||||
compass.archive(did, db_path=db)
|
||||
assert compass.find_conflicts("xyzzy topic", "another decision", db_path=db) == []
|
||||
|
||||
def test_no_side_effects_on_times_surfaced(self, db):
|
||||
"""The advisory must NOT bump times_surfaced — it is not a real surface."""
|
||||
compass.add_decision("surfacing guard ctx", "a decision here", "good", db_path=db)
|
||||
compass.find_conflicts("surfacing guard", "a decision", db_path=db)
|
||||
hit = compass.query_decisions("surfacing", db_path=db)[0]
|
||||
assert hit["times_surfaced"] == 1 # only the query above counted
|
||||
|
||||
def test_sanitizes_fts_special_chars(self, db):
|
||||
"""Raw FTS5 syntax characters must not crash MATCH — sanitized to literals."""
|
||||
compass.add_decision("special ctx", "a normal decision", "good", db_path=db)
|
||||
weird = 'broken " ( ) * : query -term AND OR NOT'
|
||||
result = compass.find_conflicts(weird, "more * (text) ^caret", db_path=db)
|
||||
assert isinstance(result, list) # no exception raised
|
||||
|
||||
def test_empty_text_returns_empty(self, db):
|
||||
"""Text with no usable tokens yields no conflicts (and no crash)."""
|
||||
assert compass.find_conflicts(" ", " ", db_path=db) == []
|
||||
|
||||
|
||||
class TestSetNote:
|
||||
"""note edits persist AND re-index immediately via the FTS5 UPDATE trigger."""
|
||||
|
||||
def test_set_note_updates_and_returns_true(self, db):
|
||||
"""set_note stores the note and reports the row was changed."""
|
||||
did = compass.add_decision("note ctx", "note dec", "good", db_path=db)
|
||||
assert compass.set_note(did, "a fresh observation", db_path=db) is True
|
||||
hit = compass.query_decisions("note ctx", db_path=db)[0]
|
||||
assert hit["note"] == "a fresh observation"
|
||||
|
||||
def test_note_edit_is_immediately_fts_searchable(self, db):
|
||||
"""PROOF: the decisions_au trigger re-indexes a note UPDATE for FTS."""
|
||||
did = compass.add_decision("indexing ctx", "indexing dec", "good", db_path=db)
|
||||
# 'zebra' appears nowhere yet.
|
||||
assert compass.query_decisions("zebra", db_path=db) == []
|
||||
compass.set_note(did, "mentions zebra now", db_path=db)
|
||||
# Immediately findable through the freshly re-indexed note column.
|
||||
found = compass.query_decisions("zebra", db_path=db)
|
||||
assert len(found) == 1
|
||||
assert found[0]["id"] == did
|
||||
|
||||
def test_set_note_missing_id_returns_false(self, db):
|
||||
"""set_note on a non-existent id returns False (no silent create)."""
|
||||
assert compass.set_note(9999, "nope", db_path=db) is False
|
||||
|
||||
|
||||
class TestScoreRemoved:
|
||||
"""score is gone from every Python surface (DPLAN-0246 seedgo ruling)."""
|
||||
|
||||
def test_score_not_in_decision_columns(self):
|
||||
"""The code-level column list no longer names score."""
|
||||
assert "score" not in store._DECISION_COLUMNS
|
||||
|
||||
def test_score_absent_from_query_dict(self, db):
|
||||
"""Query result dicts carry no score key."""
|
||||
compass.add_decision("score ctx", "score dec", "good", db_path=db)
|
||||
hit = compass.query_decisions("score", db_path=db)[0]
|
||||
assert "score" not in hit
|
||||
|
||||
def test_score_absent_from_review_dict(self, db):
|
||||
"""Review result dicts carry no score key."""
|
||||
compass.add_decision("review score ctx", "dec", "good", db_path=db)
|
||||
result = compass.review(db_path=db)
|
||||
assert result is not None
|
||||
assert "score" not in result
|
||||
|
||||
Reference in New Issue
Block a user