use crate::cli::SchemaTarget;
use crate::error;
use crate::output::CONTRACT;
use serde_json::{Value, json};
pub fn contract(target: SchemaTarget) -> Value {
let records = json!({
"cut": {"kind":"cut","id":"bl_<12 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","text":"string <= 10000 bytes","tags":["string"],"severity":"minor|major|blocker","cwd":"repo-relative path when cwd is inside the discovered repo; ~-relative when under the home directory; absolute path otherwise","source":"optional opaque string; omitted for self-reports; hook exec claude-code writes hook","evidence":"optional {cmd:string,exit:integer,stderr:string,note:string}; absent fields omitted; values best-effort redacted, including home-path rewriting"},
"dogear": {"kind":"dogear","id":"bl_<20 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","text":"string <= 10000 bytes","tags":["string"],"evidence":"optional string","cwd":"repo-relative path when cwd is inside the discovered repo; ~-relative when under the home directory; absolute path otherwise"},
"resolve": {"kind":"resolve","id":"bl_<12 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","note":"string|null","task":"optional string; omitted when absent","pr":"optional string; omitted when absent","commit":"optional string; omitted when absent","url":"optional string; omitted when absent","dropped":"optional true; omitted when false","amend":"optional true; omitted when false"},
"list_item": {"record":"all cut or dogear fields","status":"open|resolved","resolution":"{ts,agent,note,task?,pr?,commit?,url?,dropped?,amended?:true only when an amend wins}|omitted"}
});
let errors = json!({
"shape": {"ok":false,"error":{"code":"string","message":"string","details":{},"retryable":false,"suggested_fix":"string"},"meta":{"contract":CONTRACT}},
"codes": error::error_codes()
});
let exit_codes: Value = json!(error::exit_code_map());
match target {
SchemaTarget::Record => json!({"contract":CONTRACT,"records":records}),
SchemaTarget::Error => json!({"contract":CONTRACT,"errors":errors}),
SchemaTarget::ExitCodes => json!({"contract":CONTRACT,"exit_codes":exit_codes}),
SchemaTarget::All => json!({
"contract": CONTRACT,
"success_envelope": {"ok":true,"data":"command-specific object","meta":{"contract":CONTRACT,"file":"absolute path where relevant","agent_source":"flag|env|detected|default where relevant","warnings":["string; omitted when empty"]}},
"commands": {
"add": {"alias":["log"],"positional":"TEXT or -; optional when stdin is piped","flags":{"--agent":"NAME","--tag":"TAG; repeatable","--severity":"minor|major|blocker; default minor; how much did it hurt? blocker: could not proceed; major: lost real time; minor: a papercut","--cmd":"TEXT; optional evidence command; leading hyphens accepted","--exit":"N; optional evidence exit status","--stderr-file":"PATH; regular UTF-8 file up to 1 MiB, redacted then stored up to 4096 bytes; symlinks follow targets","--evidence":"TEXT; optional evidence note; leading hyphens accepted","--dry-run":"boolean"},"output":"{changed,record}","read_only":false,"appends":true,"destructive":false},
"dogear": {"alias":["idea"],"positional":"TEXT or -; optional when stdin is piped","flags":{"--agent":"NAME","--tag":"TAG; repeatable","--evidence":"TEXT; optional research note; leading hyphens accepted","--dry-run":"boolean"},"output":"{changed,record}","read_only":false,"appends":true,"destructive":false},
"list": {"flags":{"--kind":"cut|dogear|all; default cut; --severity is rejected unless kind is cut","--status":"open|resolved|all; default open","--agent":"NAME","--tag":"TAG","--include-auto":"boolean; include records tagged auto; implied by --tag auto","--severity":"minor|major|blocker; cut only","--since":"full RFC3339|Nd|Nh","--limit":"N; default 50","--format":"json|md; default json"},"output":"{items,count,total,truncated}; md is raw markdown","semantics":"records tagged auto are excluded by default; --include-auto includes them, and --tag auto implies --include-auto","read_only":true,"appends":false,"destructive":false},
"export": {"flags":{"--format":"otlp-json; required","--since":"full RFC3339|Nd|Nh","--include-auto":"boolean; include records tagged auto"},"output":"raw OTLP LogsData JSON object; exactly one newline-terminated JSONL line with no success envelope or warnings","raw_output":true,"eventName":"blotter.friction.reported","semantics":"exports folded cuts of every status, excludes dogears and evidence fields, excludes records tagged auto by default, and sorts log records by cut timestamp ascending then ID ascending","read_only":true,"appends":false,"destructive":false},
"triage": {"flags":{"--min-count":"N; default 3; must be at least 2","--include-auto":"boolean; include records tagged auto"},"output":"{clusters:[{count,occurrences,ids,tags,text,source?,suggested_action}],count,scanned}","semantics":"records tagged auto are excluded by default; --include-auto includes them; equal non-empty normalized titles link regardless of tags; otherwise matching tags (or both untagged) use filtered-token linkage: 80% overlap with the shorter token set or 3 shared locally rare tokens","exit_codes":{"0":"no chronic clusters","1":"chronic clusters found"},"read_only":true,"appends":false,"destructive":false},
"verify": {"flags":{"--include-auto":"boolean; include records tagged auto"},"output":"{recurrences:[{resolved_id,resolved_text,source?,resolution:{ts,task?,pr?,commit?},recurrence_ids,count,first_recurrence_ts}],count,scanned}","semantics":"records tagged auto are excluded by default; --include-auto includes them; each eligible resolved cut is an independent anchor; a recurrence is an open cut whose ts is strictly after the anchor's materialized resolution ts (latest amend wins) and that links under triage's exact title/tag/filtered-token rule; dogears, dropped resolutions, and empty normalized resolved titles are excluded; one open cut may recur against multiple anchors","exit_codes":{"0":"no recurrences","1":"recurrences found"},"read_only":true,"appends":false,"destructive":false},
"retrospect": {"flags":{},"output":"{candidates:[{type,title,program?,record_ids,resolved_anchor_ids?,occurrences,first_ts,last_ts,evidence:{texts:[...],resolution_notes:[...]}}],count,scanned}","candidate_types":["wrapper_alias","doc_repair","skill_candidate"],"semantics":"retrospect takes no window: chronic signal is long-horizon by design; auto-captures are included by default; open-cut clusters use triage's representative linkage and r19 scoring at a two-record candidate threshold, with wrapper_alias taking precedence over doc_repair; resolved recurrences use verify's anchor and linkage rules, and only recurrence counts of two or more become skill_candidate; evidence contains only bounded texts and resolution notes, never evidence command, stderr, or note fields","exit_codes":{"0":"no promotion candidates","1":"promotion candidates found"},"read_only":true,"appends":false,"destructive":false},
"digest": {"flags":{"--since":"full RFC3339|Nd|Nh; default 7d","--include-auto":"boolean; include records tagged auto","--format":"json|md; default json"},"output":"{chronic:[{count,occurrences,ids,tags,text,source?,suggested_action}],new_cuts:{count,by_tag:[{tag,count,ids}]},open_dogears:{count,items:[{id,ts,text,tags}]},window:{since,until}}","semantics":"records tagged auto are excluded by default; --include-auto includes them; chronic uses all open cuts with triage min_count 2; new_cuts uses open cuts in the inclusive since/until window and groups untagged cuts under tag ''; open_dogears includes all open dogears","format":{"json":"success envelope","md":"raw pasteable markdown without an envelope; empty sections are omitted, an empty report is 'No friction in window.', and warnings are trailing > note: lines"},"read_only":true,"appends":false,"destructive":false},
"sweep": {"positional":"PATH...; repository directories or direct JSONL logs; at least one path from PATHS or --registry is required","flags":{"--registry":"FILE; user-owned text file with one path per line; blank lines and # comments ignored; relative paths resolve from the registry file's directory","--since":"full RFC3339|Nd|Nh; optional","--kind":"cut|dogear|all; default cut","--include-auto":"boolean; include records tagged auto"},"output":"{repos:[{path,counts:{open_cuts,open_dogears},by_tag:[{tag,count}],items:[ListItem...],truncated?:true}],totals:{repos_swept,repos_skipped,open_cuts,open_dogears}}","semantics":"records tagged auto are excluded by default; --include-auto includes them; each path is read under its own shared lock and repos sort by canonical log path; counts and totals include all open items, while items and by_tag honor --kind and --since before the per-repo 50-item cap; BLOTTER_FILE is ignored; global --file conflicts with sweep; per-repo lock timeouts and unreadable logs become skip warnings with exit 0, a sweep-scoped deliberate deviation from exit-75","read_only":true,"appends":false,"destructive":false},
"resolve": {"positional":"one or more IDs; optional bl_ or pc_ plus at least 4 hex digits each","flags":{"--note":"TEXT; leading hyphens accepted","--agent":"NAME","--task":"ID; optional graduation task; valid for cuts and dogears","--pr":"URL; optional graduation pull request; valid for cuts and dogears","--commit":"SHA; optional graduation commit; valid for cuts and dogears","--url":"URL; dogear-only publication URL; conflicts with --dropped","--dropped":"boolean; dogear-only lifecycle outcome; conflicts with --url","--amend":"boolean; append a correction for already-resolved records; requires at least one resolution field","--dry-run":"boolean"},"output":"{changed,records:[...]}; always an array, including one ID; IDs are canonicalized, sorted, and duplicate inputs collapse; non-amend mixed already-resolved IDs warn with a sorted count/list; amend requires every requested record already be resolved and appends the batch atomically","amend_fold":"the first non-amend resolve is the base; the latest amend for a resolved record replaces all base user-set fields and supplies the materialized ts and agent; orphan amends warn and do not materialize; no log record is rewritten","read_only":false,"appends":true,"destructive":false},
"archive": {"flags":{"--before":"full RFC3339|Nd|Nh; required","--dry-run":"boolean; plan without writes"},"output":"{changed,archived,kept,archive_file,backup,restore_hint}; archive_file, backup, and restore_hint are null for dry-run and no-op","semantics":"archives only closed current bl_ cut and dogear groups when every group event predates the exclusive cutoff; malformed, unknown, orphan, and legacy pc_ lines remain verbatim; auto-tagged records are eligible","read_only":true,"appends":false,"destructive":false,"apply":{"read_only":false,"appends":false,"destructive":true,"semantics":"apply re-decides under an exclusive lock, writes a timestamped backup and archive sidecar whose names derive from BLOTTER_NOW, then atomically replaces the log; reruns under an identical clock fail with io_error by design"}},
"hook": {"targets":["claude-code"],"install":{"positional":"claude-code","flags":{"--settings":"PATH; explicit Claude Code settings file","--global":"use ~/.claude/settings.json; conflicts with --settings","--dry-run":"boolean"},"output":"{changed,settings_path,command}","read_only":false,"appends":false,"destructive":false},"exec":{"positional":"claude-code","contract":"Claude Code reads one JSON payload from stdin (max 1 MiB), files only failed Bash calls into an existing log with source hook, and dedupes when any open cut's text equals the redacted command text; stdout is always empty and exec always exits with exit 0.","payload":{"read_fields":["hook_event_name","tool_name","tool_input.command","error","is_interrupt","cwd"],"required_fields":["hook_event_name","tool_name","tool_input.command"],"stdin_max_bytes":1048576,"gates":{"hook_event_name":"must equal PostToolUseFailure","tool_name":"must equal Bash","is_interrupt":"must not be true","tool_input.command":"must be non-empty","tool_input.command_bytes":"must be at most 500; longer commands are noise and are skipped","tool_input.command_program":"best-effort first program after leading VAR=value assignments (basename only) must not be a read-only probe; non-zero exit is an expected answer and grep, rg, ls, find, tail, head, cat, stat, test, [, which, curl, and gh are skipped","resolved_log_file":"must already exist"}},"explain":{"env":"BLOTTER_HOOK_EXPLAIN","enabled_value":"1","contract":"when set exactly to 1, writes one best-effort human-readable reason to stderr; stdout remains empty and exit remains 0"},"output":"none","read_only":false,"appends":true,"destructive":false}},
"schema": {"positional":"all|record|error|exit-codes; default all","read_only":true,"appends":false,"destructive":false},
"doctor": {"flags":{"--fix":"repair torn_line, malformed, and conflict_marker findings","--dry-run":"plan repairs without writes; requires --fix","--leaks":"scan every raw physical line for home-path leaks; conflicts with --fix","--deny":"LITERAL; non-empty repeatable literal raw-line deny pattern; requires --leaks and therefore conflicts with --fix"},"output":"{healthy,findings:[{line,kind,message,fixable}],checked_lines,fix?:{changed,applied:[{line,kind,action}],backup?,quarantine?,restore_hint?,dry_run}}","semantics":"--leaks adds diagnose-only leak findings for home paths and --deny literals; leak is not fixable, and --leaks/--deny conflict with --fix because the gate is read-only","exit_codes":{"0":"healthy","1":"findings"},"read_only":true,"appends":false,"destructive":false,"fix":{"read_only":false,"appends":true,"destructive":true,"semantics":"--fix re-inspects under an exclusive lock; every fix is backed up, quarantined, and removed by atomic replacement"}}
},
"global_flags": ["--file <PATH>","--pretty"],
"env": {
"BLOTTER_FILE":"log-file override",
"BLOTTER_AGENT":"agent-name fallback",
"BLOTTER_NOW":"full RFC3339 clock override",
"BLOTTER_HOOK_EXPLAIN":"hook exec diagnostics when set exactly to 1; one best-effort stderr line, stdout empty, exit 0"
},
"records": records,
"id": {"prefix":"bl_","accepted_prefixes":["bl_","pc_"],"legacy_prefix":"pc_ is accepted for legacy records and never emitted","encoding":"u32 little-endian UTF-8 byte length before each field","cut":{"hex_digits":12,"hash":"SHA-256 first 6 bytes","fields_in_order":["literal bl1","literal cut","ts","agent","text","severity","tag count","each sorted unique tag as its own field"]},"dogear":{"hex_digits":20,"hash":"SHA-256 first 10 bytes","fields_in_order":["literal bl1","literal dogear","ts","agent","text","tag count","each sorted unique tag as its own field"]}},
"discovery": ["--file","BLOTTER_FILE","nearest .git directory or file then <root>/.blotter.jsonl","$HOME/.blotter/log.jsonl"],
"errors": errors,
"exit_codes": exit_codes,
"storage": {"format":"append-only JSONL","locking":"local filesystems only; 50 retries x 100ms","durability":"best effort; no fsync per append"}
}),
}
}