// tmctl is the TextMachine CLI: translate / report / status / redrive. // main.go — тонкая обвязка (пакет №4): разбор аргументов — invocation.go, // рендеры вывода — render.go, .env — dotenv.go; здесь только связка // «parse → env → ctx → fetch → render» и маппинг exit-кодов. package main import ( "context" "errors" "fmt" "os" "os/signal" "path/filepath" "syscall" "textmachine/backend/internal/obs" "textmachine/backend/internal/pipeline" "textmachine/backend/internal/store" ) func main() { err := run() if err != nil { fmt.Fprintln(os.Stderr, "tmctl:", err) } os.Exit(exitCode(err)) } // exitCode maps a run() error onto the ratified shell contract (Веха 2): 0 clean · // 2 completed-with-flags (приёмка допускает N флагов; типизированный сентинел // *pipeline.CompletedWithFlags, переживает %w-обёртки через errors.As) · 1 infra // failure и всё остальное, включая ошибку разбора флагов (ContinueOnError в // parseInvocation держит 2 эксклюзивным для флагнутых чанков). func exitCode(err error) int { var flagged *pipeline.CompletedWithFlags switch { case err == nil: return 0 case errors.As(err, &flagged): return 2 default: return 1 } } func run() error { inv, err := parseInvocation(os.Args[1:], os.Stderr) if err != nil { return err } loadDotEnv(filepath.Join(filepath.Dir(inv.cfgPath), ".env")) loadDotEnv(".env") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // LogBodies решается один раз на допуске (privacy by default): содержимое // LLM-обменов попадает в логи только при LOG_LLM_BODIES=1 И LOG_LEVEL=debug. ctx = obs.WithReqInfo(ctx, obs.ReqInfo{ TraceID: obs.NewTraceID(), LogBodies: os.Getenv("LOG_LLM_BODIES") == "1", }) switch inv.cmd { case "translate": return translate(ctx, inv.cfgPath, inv.resnapshot) case "report": return report(inv.cfgPath) case "status": return status(ctx, inv.cfgPath, inv.asJSON) case "redrive": return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.sel) default: return fmt.Errorf("unknown command %q (want translate|report|status|redrive)", inv.cmd) } } func translate(ctx context.Context, cfgPath string, resnapshot bool) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() r.Resnapshot = resnapshot res, err := r.TranslateBook(ctx) if err != nil { return err } return renderTranslate(os.Stdout, res, func() (float64, float64, error) { return r.Store.SpentUSD(res.BookID) }) } func report(cfgPath string) error { // Read-only ($0): no API keys required (D20.4 — a store audit must not demand provider keys). r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() // Чтения — коллбеками (interleaved с печатью, как исторически): провал чтения // посреди аудита оставляет уже напечатанную часть на stdout (см. renderReport). if err := renderReport(os.Stdout, func() ([]store.RequestLogView, error) { return r.Store.RequestLogRows(r.Book.BookID) }, func() ([]store.ChunkStatus, error) { return r.Store.ChunkStatusesForBook(r.Book.BookID) }, func() ([]store.RetrievalState, error) { return r.Store.RetrievalStatesForBook(r.Book.BookID) }, func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) }); err != nil { return err } // Per-run quality-report (D39 слой 5): aggregate the deterministic quality signals into a readable // summary — the "telemetry from day one" the owner asked for (H5). Read-only ($0), never a gate. q, err := r.QualityReport() if err != nil { return err } return renderQuality(os.Stdout, q) } // status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the // StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human // dashboard (unit counts, per-chapter quality passports, money, secondary ETA). BOTH modes exit 2 // (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer // never reads a clean 0 over a book that still needs attention. func status(ctx context.Context, cfgPath string, asJSON bool) error { // Read-only ($0): no API keys required (D20.4 — a progress projection must not demand keys). r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() rep, err := r.Status(ctx) if err != nil { return err } if asJSON { return renderStatusJSON(os.Stdout, rep) } return renderStatusHuman(os.Stdout, rep, cfgPath) } // redrive re-attacks the FLAGGED chunks matching the selector (D15.3): it resets their terminal // flag (chunk_status + checkpoints of the flagged/skipped stages) and re-runs the durable loop // with a FRESH retry/escalation budget, never touching DispOK work. On --dry-run it only reports // the plan. Redrive-calls bill normally; money already spent on the discarded attempts stays // committed (honest). By default it requires the current config to render the SAME snapshot the // flagged rows carry (else it fails loud); passing --resnapshot wires r.Resnapshot so Redrive skips // that drift guard and accepts the re-pin/re-pay explicitly (D20.4: the flag used to be parsed but // silently ignored — Redrive already honoured r.Resnapshot, only the CLI never set it). func redrive(ctx context.Context, cfgPath string, resnapshot bool, sel pipeline.RedriveSelector) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() r.Resnapshot = resnapshot summary, res, err := r.Redrive(ctx, sel) if err != nil { return err } return renderRedrive(os.Stdout, r.Book.BookID, summary, res, func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) }) }