# Active Memory (Promotion Gate + Solved Questions) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the tht memory system *active*: (A) a reviewer gate at end-of-workflow (F8) that proposes the session's reusable decisions for promotion into the global memory, and (B) automatic indexing of the question→SQL pair at `tht session finalize`, with recall commands consumable in F4/F6/F7. **Architecture:** Part A adds two decision types (`memory_promoted`/`memory_promotion_declined`), a declined-candidates filter in `tht/memory.py`, and a new deterministic gate tool `reviewer_memory_promote` in `tht-gate.js` that fetches candidates via `tht memory promote --preview --json`, shows a pre-selected multiselect, then persists via `tht memory save-one` + ledger decisions. Part B adds a `solved_question` vector kind (stored in the existing `memory` pgvector table — **no server DDL**), a `tht/solved.py` module (record build + D11-style one-row upsert), two CLI commands (`tht memory solved-index`, `tht memory solved-search`), and a best-effort hook in `finalize_cmd`. SKILL.md is updated to prescribe both flows. **Tech Stack:** Python (typer CLI, pydantic, pytest in `harness/.venv`), JavaScript (Pi extension, `node:test` via `npm test` in `harness/`). No frontend changes: the promotion gate reuses the existing `multiselect` widget descriptor already rendered by the browser. ## Global Constraints - `tht`'s `-c`/`--config` is a PER-COMMAND option: always AFTER the subcommand (project gotcha; the gate never passes it — config resolves from env/default). - `--json` output must be pristine: only valid JSON on stdout (warnings go to stderr with `err=True`). - User-facing CLI/gate copy is Italian (match existing messages); SKILL.md prose is English with Italian example content (match existing file). - Python: ruff line-length 100 (`.venv/bin/ruff check .` from `harness/`). No ESLint on JS; match existing tab-indented style in `tht-gate.js`. - Run Python tests with `harness/.venv/bin/pytest -q` (l2 excluded by default); JS gate tests with `npm test` from `harness/`. - Do NOT touch `frontend/` or `backend/`. - The pgvector is remote: workstation writes go ONLY through `vector_write_rest` (writer key, upsert-only, no deletes). Never introduce a `VectorStore.sync` call for `solved_question` records (its delete-stale step would wipe other sessions' records). - The model must never bypass the gate: promotion CLI commands get added to the bash `FORBIDDEN` list (the gate itself calls the CLI via `execFileSync`, which the hook does not intercept). ## File Structure | File | Change | Responsibility | |---|---|---| | `harness/tht/decisions.py` | modify | +2 decision types | | `harness/workflow.yaml` | modify | F8 `emits` the new types | | `harness/tht/memory.py` | modify | declined-seq filter; `_question_context` → public `question_context` | | `harness/.pi/extensions/tht-gate.js` | modify | `reviewer_memory_promote` tool + pure helpers + FORBIDDEN | | `harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js` | create | L1 tests for the pure helpers | | `harness/tht/solved.py` | create | solved-question record build + one-row upsert | | `harness/tht/vectorstore/reader.py` | modify | `solved_question` → `memory` table mapping | | `harness/tht/vectorstore/rest_writer.py` | modify | allow kind in `memory` table | | `harness/tht/cli/memory_cmd.py` | modify | `solved-index`, `solved-search`, `index_solved_session` | | `harness/tht/cli/session_cmd.py` | modify | best-effort indexing hook in `finalize_cmd` | | `harness/.pi/skills/tht-sessione/SKILL.md` | modify | prescribe promotion (F2/F5/F8) + recall (F4/F6/F7, Session end) | | `harness/tests/test_memory_promotion.py` | create | Tasks 1–2 tests | | `harness/tests/test_solved_question.py` | create | Task 5 tests | | `harness/tests/test_solved_build.py` | create | Task 6 tests | | `PROJECT_STATE.md` | modify | snapshot entry | Design notes locked in: - **Promotion runs INSIDE F8** (after the datamart decision, before `reviewer_confirm kind:"phase"`), so the ledger decisions are recorded at phase 8 and the workflow closes with a complete audit trail before finalize. The reusable types were each individually reviewer-approved earlier, and `sql_approved` already exists by end of F7 — no need to wait for the finalize battery. - **Declined candidates carry `detail: "seq:"`** on the `memory_promotion_declined` decision; `declined_promotion_seqs()` parses it so a re-opened promotion gate never re-proposes them. Promoted ones are already deduped by the registry key `(session_id, decision_seq)`. - **`solved_question` reuses the `memory` pgvector table** (kinds already share tables: `schema_table`/`schema_column` share `schema_records`). `KIND_TO_TABLE`/`TABLE_TO_KINDS` get the mapping; the REST reader filters by kind client-side. No migration. - **Embedding = the rewritten question only** (`record.content`); the SQL lives in `metadata`. The dedup hash therefore covers question+SQL (`_solved_hash`), so a re-finalize that changes only the SQL still updates the row. `solved_question` records never flow through `sync()` (documented in the module). - **Finalize never fails on indexing**: the hook wraps `index_solved_session` in try/except and degrades to a yellow warning (missing writer key, VPN down, etc.). - The integration test `tests/integration/test_gate_cli_signatures.py` extracts gate CLI call sites automatically (`memory` is already in `_GROUPS`), so the new gate calls are covered without editing it. --- ### Task 1: Decision types + workflow emits **Files:** - Modify: `harness/tht/decisions.py` (the `DecisionType` Literal, after `"datamart_declined",`) - Modify: `harness/workflow.yaml` (F8 `emits`) - Test: `harness/tests/test_memory_promotion.py` (create) **Interfaces:** - Produces: decision types `"memory_promoted"` and `"memory_promotion_declined"` valid in `DecisionRecord` and accepted by `tht decision add` from phase 8 (`Workflow.decision_min_phase(...) == 8`). Convention consumed by Tasks 2–3: `subject` = the original decision's subject, `detail` = `"seq:"`. - [ ] **Step 1: Write the failing tests** ```python """L1: gate di promozione memorie (F8) — tipi di decisione + filtro candidati. La promozione era solo CLI facoltativa (mai innescata): il gate reviewer_memory_promote la rende un passo del workflow. Questi test fissano il contratto harness-side: - i due nuovi decision type esistono e sono ammessi dalla Fase 8 (workflow.yaml emits) - i candidati rifiutati al gate (memory_promotion_declined, detail "seq:") non vengono riproposti da reusable_promotions/preview_promotions. """ from datetime import datetime from tht.decisions import DecisionRecord from tht.workflow import load_workflow def test_promotion_decision_types_are_valid(): for t in ("memory_promoted", "memory_promotion_declined"): d = DecisionRecord( seq=1, ts=datetime(2026, 1, 1), type=t, subject="fact_x", detail="seq:5" ) assert d.type == t def test_promotion_decision_types_min_phase_is_f8(): wf = load_workflow() assert wf.decision_min_phase("memory_promoted") == 8 assert wf.decision_min_phase("memory_promotion_declined") == 8 ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py -v` Expected: FAIL — pydantic `ValidationError` (type not in Literal) on the first test; `decision_min_phase` returning 1 on the second. - [ ] **Step 3: Add the two types to `DecisionType`** In `harness/tht/decisions.py`, after the lines ```python "datamart_requested", "datamart_declined", ``` insert: ```python # F8: promozione memorie riusabili al gate reviewer_memory_promote. subject = # subject della decisione originale, detail = "seq:" (usato da # declined_promotion_seqs per non riproporre i candidati rifiutati). "memory_promoted", "memory_promotion_declined", ``` - [ ] **Step 4: Add the types to F8's `emits` in `harness/workflow.yaml`** Replace: ```yaml emits: [datamart_requested, datamart_declined] ``` with: ```yaml emits: [datamart_requested, datamart_declined, memory_promoted, memory_promotion_declined] ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py -v` Expected: 2 PASS - [ ] **Step 6: Run the full L1 suite + lint (no regressions)** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .` Expected: all pass, no lint errors. - [ ] **Step 7: Commit** ```bash git add harness/tht/decisions.py harness/workflow.yaml harness/tests/test_memory_promotion.py git commit -m "feat(memory): memory_promoted/memory_promotion_declined decision types (F8 emits)" ``` --- ### Task 2: Declined-candidates filter in memory.py **Files:** - Modify: `harness/tht/memory.py` - Test: `harness/tests/test_memory_promotion.py` (extend) **Interfaces:** - Consumes: the `detail: "seq:"` convention from Task 1. - Produces: `declined_promotion_seqs(decisions: list[DecisionRecord]) -> set[int]`; `reusable_promotions(...)` (same signature) now excludes declined seqs; `question_context(decisions, manifest) -> str` (public rename of `_question_context`, consumed by Task 6). - [ ] **Step 1: Write the failing tests (append to `tests/test_memory_promotion.py`)** ```python from tht.decisions import append_decision from tht.memory import declined_promotion_seqs, reusable_promotions from tht.session.models import SessionManifest def _manifest() -> SessionManifest: return SessionManifest( id="s1", created_at=datetime(2026, 1, 1), question="domanda originale", database="db", schema="public", ) def test_declined_promotion_seqs_parses_seq_detail(): d = DecisionRecord( seq=9, ts=datetime(2026, 1, 1), type="memory_promotion_declined", subject="fact_x", detail="seq:5", ) assert declined_promotion_seqs([d]) == {5} def test_declined_promotion_seqs_ignores_malformed_and_other_types(): ds = [ DecisionRecord(seq=1, ts=datetime(2026, 1, 1), type="memory_promotion_declined", subject="x", detail=""), DecisionRecord(seq=2, ts=datetime(2026, 1, 1), type="table_promoted", subject="x", detail="seq:3"), ] assert declined_promotion_seqs(ds) == set() def test_reusable_promotions_exclude_declined(tmp_path): append_decision(tmp_path, type="table_promoted", subject="fact_a", detail="tab principale", rationale="scelta reviewer") # seq 1 append_decision(tmp_path, type="concept_clarified", subject="attivo", detail="flag_attivo = TRUE") # seq 2 append_decision(tmp_path, type="memory_promotion_declined", subject="fact_a", detail="seq:1") # seq 3 cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl") assert [c.decision_seq for c in cand] == [2] ``` - [ ] **Step 2: Run to verify they fail** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py -v` Expected: FAIL with `ImportError: cannot import name 'declined_promotion_seqs'`. - [ ] **Step 3: Implement in `harness/tht/memory.py`** (a) After the `decided_memory_ids` function, add: ```python _DECLINED_SEQ_RE = re.compile(r"\bseq:(\d+)\b") def declined_promotion_seqs(decisions: list[DecisionRecord]) -> set[int]: """decision_seq dei candidati che il reviewer ha rifiutato al gate di promozione (F8, `memory_promotion_declined` con detail "seq:"): una riapertura del gate non deve riproporli. I promossi sono gia' dedupati dal registro.""" out: set[int] = set() for d in decisions: if d.type != "memory_promotion_declined": continue m = _DECLINED_SEQ_RE.search(d.detail or "") if m: out.add(int(m.group(1))) return out ``` (b) Rename `_question_context` → `question_context` (public: Task 6's solved-record build reuses it) and update its one internal call site in `_compute_promotions`: ```python def question_context(decisions: list[DecisionRecord], manifest: SessionManifest) -> str: rewritten = [d for d in decisions if d.type == "question_rewritten"] return rewritten[-1].detail if rewritten else manifest.question ``` (in `_compute_promotions`: `context = question_context(decisions, manifest)`) (c) Replace the body of `reusable_promotions` with: ```python def reusable_promotions( session_dir: Path, manifest: SessionManifest, registry_path: Path ) -> list[MemoryRecord]: """Candidati riusabili (tipi in REUSABLE_TYPES) non ancora promossi ne' rifiutati al gate, SENZA cap: il chiamante applica MAX_PROMOTION_CANDIDATES e segnala il troncamento.""" from tht.phase import effective_decisions cand = _compute_promotions( session_dir, manifest, seqs=None, existing=load_registry(registry_path) ) declined = declined_promotion_seqs(effective_decisions(session_dir)) return [ c for c in cand if c.type in REUSABLE_TYPES and c.decision_seq not in declined ] ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py tests/test_memory_metadata.py tests/test_memory_save_one.py -v` Expected: all PASS (the rename must not break existing memory tests). - [ ] **Step 5: Full suite + lint** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add harness/tht/memory.py harness/tests/test_memory_promotion.py git commit -m "feat(memory): filter gate-declined candidates from promotion preview" ``` --- ### Task 3: Gate tool `reviewer_memory_promote` **Files:** - Modify: `harness/.pi/extensions/tht-gate.js` - Test: `harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js` (create) **Interfaces:** - Consumes: `tht memory promote --session --preview --json` (existing; emits `[{decision_seq, type, subject, detail, rationale, question_context, tables, concepts}]`), `tht memory save-one --session --decision --json` (existing), `decisionAddArgs` / `buildMultiselectRequest` / `emitAndWait` / `relayIfThtFails` (existing in-file), decision types from Task 1. - Produces: registered Pi tool `reviewer_memory_promote {session: string}`; exported pure helpers `promotionOptions(candidates)`, `promotionContent(candidates)`, `splitPromotionChoices(candidates, choices)`. - [ ] **Step 1: Write the failing JS tests** Create `harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js`: ```js const test = require("node:test"); const assert = require("node:assert"); const { promotionOptions, promotionContent, splitPromotionChoices, } = require("../../tht-gate.js"); // F8 memory-promotion gate: the candidates are DETERMINISTIC (computed by // `tht memory promote --preview --json`), the model only names the session. // These tests pin the pure candidate->widget mapping and the choice partition. const CANDIDATES = [ { decision_seq: 3, type: "table_promoted", subject: "fact_seeablazione", detail: "tabella principale ablazioni", rationale: "scelta dal reviewer", question_context: "quante ablazioni nel 2023", tables: ["fact_seeablazione"], concepts: [] }, { decision_seq: 5, type: "concept_clarified", subject: "paziente attivo", detail: "flag_attivo = TRUE", rationale: "", question_context: "quante ablazioni nel 2023", tables: [], concepts: ["paziente attivo"] }, ]; test("promotionOptions maps candidates to seq-keyed options", () => { assert.deepEqual(promotionOptions(CANDIDATES), [ { id: "seq-3", label: "table_promoted: fact_seeablazione" }, { id: "seq-5", label: "concept_clarified: paziente attivo" }, ]); }); test("promotionContent lists every candidate with detail and context", () => { const c = promotionContent(CANDIDATES); assert.ok(c.includes("fact_seeablazione")); assert.ok(c.includes("flag_attivo = TRUE")); assert.ok(c.includes("quante ablazioni nel 2023")); }); test("splitPromotionChoices partitions by selection", () => { const { promote, decline } = splitPromotionChoices(CANDIDATES, ["seq-5"]); assert.deepEqual(promote.map((c) => c.decision_seq), [5]); assert.deepEqual(decline.map((c) => c.decision_seq), [3]); }); test("empty or missing choices declines everything", () => { assert.equal(splitPromotionChoices(CANDIDATES, []).decline.length, 2); assert.equal(splitPromotionChoices(CANDIDATES, undefined).decline.length, 2); }); ``` - [ ] **Step 2: Run to verify they fail** Run: `cd /Users/mp/projects/ThothII/harness && npm test` Expected: the new file FAILS (`promotionOptions is not a function`); the rest of the suite passes. - [ ] **Step 3: Add the pure helpers to `tht-gate.js`** Immediately after the `shouldSkipEmptyDecide` export, add: ```js // --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------ // The candidates come from `tht memory promote --preview --json` (deterministic, // reviewer-approved decisions only); the model never authors them. export function promotionOptions(candidates) { return candidates.map((c) => ({ id: `seq-${c.decision_seq}`, label: `${c.type}: ${c.subject}`, })); } export function promotionContent(candidates) { return candidates .map( (c) => `- **${c.type}: ${c.subject}** (decisione #${c.decision_seq})\n` + ` ${c.detail || ""}\n` + ` Motivo: ${c.rationale || "—"}\n` + ` Domanda di contesto: ${c.question_context || "—"}`, ) .join("\n"); } export function splitPromotionChoices(candidates, choices) { const chosen = new Set(choices ?? []); const promote = []; const decline = []; for (const c of candidates) { (chosen.has(`seq-${c.decision_seq}`) ? promote : decline).push(c); } return { promote, decline }; } ``` - [ ] **Step 4: Run the JS tests to verify they pass** Run: `cd /Users/mp/projects/ThothII/harness && npm test` Expected: all PASS. - [ ] **Step 5: Register the tool** In `tht-gate.js`, immediately BEFORE the `pi.registerTool({ name: "rewrite_question", ...})` block, add: ```js pi.registerTool({ name: "reviewer_memory_promote", label: "Promozione memorie riusabili (reviewer)", description: "F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " + "calcolati dalla CLI (tht memory promote --preview: tipi riusabili concept_clarified/" + "table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " + "vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " + "le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " + "alla sessione: i candidati sono deterministici, NON li scrivi tu.", parameters: Type.Object({ session: Type.String(), }), async execute(_id, params, _signal, _onUpdate, ctx) { lockActive = true; const { session } = params; const phase = phaseId(ctx, currentPhase(ctx, session)); let candidates; try { candidates = JSON.parse( tht(ctx, ["memory", "promote", "--session", session, "--preview", "--json"]), ); } catch (e) { const msg = (e.stderr || e.message || String(e)).toString().trim(); return textResult(`Preview di promozione non disponibile: ${msg}`); } if (!Array.isArray(candidates) || candidates.length === 0) { await ctx.ui.notify( "Nessuna decisione riusabile da promuovere in memoria per questa sessione.", "info", ); return textResult( "Nessun candidato di promozione: prosegui con la chiusura della sessione.", ); } const options = promotionOptions(candidates); const widget = buildMultiselectRequest({ id: `u${Date.now()}`, phase, title: "Quali decisioni salvare nella memoria riutilizzabile?", allowEmpty: true, options, selected: options.map((o) => o.id), content: promotionContent(candidates), }); const resp = await emitAndWait(ctx, widget); if (resp.control === "freetext") return textResult(`Altro (reviewer): ${resp.text}. Valuta e ripresenta il gate.`); if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); const { promote, decline } = splitPromotionChoices(candidates, resp.choices); let saved = 0; for (const c of promote) { const err = relayIfThtFails( ctx, ["memory", "save-one", "--session", session, "--decision", String(c.decision_seq), "--json"], "", ); if (err) return err; const e2 = relayIfThtFails(ctx, decisionAddArgs(session, { type: "memory_promoted", subject: c.subject, detail: `seq:${c.decision_seq}`, rationale: c.rationale || c.detail || "", }), ""); if (e2) return e2; saved++; } for (const c of decline) { const err = relayIfThtFails(ctx, decisionAddArgs(session, { type: "memory_promotion_declined", subject: c.subject, detail: `seq:${c.decision_seq}`, }), ""); if (err) return err; } return textResult( `Promozione registrata: ${saved} memorie salvate nel vectordb, ` + `${decline.length} candidati scartati.`, ); }, }); ``` - [ ] **Step 6: Extend the anti-bypass FORBIDDEN list** In `tht-gate.js`, change: ```js const FORBIDDEN = [ /\btht\s+phase\s+(advance|reopen)\b/, /\btht\s+decision\s+add\b/, /\btht\s+cte\s+plan\b/, ]; ``` to: ```js const FORBIDDEN = [ /\btht\s+phase\s+(advance|reopen)\b/, /\btht\s+decision\s+add\b/, /\btht\s+cte\s+plan\b/, // La promozione in memoria passa dal reviewer (reviewer_memory_promote), mai da shell. /\btht\s+memory\s+(promote|save-one)\b/, ]; ``` (The gate's own calls use `execFileSync` directly and are not intercepted by the bash `tool_call` hook.) - [ ] **Step 7: Run JS tests + the gate CLI signature integration test** Run: `cd /Users/mp/projects/ThothII/harness && npm test && .venv/bin/pytest tests/integration/test_gate_cli_signatures.py -v` Expected: all PASS — the signature test auto-extracts the two new `["memory", ...]` call sites and verifies `--session/--preview/--json/--decision` exist on the CLI. - [ ] **Step 8: Commit** ```bash git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js git commit -m "feat(gate): reviewer_memory_promote — deterministic F8 memory-promotion gate" ``` --- ### Task 4: SKILL.md — prescribe the promotion flow **Files:** - Modify: `harness/.pi/skills/tht-sessione/SKILL.md` **Interfaces:** - Consumes: the `reviewer_memory_promote` tool (Task 3). - Produces: the workflow contract the model follows. No code. - [ ] **Step 1: Replace the Phase 2 D11 note (current step 5)** Replace: ```markdown 5. **Promotion (D11).** To promote ONE memory in `profile=workstation`, use `tht memory save-one` (targeted one-row upsert via the writer key). Batch promotion (`tht memory promote`) is server-side. Memories live ONLY in the vectordb (no local registry). ``` with: ```markdown 5. Memories are promoted at the END of the workflow (Phase 8, the `reviewer_memory_promote` gate) — never promote from here, never run `tht memory promote`/`save-one` yourself (the gate blocks them). ``` - [ ] **Step 2: Remove the Phase 5 optional-promotion step** Replace: ```markdown 3. **Memory promotion (F5).** If you want to promote memories from this session, use `tht memory save-one` (workstation) or `tht memory promote` (server). Memories live ONLY in the vectordb (no local registry). 4. Close with `reviewer_confirm kind:"phase"`. ``` with: ```markdown 3. Close with `reviewer_confirm kind:"phase"`. ``` - [ ] **Step 3: Rewrite Phase 8 to include the promotion gate** Replace: ```markdown 1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no). 2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell the reviewer that dbt generation is not implemented yet. 3. If no: close with `reviewer_confirm kind:"phase"`. The session is finalizable. ``` with: ```markdown 1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no). 2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell the reviewer that dbt generation is not implemented yet. 3. **Memory promotion.** Call `reviewer_memory_promote` with ONLY the session id: the gate computes the candidates itself (`tht memory promote --preview` — the 3 reusable types, already excluding promoted/declined ones) and shows the reviewer a pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`; deselected → `memory_promotion_declined` (never re-proposed). If the gate reports zero candidates, move on — do not retry. 4. Close with `reviewer_confirm kind:"phase"`. The session is finalizable. ``` - [ ] **Step 4: Verify the file renders coherently** Run: `grep -n "reviewer_memory_promote\|Promotion (D11)\|Memory promotion (F5)" /Users/mp/projects/ThothII/harness/.pi/skills/tht-sessione/SKILL.md` Expected: `reviewer_memory_promote` appears in F2's step 5 and Phase 8; the two old notes are gone. - [ ] **Step 5: Commit** ```bash git add harness/.pi/skills/tht-sessione/SKILL.md git commit -m "docs(skill): prescribe the F8 memory-promotion gate; drop optional D11 notes" ``` --- ### Task 5: `solved_question` kind + `tht/solved.py` **Files:** - Create: `harness/tht/solved.py` - Modify: `harness/tht/vectorstore/reader.py` (KIND_TO_TABLE) - Modify: `harness/tht/vectorstore/rest_writer.py` (TABLE_TO_KINDS) - Test: `harness/tests/test_solved_question.py` (create) **Interfaces:** - Consumes: `VectorRecord`, `content_hash`, `pack_metadata`, `VectorRestClient.existing_hashes(table, kinds)` / `.upsert_records(table, rows)` (all existing). - Produces: `SOLVED_KIND = "solved_question"`; `solved_question_record(*, session_id, question, sql, tables) -> VectorRecord` (id `solved:`, content = question); `save_solved_question(record, *, writer, embedder) -> int`; `_solved_hash(record) -> str`. Consumed by Task 6. - [ ] **Step 1: Write the failing tests** Create `harness/tests/test_solved_question.py`: ```python """L1: coppia domanda->SQL risolta (kind solved_question) — memoria attiva parte B. Una sessione finalizzata produce UN record nel vectordb (tabella `memory`, kind dedicato): embedding = domanda riscritta, metadata = {question, sql, tables, session_id}. Upsert one-row stile D11 (mai sync: il suo delete-stale cancellerebbe i record delle altre sessioni). L'hash di dedup copre domanda+SQL, cosi' un re-finalize che cambia solo l'SQL aggiorna comunque la riga. """ from unittest.mock import MagicMock from tht.solved import ( SOLVED_KIND, _solved_hash, save_solved_question, solved_question_record, ) from tht.vectorstore.reader import tables_for_kinds def _rec(**kw): base = dict( session_id="s1", question="quante ablazioni nel 2023", sql="SELECT count(*) FROM fact_seeablazione", tables=["fact_seeablazione"], ) base.update(kw) return solved_question_record(**base) def test_record_shape(): r = _rec() assert r.id == "solved:s1" assert r.kind == SOLVED_KIND assert r.content == "quante ablazioni nel 2023" # embedding = solo la domanda assert r.metadata["sql"].startswith("SELECT") assert r.metadata["tables"] == ["fact_seeablazione"] assert r.metadata["session_id"] == "s1" def test_solved_kind_maps_to_memory_table(): assert tables_for_kinds([SOLVED_KIND]) == ["memory"] def test_save_upserts_single_row_into_memory_table(): writer = MagicMock() writer.existing_hashes.return_value = {} writer.upsert_records.return_value = 1 embedder = MagicMock() embedder.embed_documents.return_value = [[0.1] * 8] assert save_solved_question(_rec(), writer=writer, embedder=embedder) == 1 writer.sync.assert_not_called() table, rows = writer.upsert_records.call_args[0] assert table == "memory" assert len(rows) == 1 assert rows[0]["record_key"] == "solved:s1" assert rows[0]["metadata"]["kind"] == SOLVED_KIND assert rows[0]["metadata"]["sql"].startswith("SELECT") def test_save_skips_when_question_and_sql_unchanged(): r = _rec() writer = MagicMock() writer.existing_hashes.return_value = {r.id: _solved_hash(r)} embedder = MagicMock() assert save_solved_question(r, writer=writer, embedder=embedder) == 0 embedder.embed_documents.assert_not_called() writer.upsert_records.assert_not_called() def test_sql_change_alone_triggers_reupsert(): old = _rec() new = _rec(sql="SELECT 1") # stessa domanda, SQL diverso writer = MagicMock() writer.existing_hashes.return_value = {old.id: _solved_hash(old)} writer.upsert_records.return_value = 1 embedder = MagicMock() embedder.embed_documents.return_value = [[0.0] * 4] assert save_solved_question(new, writer=writer, embedder=embedder) == 1 ``` - [ ] **Step 2: Run to verify they fail** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_question.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'tht.solved'`. - [ ] **Step 3: Add the kind mappings** In `harness/tht/vectorstore/reader.py`, change `KIND_TO_TABLE` to: ```python KIND_TO_TABLE = { "schema_table": "schema_records", "schema_column": "schema_records", "evidence": "evidence", "memory": "memory", "solved_question": "memory", # coppie domanda->SQL: stessa tabella, kind dedicato } ``` In `harness/tht/vectorstore/rest_writer.py`, change `TABLE_TO_KINDS` to: ```python TABLE_TO_KINDS = { "schema_records": {"schema_table", "schema_column"}, "evidence": {"evidence"}, "memory": {"memory", "solved_question"}, } ``` - [ ] **Step 4: Create `harness/tht/solved.py`** ```python """Coppie domanda->SQL risolte (kind `solved_question`) — memoria attiva, parte B. Una sessione finalizzata produce UN record nel vectordb: l'embedding e' la domanda riscritta (content), il metadata porta l'SQL finale e le tabelle promosse. Vive nella tabella pgvector `memory` con kind dedicato (nessuna DDL server-side); si consulta nelle fasi F4/F6/F7 con `tht memory solved-search` come materiale di riferimento (exemplar), NON come decisione da ri-applicare. Scrittura: SOLO upsert one-row stile D11 (`save_solved_question`). Questi record non passano MAI da `VectorStore.sync`/`RestVectorWriter.sync`: il passo delete-stale del sync, ricevendo il solo record corrente, cancellerebbe le coppie delle altre sessioni. Per lo stesso motivo l'hash di dedup e' calcolato qui (domanda+SQL) e non dal solo content come fa il sync. """ from tht.vectorstore.records import VectorRecord SOLVED_KIND = "solved_question" def solved_question_record( *, session_id: str, question: str, sql: str, tables: list[str] ) -> VectorRecord: return VectorRecord( id=f"solved:{session_id}", kind=SOLVED_KIND, ref=session_id, title=question[:120], content=question, metadata={ "question": question, "sql": sql, "tables": tables, "session_id": session_id, }, ) def _solved_hash(record: VectorRecord) -> str: # La domanda e' l'embedding (content); l'SQL vive solo nel metadata. L'hash # copre entrambi: un re-finalize che cambia solo l'SQL aggiorna la riga. from tht.vectorstore.store import content_hash return content_hash(record.content + "\n" + str(record.metadata.get("sql", ""))) def save_solved_question(record: VectorRecord, *, writer, embedder) -> int: """Upsert one-row della coppia domanda->SQL via writer key (stesso pattern di save_one_memory, spec D11): hash dedup client-side, embedding solo se domanda o SQL sono cambiati. `writer` e' un VectorRestClient (writer key). Ritorna il numero di righe upsertate (0 = invariata).""" from tht.vectorstore.rest_writer import pack_metadata new_hash = _solved_hash(record) existing = writer.existing_hashes("memory", [SOLVED_KIND]) if existing.get(record.id) == new_hash: return 0 embedding = embedder.embed_documents([record.content])[0] return writer.upsert_records("memory", [{ "record_key": record.id, "kind": record.kind, "content_hash": new_hash, "metadata": pack_metadata(record), "embedding": embedding, }]) ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_question.py tests/test_vector_dual_key.py -v` Expected: all PASS (dual-key tests confirm the mapping change breaks nothing). - [ ] **Step 6: Full suite + lint, then commit** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .` ```bash git add harness/tht/solved.py harness/tht/vectorstore/reader.py harness/tht/vectorstore/rest_writer.py harness/tests/test_solved_question.py git commit -m "feat(solved): solved_question vector kind + one-row upsert (D11 pattern)" ``` --- ### Task 6: CLI — `tht memory solved-index` / `solved-search` **Files:** - Modify: `harness/tht/solved.py` (add `build_solved_record` + `SolvedIndexError`) - Modify: `harness/tht/cli/memory_cmd.py` (two commands + `index_solved_session`) - Test: `harness/tests/test_solved_build.py` (create) **Interfaces:** - Consumes: `question_context` (Task 2), `solved_question_record`/`save_solved_question` (Task 5), `effective_decisions` (`tht/phase.py`), `promoted_tables_for(cfg, session_id) -> set[str] | None` (`tht/cli/sql_cmd.py:42`), `has_vector_write_rest`/`require_vector_write_allowed` (`tht/cli/_guards.py`), `make_embedder`/`open_searcher`/`require_vector_cfg` (`tht/cli/vector_cmd.py`), `load_session_or_exit`/`session_dir` (already imported at the top of `memory_cmd.py`). - Produces: `build_solved_record(session_dir, manifest, promoted_tables) -> VectorRecord` (raises `SolvedIndexError`); `index_solved_session(cfg, session_id) -> int` (raises `RuntimeError` when the writer key is missing — the finalize hook, Task 7, degrades it to a warning); CLI `tht memory solved-index [--json]` and `tht memory solved-search "" [--top N] [--json]`. - [ ] **Step 1: Write the failing tests** Create `harness/tests/test_solved_build.py`: ```python """L1: build del record solved_question dagli artefatti persistiti della sessione. Il record si costruisce SOLO da cio' che il workflow ha approvato: sql_final.sql presente + decisione sql_approved nella vista effective; la domanda e' l'ultima question_rewritten (fallback: la domanda del manifest).""" from datetime import datetime import pytest from tht.decisions import append_decision from tht.session.models import SessionManifest from tht.solved import SolvedIndexError, build_solved_record def _manifest() -> SessionManifest: return SessionManifest( id="s1", created_at=datetime(2026, 1, 1), question="domanda originale", database="db", schema="public", ) def test_build_uses_rewritten_question_sql_and_tables(tmp_path): (tmp_path / "sql_final.sql").write_text("SELECT 1\n") append_decision(tmp_path, type="question_rewritten", subject="domanda", detail="domanda riscritta esplicita") append_decision(tmp_path, type="sql_approved", subject="phase:7") rec = build_solved_record(tmp_path, _manifest(), {"fact_x", "dim_y"}) assert rec.id == "solved:s1" assert rec.content == "domanda riscritta esplicita" assert rec.metadata["sql"] == "SELECT 1" assert rec.metadata["tables"] == ["dim_y", "fact_x"] # ordinate def test_build_falls_back_to_manifest_question(tmp_path): (tmp_path / "sql_final.sql").write_text("SELECT 1") append_decision(tmp_path, type="sql_approved", subject="phase:7") rec = build_solved_record(tmp_path, _manifest(), None) assert rec.content == "domanda originale" assert rec.metadata["tables"] == [] def test_build_requires_sql_file(tmp_path): append_decision(tmp_path, type="sql_approved", subject="phase:7") with pytest.raises(SolvedIndexError, match="sql_final.sql"): build_solved_record(tmp_path, _manifest(), None) def test_build_requires_sql_approved(tmp_path): (tmp_path / "sql_final.sql").write_text("SELECT 1") with pytest.raises(SolvedIndexError, match="sql_approved"): build_solved_record(tmp_path, _manifest(), None) ``` - [ ] **Step 2: Run to verify they fail** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_build.py -v` Expected: FAIL with `ImportError: cannot import name 'build_solved_record'`. - [ ] **Step 3: Add `build_solved_record` to `harness/tht/solved.py`** Append: ```python class SolvedIndexError(Exception): """La sessione non ha (ancora) gli artefatti per il record solved_question.""" def build_solved_record(session_dir, manifest, promoted_tables) -> VectorRecord: """Costruisce il record dagli artefatti persistiti (vista effective D15): richiede sql_final.sql e la decisione sql_approved; la domanda e' l'ultima question_rewritten, fallback la domanda del manifest.""" from tht.memory import question_context from tht.phase import effective_decisions sql_file = session_dir / "sql_final.sql" if not sql_file.exists(): raise SolvedIndexError("sql_final.sql assente") decisions = effective_decisions(session_dir) if not any(d.type == "sql_approved" for d in decisions): raise SolvedIndexError("decisione sql_approved assente") return solved_question_record( session_id=manifest.id, question=question_context(decisions, manifest), sql=sql_file.read_text().strip(), tables=sorted(promoted_tables or set()), ) ``` Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_build.py -v` Expected: 4 PASS. - [ ] **Step 4: Add the orchestration helper + the two commands to `memory_cmd.py`** Append at the end of `harness/tht/cli/memory_cmd.py`: ```python def index_solved_session(cfg, session_id: str) -> int: """Indicizza la coppia domanda->SQL della sessione (kind solved_question). Solleva RuntimeError se manca la writer key e SolvedIndexError se mancano gli artefatti: il finalize li degrada a warning, il comando CLI li converte in errori espliciti.""" from tht.cli.sql_cmd import promoted_tables_for from tht.cli.vector_cmd import make_embedder from tht.solved import build_solved_record, save_solved_question from tht.vectorstore.rest_client import VectorRestClient if not has_vector_write_rest(cfg): raise RuntimeError( "vector_write_rest assente: la coppia domanda->SQL si indicizza con la " "writer key (workstation) o dal server" ) manifest = load_session_or_exit(cfg, session_id) record = build_solved_record( session_dir(cfg, session_id), manifest, promoted_tables_for(cfg, session_id) ) return save_solved_question( record, writer=VectorRestClient(cfg.vector_write_rest), embedder=make_embedder(cfg.embeddings), ) @memory_app.command("solved-index") def solved_index_cmd( session_id: str = typer.Argument(..., help="Id sessione con sql_final.sql approvato."), json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."), config: Path = CONFIG_OPT, ) -> None: """Indicizza la coppia domanda->SQL nel vectordb (backfill; il finalize lo fa da solo).""" import json as _json from tht.solved import SolvedIndexError cfg = _load_config_or_exit(config) require_vector_write_allowed(cfg, "memory solved-index") try: count = index_solved_session(cfg, session_id) except RuntimeError as e: typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=4) except SolvedIndexError as e: typer.secho(f"ERRORE: sessione {session_id} non indicizzabile: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=3) msg = ( f"1 coppia domanda->SQL indicizzata (solved:{session_id})." if count else "Nessun upsert: coppia gia' aggiornata." ) if json_out: typer.echo(_json.dumps({"upserted": count, "id": f"solved:{session_id}"}, ensure_ascii=False)) return typer.secho(f"OK: {msg}", fg=typer.colors.GREEN) @memory_app.command("solved-search") def solved_search_cmd( question: str = typer.Argument(..., help="Domanda da confrontare con quelle risolte."), top: int = typer.Option(3, "--top"), json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."), config: Path = CONFIG_OPT, ) -> None: """Domande gia' risolte simili (kind solved_question): domanda, SQL e tabelle.""" from rich.console import Console from rich.table import Table from tht.cli.vector_cmd import make_embedder, open_searcher from tht.solved import SOLVED_KIND cfg = _load_config_or_exit(config) require_vector_cfg(cfg) searcher = open_searcher(cfg) embedder = make_embedder(cfg.embeddings) hits = searcher.search(embedder.embed_query(question), top_n=top, kinds=[SOLVED_KIND]) results = [ { "session_id": h.metadata.get("session_id", h.ref), "question": h.metadata.get("question", h.content), "sql": h.metadata.get("sql", ""), "tables": h.metadata.get("tables", []), "score": round(h.similarity, 4), } for h in hits ] if json_out: typer.echo(json.dumps(results, ensure_ascii=False, indent=2)) return if not results: typer.secho("Nessuna domanda risolta simile.", fg=typer.colors.YELLOW) return table = Table(title=f"Domande risolte simili a: {question}") table.add_column("Sessione") table.add_column("Domanda") table.add_column("Tabelle") table.add_column("Score", justify="right") for r in results: table.add_row(r["session_id"], r["question"][:60], ", ".join(r["tables"]), f"{r['score']:.3f}") Console().print(table) ``` (`json` and `require_vector_cfg` are already module-level imports in `memory_cmd.py` — `solved_search_cmd` uses them directly; only `make_embedder`/`open_searcher` are imported lazily, matching the file's existing pattern.) - [ ] **Step 5: Verify CLI wiring by help text** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/tht memory solved-index --help && .venv/bin/tht memory solved-search --help` Expected: both exit 0 and show `--json` (and `--top` on solved-search). - [ ] **Step 6: Full suite + lint, then commit** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .` Expected: all pass. ```bash git add harness/tht/solved.py harness/tht/cli/memory_cmd.py harness/tests/test_solved_build.py git commit -m "feat(cli): tht memory solved-index / solved-search (question->SQL exemplars)" ``` --- ### Task 7: Finalize hook (best-effort indexing) **Files:** - Modify: `harness/tht/cli/session_cmd.py` (`finalize_cmd`, after the manifest write) **Interfaces:** - Consumes: `index_solved_session(cfg, session_id)` (Task 6). Lazy import inside the function (memory_cmd imports FROM session_cmd — a top-level import back would be circular; finalize already uses lazy imports throughout). - Produces: `tht session finalize` indexes the pair automatically; any failure prints a yellow warning and finalize still succeeds. - [ ] **Step 1: Add the hook** In `harness/tht/cli/session_cmd.py`, inside `finalize_cmd`, locate: ```python manifest.status = "finalized" manifest.updated_at = datetime.now(UTC) manifest.updated_by = current_author() manifest.to_yaml(sdir / MANIFEST) ``` and insert immediately AFTER it (before the final `typer.secho(f"OK: sessione ...")`): ```python # --- memoria attiva (parte B): indicizza la coppia domanda->SQL, best-effort --- # Import lazy: memory_cmd importa da session_cmd (un import top-level qui sarebbe # circolare). Qualunque errore (writer key assente, VPN giu', Ollama spento) NON # deve bloccare il finalize: l'indice e' derivato e recuperabile con # `tht memory solved-index `. try: from tht.cli.memory_cmd import index_solved_session if index_solved_session(cfg, session_id): typer.secho( "OK: coppia domanda->SQL indicizzata nel vectordb (solved_question).", fg=typer.colors.GREEN, ) except Exception as e: typer.secho( f"ATTENZIONE: coppia domanda->SQL non indicizzata ({e}). " f"Recupera con `tht memory solved-index {session_id}`.", fg=typer.colors.YELLOW, err=True, ) ``` - [ ] **Step 2: Verify no import cycle and no regressions** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/python -c "import tht.cli" && .venv/bin/pytest -q && .venv/bin/ruff check .` Expected: import OK, all tests pass, no lint errors. (The full finalize path needs the real DWH + writer key: it is exercised at L2 during the live gate below, not here.) - [ ] **Step 3: Commit** ```bash git add harness/tht/cli/session_cmd.py git commit -m "feat(finalize): auto-index the question->SQL pair (best-effort, never blocks)" ``` --- ### Task 8: SKILL.md recall (F4/F6/F7 + Session end) + docs **Files:** - Modify: `harness/.pi/skills/tht-sessione/SKILL.md` - Modify: `PROJECT_STATE.md` **Interfaces:** - Consumes: `tht memory solved-search` (Task 6), the finalize hook (Task 7). - [ ] **Step 1: Phase 4 — add the exemplar consultation to step 1** In the Phase 4 section, replace: ```markdown 1. `tht schema introspect` + `tht schema render --format mschema-text` for the schema context. Copy table/column names EXACTLY from it — never invent objects. ``` with: ```markdown 1. `tht schema introspect` + `tht schema render --format mschema-text` for the schema context. Copy table/column names EXACTLY from it — never invent objects. Also run `tht memory solved-search "" --json`: similar already-solved questions show which tables comparable questions used. Cite relevant precedents (session id + tables) to the reviewer as CONTEXT — they are reference material, NOT decisions to apply; their filters/periods may not transfer. ``` - [ ] **Step 2: Phase 6 — add the exemplar consultation to step 1** Replace: ```markdown 1. Read `cte.md`. Decompose the rewritten question into CTEs (Agent View Generation): each CTE captures an informative subset with a clear purpose, named in snake_case. ``` with: ```markdown 1. Read `cte.md`. Decompose the rewritten question into CTEs (Agent View Generation): each CTE captures an informative subset with a clear purpose, named in snake_case. `tht memory solved-search "" --json` shows how similar solved questions were structured — use as reference only. ``` - [ ] **Step 3: Phase 7 — add the exemplar consultation to step 1** Replace: ```markdown 1. Read `sql-generation.md`. Recursive divide-and-conquer: the CTEs approved in Phase 6 are the preferred building blocks (reuse them by name). ``` with: ```markdown 1. Read `sql-generation.md`. Recursive divide-and-conquer: the CTEs approved in Phase 6 are the preferred building blocks (reuse them by name). `tht memory solved-search "" --json` gives the final SQL of similar solved questions: reference exemplars — never copy filters, periods or populations without checking them against the current rewritten question. ``` - [ ] **Step 4: Session end — document the auto-indexing** Replace: ```markdown When the workflow is complete (Phase 8), `tht session finalize` closes the session and unlocks input. The persisted state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not recorded did not happen. ``` with: ```markdown When the workflow is complete (Phase 8), `tht session finalize` closes the session and unlocks input. Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`, best-effort — on failure recover with `tht memory solved-index `). The persisted state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not recorded did not happen. ``` - [ ] **Step 5: Update `PROJECT_STATE.md`** Add a dated entry (match the file's existing style) summarizing: active memory shipped — F8 promotion gate (`reviewer_memory_promote`, decision types `memory_promoted`/`memory_promotion_declined`, declined-filter) + solved-question exemplars (`solved_question` kind in the `memory` table, `tht memory solved-index/solved-search`, finalize hook, recall prescribed in F4/F6/F7). Note the pending L2 gate: one live end-to-end session on workspace `psd` to verify (a) the promotion widget renders and persists, (b) finalize indexes the pair, (c) `solved-search` returns it. - [ ] **Step 6: Final verification sweep** Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && npm test && .venv/bin/ruff check .` Expected: everything green. - [ ] **Step 7: Commit** ```bash git add harness/.pi/skills/tht-sessione/SKILL.md PROJECT_STATE.md git commit -m "docs(skill): prescribe solved-question recall in F4/F6/F7; refresh PROJECT_STATE" ``` --- ## Manual L2 gate (after all tasks — human-run, VPN + writer key required) Not automatable in CI (real GLM + remote pgvector). Run one full session on workspace `psd` via `./scripts/run-stack.sh`: 1. Complete a question through F8: at step 3 the promotion checklist must appear pre-selected; deselect one candidate, approve. 2. Verify the ledger: `tht decision list --session ` shows `memory_promoted` (selected) and `memory_promotion_declined` (deselected, `detail seq:`). 3. Re-open the gate scenario (new session on a similar question): F2 must retrieve the promoted memory; the declined one must not resurface at a re-run of the promotion preview. 4. `tht session finalize ` prints the green solved-question line; `tht memory solved-search "" --json` returns the pair with sql + tables.