// tmctl is the TextMachine CLI: translate / report / status / export / redrive / manifest / backup / // migrate. // main.go — thin wiring (package №4): argument parsing — invocation.go, // output renderers — render.go, .env — dotenv.go; here just the // «parse → env → ctx → fetch → render» wiring and exit-code mapping. package main import ( "context" "errors" "fmt" "os" "os/signal" "path/filepath" "syscall" "textmachine/backend/internal/membank" "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)) } // The refusal band. A code in [refusalFirst, refusalLast] means the invocation was TURNED DOWN before // it did any work of its own — nothing reached a provider, nothing was spent, nothing was written — and // the particular number names why. // // It is a BAND and not a list because the vocabulary is the engine's and it will grow: a caller looks up // the number, and a class this build of the caller has never heard of still lands in the band and reads // as "refused" rather than as "failed". That difference is the whole of PD-196 — the platform's intake // rejects a book as `source_unreadable` after five failures and deletes the upload, and every refusal // used to arrive as the same exit 1, so an operator's typo in a hand-written book.yaml was one step from // destroying a user's file. A new class is a new constant here and in pipeline.RefusalClass; no consumer // has to enumerate them to stay safe. const ( refusalFirst = 10 refusalLast = 19 exitConfigInvalid = 10 // the configuration will not run: unreadable, unparseable, invalid, no key exitSourceUnreadable = 11 // the BOOK's source cannot be read or decoded — the one class about the text exitProjectLocked = 12 // another tmctl owns this project right now; come back later exitSchemaMismatch = 13 // this project's schema is not this binary's: run `tmctl migrate` (row 174) exitRefusedOther = 19 // a refusal class this build of tmctl has no number for ) // refusalExit is the dictionary the band is read through. var refusalExit = map[pipeline.RefusalClass]int{ pipeline.RefusalBadConfig: exitConfigInvalid, pipeline.RefusalSourceUnreadable: exitSourceUnreadable, pipeline.RefusalProjectLocked: exitProjectLocked, pipeline.RefusalSchemaMismatch: exitSchemaMismatch, } // exitCode maps a run() error onto the ratified shell contract (Milestone 2 / R1-FL-A): // // 0 clean // 1 infra failure and everything else, including a flag-parse error // 2 completed-with-flags (acceptance allows N flags; the typed *pipeline.CompletedWithFlags, // which survives %w-wraps via errors.As) // 3 bank-mining signature stop (*pipeline.WaveSignatureStop — the run paused before the edit wave // for owner sign; a DELIBERATE human-in-the-loop halt, not a crash) // 4 ceiling halt (*pipeline.CeilingHalt — the book or day USD ceiling stopped the run) // 5 graceful stop (SIGINT/SIGTERM was caught and the run wound down) // 10-19 refusal band, see above // // 3 is DISTINCT from both 2 (flagged chunks) and 1 (crash) so exit-code automation can tell a // sign-boundary pause from a real failure — a human sees the stderr text either way, but a script that // shipped "translate && export" must NOT treat the pause as success (exit 0) nor as an infra crash. // // 4 and 5 exist for the same reason one level up, and they are money (row 165). A supervisor learns how // a run ended from its exit code, so while a ceiling halt and a caught SIGTERM both mapped to 1 the // platform recorded `failed` for both — for a stop its own contract calls `paused` and forbids calling // `failed` (PD-113), and for the user's own stop and for an ordinary host reboot (PD-152). Since the // ceiling argument is now set flush against the hold (PD-158), "the money ran out" and "the machine // broke" were the same number at exactly the moment they became opposite facts. // // 5 reads a CANCELLED context because in tmctl the run context is cancelled by signal.NotifyContext and // by nothing else (see run): a cancelled run is one somebody asked to stop. It is checked after the // typed sentinels, so a run that reached its own terminal state before the signal keeps its own code. func exitCode(err error) int { var flagged *pipeline.CompletedWithFlags var sigStop *pipeline.WaveSignatureStop var ceiling *pipeline.CeilingHalt var refusal *pipeline.Refusal switch { case err == nil: return 0 case errors.As(err, &sigStop): return 3 case errors.As(err, &flagged): return 2 case errors.As(err, &ceiling): return 4 case errors.As(err, &refusal): if code, ok := refusalExit[refusal.Class]; ok { return code } return exitRefusedOther case errors.Is(err, context.Canceled): return 5 default: return 1 } } // traceID is the identity of this invocation: its log axis, its request_log column, and — since the // event seam exists — the `engine_run_id` half of the ratified idempotency key (engine_run_id, seq). // // It is ACCEPTED from the environment (row 102) and minted only when none was given. A run the platform // spawned should be ONE trace end to end, and letting the caller name it means the idempotency namespace // of the stream is the caller's by construction rather than something it has to learn from a handshake // (the reader's own comment anticipates exactly this). The environment and not a flag: argv is the one // channel this zone keeps deliberately free of run identity (PD-99), and a supervisor already has an // Env for the unit. // // A value it cannot use is REFUSED rather than trimmed into something else: an id is compared, joined // and stored, so silently correcting it would make two runs share a namespace. The shape is what a log // axis and a database key can both carry — printable, no spaces, bounded. func traceID() string { v := os.Getenv("TM_TRACE_ID") if v == "" { return obs.NewTraceID() } if len(v) > 64 { fmt.Fprintf(os.Stderr, "tmctl: TM_TRACE_ID is %d characters (max 64) — minting one instead\n", len(v)) return obs.NewTraceID() } for _, c := range v { if c <= ' ' || c > '~' { fmt.Fprintln(os.Stderr, "tmctl: TM_TRACE_ID must be printable ASCII without spaces — minting one instead") return obs.NewTraceID() } } return v } func run() error { inv, err := parseInvocation(os.Args[1:], os.Stderr) if err != nil { return err } loadDotEnv(filepath.Join(filepath.Dir(inv.cfgPath), ".env"), os.Stderr) loadDotEnv(".env", os.Stderr) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // LogBodies is decided once at admission (privacy by default): the content // of LLM exchanges reaches the logs only when LOG_LLM_BODIES=1 AND LOG_LEVEL=debug. ctx = obs.WithReqInfo(ctx, obs.ReqInfo{ TraceID: traceID(), LogBodies: os.Getenv("LOG_LLM_BODIES") == "1", }) switch inv.cmd { case "translate": // F4 (row 83): back up + integrity-check the project DB before a paid run starts. This lives HERE // (the boevoy command dispatch), not in translate()/the Runner, so a fake-provider test that drives // those directly never triggers it — no provider-name discriminator needed. if err := preflightBackup(inv.cfgPath, os.Stdout); err != nil { return err } return translate(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.verifyBank, inv.ceilingUSD) case "report": return report(inv.cfgPath) case "status": return status(ctx, inv.cfgPath, inv.asJSON) case "export": return export(inv.cfgPath, inv.asPlaintext, inv.asPairs) case "redrive": // A dry-run redrive spends nothing and mutates nothing; only a real redrive re-attacks and re-bills. if !inv.sel.DryRun { if err := preflightBackup(inv.cfgPath, os.Stdout); err != nil { return err } } return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.sel, inv.verifyBank, inv.ceilingUSD) case "manifest": return manifestCmd(inv.cfgPath, inv.asJSON) case "backup": return backupCmd(inv.cfgPath, os.Stdout) case "migrate": return migrateCmd(inv.cfgPath, os.Stdout) case "seed-lint": return seedLint(inv.seedPath) default: return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|manifest|backup|migrate|seed-lint)", inv.cmd) } } // translate runs the book. The three money flags are ORTHOGONAL (D20.2-Q2 / row 145): --resnapshot // grants permission to re-pin jobs onto the current config snapshot, --accept-rebill[=usd] consents to // the AMOUNT that re-pin would re-pay, and --ceiling-usd replaces the BOOK ceiling for this process. // // ⚠ --ceiling-usd is NOT a per-run budget. The ledger admits against the book's CUMULATIVE spend // (store/ledger.go: `bookTotal + estimate > BookUSD`, where bookTotal is committed+reserved across every // run of the book), so the flag says "this book may reach $X while I am running", not "this run may // spend $X". A caller that wants to grant an increment has to pass already-committed + increment, and a // value below what the book has already spent denies the very first reservation. // // Over the book's consent threshold the run stops with the sum before reserving anything — a flag that // names no money cannot carry a Р6 consent to a spend. func translate(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, verifyBank bool, ceilingUSD float64) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() r.Resnapshot = resnapshot r.AcceptRebill = acceptRebill r.VerifyBank = verifyBank r.CeilingUSD = ceilingUSD res, err := r.TranslateBook(ctx) if err != nil { // R1-FL-A: the bank-mining signature stop is a typed sentinel, not an infra failure — render the // operator's next steps to stdout (the bank table + the signature-map path + resume guidance) and // return it so main() maps it to the distinct exit code 3 (the Error() text also prints to stderr). var sigStop *pipeline.WaveSignatureStop if errors.As(err, &sigStop) { renderSignatureStop(os.Stdout, sigStop) // …and the MONEY (S17): the draft wave the pause sits behind is already paid for, and a stop // that reports zero about it leaves the operator guessing what the run has cost so far. if committed, reserved, lerr := r.Store.SpentUSD(r.Book.BookID); lerr == nil { fmt.Fprintf(os.Stdout, "Book ledger so far: committed=$%.6f reserved=$%.6f\n", committed, reserved) } } return err } return renderTranslate(os.Stdout, res, func() (float64, float64, error) { return r.Store.SpentUSD(res.BookID) }) } // seedLint validates a glossary seed YAML (a manual seed or the emitted mined delta) through the REAL // loadGlossarySeed fail-louds + the shared-key collision check (WS3). $0, no store, no provider keys — a // pre-flight the operator runs on the mined delta before the W1.5 reseed. Prints "OK" on a clean seed. func seedLint(seedPath string) error { if err := membank.SeedLint(seedPath); err != nil { return err } fmt.Fprintf(os.Stdout, "seed-lint OK: %s is loadable (0 fail-louds, 0 shared-key collisions)\n", seedPath) return nil } 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() // Reads via callbacks (interleaved with printing, as historically): a read failure // mid-audit leaves the already-printed part on stdout (see 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 layer 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) } // export prints the READ-ONLY export projection (D39 layer 6 / D39.2-open №1): every chunk's FINAL // export text EXACTLY by prod semantics (final_hash → checkpoint → exportNormalize), so the polygon // extractor / reader-samples read the SAME bytes the backend ships instead of the raw checkpoint. Like // report/status it is $0 and needs no provider keys (D20.4 — an audit surface must not demand keys). // Default output is stable JSON (the machine surface); --plaintext emits the human concatenation. func export(cfgPath string, asPlaintext, asPairs bool) error { r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() exp, err := r.Export(asPairs) if err != nil { return err } return renderExport(os.Stdout, exp, asPlaintext) } // 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) } // manifestCmd rebuilds and persists the book's chapter/chunk manifest (backlog row 100). $0 and key-less // like the other read commands: it ingests + cuts the source and writes a sidecar beside the project DB — // no LLM, no provider key, no wave. // // It opens the store exactly as `status` does (NewReadOnlyRunner): read-only, no flock — EXCEPT on the // first touch of a project, where that path falls back to a full Open and therefore CREATES and migrates // the database. That is the state this command is most often run in ("parsed, never run"), so the // side effect is the common case rather than the corner one, and it is the same one `status` has always // had. It also needs the project directory to be writable, unlike status/report/export. // // It exists because the manifest is needed BEFORE any run: a book that has been accepted and cut but // never translated still has a chapter tree, and every other producer of that structure is a paid path. // A translate rewrites the manifest itself (bookrun.go), so this command is for the not-yet-run case and // for refreshing the artifact by hand after a source edit. func manifestCmd(cfgPath string, asJSON bool) error { r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() m, err := r.BuildAndPersistManifest() if err != nil { return err } return renderManifest(os.Stdout, m, r.ManifestPath(), asJSON) } // 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). // --accept-rebill[=usd] is the separate consent to the AMOUNT such a re-pin re-pays (D20.2-Q2): over // the threshold Redrive refuses BEFORE its destructive reset, so the flag telemetry survives a refusal. // --ceiling-usd applies here for the same reason it applies to translate: a redrive re-attacks flagged // chunks with real provider calls, so leaving it on the book's ceiling alone would be a hole in exactly // the surface row 145 exists to close. func redrive(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, sel pipeline.RedriveSelector, verifyBank bool, ceilingUSD float64) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { return err } defer r.Close() r.Resnapshot = resnapshot r.AcceptRebill = acceptRebill r.VerifyBank = verifyBank r.CeilingUSD = ceilingUSD summary, res, err := r.Redrive(ctx, sel) if err != nil { // A redrive can hit the bank-mining stop too (it re-runs the durable loop), and its reset is // DESTRUCTIVE and already done by then — so a bare error text left the operator with reset rows, // exit 3 and no idea why (S17). Print the same banner translate does. var sigStop *pipeline.WaveSignatureStop if errors.As(err, &sigStop) { renderSignatureStop(os.Stdout, sigStop) } return err } return renderRedrive(os.Stdout, r.Book.BookID, summary, res, func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) }) }