Lever 1: Join-graph via FK logics in annotations + suggest-fks command
- TableAnnotation.foreign_keys field stores curated logical FKs (DWH has no FK constraints)
- tht schema suggest-fks: mine from approved SQL, heuristics (time_key → dim_time),
same-name discovery + explicit --assume flag for multi-owner PKs
- mschema renders 【Foreign keys】 section populated; validation in merge.py
- SKILL.md F4 now reads FKs from mschema-text, no custom data_time_key logic
Lever 2: Context-pack consolidation at kickoff (tht search pack)
- Single embedding of question, reused for schema + evidence + solved searches
- One command: tht search pack <question> --session <id> → retrieval_pack.md
- Graceful degradation when Ollama/vector store unreachable (exit 0, empty sections)
- SKILL.md F1 prescribes as first call; reduces model thinking turns via pre-retrieval
Lever 3: Phase-summary recap v2 auto-construction from session ledger
- tht session show --json includes full decisions ledger
- tht phase meta --json exports 'emits' (substantive decision types per phase)
- Gate appends deterministic 【Decisioni registrate in questa fase】 section (appendLedgerSection)
- Model authors only summary + checks; recap table comes from persisted state (exact by construction)
- SKILL.md Disciplina 6: brief model output, gate fills the rest
Tests: 358 Python (including 10 FK + 3 pack + 1 session-ledger tests) + 111 JS gate tests, all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
"""tht phase -- workflow HITL gate commands.
|
|
|
|
`meta` is the single source the gate (tht-gate.js) reads workflow facts from,
|
|
killing the JS/Python PHASE_NAMES drift (F2). advance/reopen/show are the phase
|
|
transitions; require_phase_or_exit is the guard shared with cte/decision/datamart
|
|
commands. All read workflow facts from load_workflow() (no mirrored constants).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
from tht.phase import (
|
|
auto_advance_eligible,
|
|
advance_problems,
|
|
current_phase,
|
|
)
|
|
from tht.workflow import load_workflow
|
|
|
|
phase_app = typer.Typer(help="Fase del workflow HITL (gate di avanzamento/ritorno)")
|
|
|
|
|
|
def session_dir(cfg, session_id: str) -> Path:
|
|
"""Where a session's artifacts live. Mirror of session_cmd.session_dir (kept
|
|
here to avoid a circular import: session_cmd imports phase helpers too)."""
|
|
return cfg.paths.sessions / session_id
|
|
|
|
|
|
def require_phase_or_exit(cfg, session: str, min_phase: int) -> None:
|
|
"""Refuse with exit 1 if the session hasn't reached min_phase yet. The phase
|
|
name in the message comes from workflow.yaml (load_workflow), not a constant."""
|
|
cur = current_phase(session_dir(cfg, session))
|
|
if cur < min_phase:
|
|
wf = load_workflow()
|
|
nome = wf.phase_name(min_phase)
|
|
typer.secho(
|
|
f"Impossibile: serve la Fase {min_phase} ({nome}), "
|
|
f"sessione '{session}' è alla Fase {cur}.",
|
|
fg=typer.colors.RED, err=True,
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
|
|
@phase_app.command("meta")
|
|
def meta_cmd(
|
|
as_json: bool = typer.Option(
|
|
False,
|
|
"--json",
|
|
help="Emette i metadati del workflow come JSON (usato dall'estensione gate).",
|
|
),
|
|
) -> None:
|
|
"""Stampa i metadati del workflow derivati da workflow.yaml (F2)."""
|
|
wf = load_workflow()
|
|
if not as_json:
|
|
typer.echo(f"max_phase: {wf.max_phase}")
|
|
for p in wf.phases:
|
|
typer.echo(f" {p.id} ({p.num}/{wf.max_phase}) {p.name} advance={p.advance}")
|
|
return
|
|
typer.echo(
|
|
json.dumps(
|
|
{
|
|
"schema_version": wf.schema_version,
|
|
"max_phase": wf.max_phase,
|
|
"phases": [
|
|
{
|
|
"num": p.num,
|
|
"id": p.id,
|
|
"name": p.name,
|
|
"advance": p.advance,
|
|
"artifacts_out": p.artifacts_out,
|
|
"emits": p.emits,
|
|
}
|
|
for p in wf.phases
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
@phase_app.command("advance")
|
|
def advance_cmd(
|
|
session: str = typer.Option(..., "--session", help="Id sessione."),
|
|
auto: bool = typer.Option(
|
|
False, "--auto",
|
|
help="Avanza solo se la fase è completa (exit 6 se manca qualcosa; per il gate).",
|
|
),
|
|
) -> None:
|
|
"""Approva la fase corrente e passa alla successiva (persiste phase_approved)."""
|
|
from tht.decisions import append_decision
|
|
|
|
sdir = session_dir(_cfg(), session)
|
|
cur = current_phase(sdir)
|
|
wf = load_workflow()
|
|
if cur > wf.max_phase:
|
|
typer.secho("Sessione già alla fase terminale.", fg=typer.colors.YELLOW)
|
|
raise typer.Exit(0)
|
|
if auto:
|
|
if not auto_advance_eligible(sdir):
|
|
problems = advance_problems(sdir, cur)
|
|
for p in problems:
|
|
typer.echo(p)
|
|
raise typer.Exit(6) # needs human confirmation (gate contract)
|
|
append_decision(sdir, type="phase_approved", subject=f"phase:{cur}")
|
|
typer.echo(f"Fase {cur} ({wf.phase_name(cur)}) approvata → Fase {cur + 1}.")
|
|
|
|
|
|
@phase_app.command("reopen")
|
|
def reopen_cmd(
|
|
session: str = typer.Option(..., "--session"),
|
|
phase: int = typer.Option(..., "--phase", help="Fase a cui tornare (1..fase corrente -1)."),
|
|
) -> None:
|
|
"""Torna a una fase precedente (persiste phase_reopened + teardown artefatti)."""
|
|
from tht.decisions import append_decision
|
|
from tht.teardown import teardown_to_phase
|
|
|
|
sdir = session_dir(_cfg(), session)
|
|
cur = current_phase(sdir)
|
|
if phase < 1 or phase >= cur:
|
|
typer.secho(f"Target non valido (fase corrente {cur}).", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(1)
|
|
report = teardown_to_phase(sdir, target_phase=phase)
|
|
append_decision(sdir, type="phase_reopened", subject=f"phase:{phase}")
|
|
for f in report.deleted_files:
|
|
typer.echo(f" eliminato artefatto: {f}")
|
|
typer.echo(f"Tornati alla Fase {phase} ({load_workflow().phase_name(phase)}).")
|
|
|
|
|
|
@phase_app.command("show")
|
|
def show_cmd(
|
|
session: str = typer.Option(..., "--session"),
|
|
) -> None:
|
|
"""Mostra stato, fase corrente e ultime decisioni della sessione."""
|
|
from tht.decisions import list_decisions
|
|
|
|
sdir = session_dir(_cfg(), session)
|
|
cur = current_phase(sdir)
|
|
wf = load_workflow()
|
|
typer.echo(f"Fase corrente: {cur}/{wf.max_phase} ({wf.phase_name(min(cur, wf.max_phase))})")
|
|
decisions = list_decisions(sdir)
|
|
if decisions:
|
|
typer.echo(f"Decisioni registrate: {len(decisions)}")
|
|
for d in decisions[-5:]:
|
|
typer.echo(f" #{d.seq} {d.type} {d.subject}")
|
|
|
|
|
|
def _cfg():
|
|
"""Load the workspace config. Resolves THT_WORKSPACE/THT_CONFIG first, then
|
|
falls back to the CLI default ``config/tht.yaml`` (same convention as
|
|
schema_cmd.CONFIG_OPT)."""
|
|
import os
|
|
from pathlib import Path
|
|
|
|
ws = os.environ.get("THT_WORKSPACE") or os.environ.get("THT_CONFIG")
|
|
config_path = Path(ws) if ws else Path("config/tht.yaml")
|
|
try:
|
|
from tht.cli.schema_cmd import _load_config_or_exit # noqa: F401 (portato in Onda 1.4)
|
|
|
|
return _load_config_or_exit(config_path)
|
|
except ImportError:
|
|
from tht.workspace import load_config
|
|
|
|
return load_config(config_path)
|