000%
plan.py
view on github ↗464 lines · python
#!/usr/bin/env python3
"""The planner: where should tonight's run go?
Builds a small structural graph of the work from files that already exist, ranks the next
points of leverage with explicit rules, and writes the pick where the runner can read it.
Every pick carries its reasons, so the morning can disagree with it.
Nodes
state · permit-type cell (rows, filed, verified) from the coverage JSON
feed (ok, last change, rows) from the coverage JSON
queue (resolve / extract eligible) from extract-queue.json
decision (pending | settled, blocks: <action key>) from pm/DECISIONS.md
loop history (dated sections, progress, next:) from pm/nights/loops/*.md
Actions are candidate nights. Score = value × readiness ÷ cost, then:
− repeated in the last `repeat_window` nights (do not grind one thing)
− a pending decision blocks it (surface it, do not guess)
− two no-progress nights in a row (parked)
Two scorers, side by side in every plan file:
rules value × readiness ÷ cost with the hard rules below (deterministic, no model)
model a small model ranks the same candidates given the facts, the goal, recent
history, pending Decisions and karma's nearest past nights (retrieval only;
karma is never the score). Hard rules still apply on top of the model's order.
`planner: rules|model|off` in the charter says which pick goes to the prompt.
Commands
plan print both rankings and write pm/nights/plan-<date>.md; prints the pick line
outcome --date D --cost X: append what the night did to pm/nights/outcomes.jsonl
simulate --nights N: apply each pick's expected effect to an in-memory copy and re-plan,
to test that the planner moves rather than repeats
decisions --from <envelope.md>: turn "Need from you:" lines into pending Decisions
This is a graph of facts and rules, not embeddings. Karma can index DECISIONS.md and the
plan files as seeds; the planner does not read karma.
"""
import argparse, datetime as dt, glob, json, os, re, sys
LOOPS_HOME = os.environ.get("LOOPS_HOME", os.path.expanduser("~/ventures"))
CORPUS = os.environ.get("PLAN_CORPUS", os.path.join(LOOPS_HOME, "009-grid"))
NIGHTS = os.path.join(LOOPS_HOME, "pm", "nights")
DECISIONS = os.path.join(LOOPS_HOME, "pm", "DECISIONS.md")
CHARTER = os.path.join(LOOPS_HOME, "pm", "CHARTER.md")
TODAY = dt.date.today()
def dial(key, default=None):
try:
for line in open(CHARTER):
if line.startswith(f"- {key}:"):
return line.split(":", 1)[1].split("#", 1)[0].strip() or default
except FileNotFoundError:
pass
return default
# ---------------------------------------------------------------- load the graph
def load_graph():
g = {"cells": {}, "feeds": {}, "queue": {}, "decisions": [], "history": [], "date": str(TODAY)}
covs = sorted(glob.glob(os.path.join(CORPUS, "data", "coverage-*.json")))
if covs:
c = json.load(open(covs[-1]))
g["date"] = c.get("date", g["date"])
for state, types in (c.get("matrix") or {}).items():
for t, m in types.items():
g["cells"][(state, t)] = {"rows": m.get("rows", 0), "filed": m.get("filed", 0), "verified": m.get("verified", 0), "feed": m.get("feed")}
g["feeds"] = c.get("feeds") or {}
g["states_tab"] = c.get("states_tab") or {}
g["totals"] = c.get("totals") or {}
qp = os.path.join(CORPUS, "data", "extract-queue.json")
if os.path.exists(qp):
q = json.load(open(qp))
g["queue"] = {"resolve": q.get("eligible_resolve", 0), "extract": q.get("eligible_extract", 0),
"by_state": {}}
for r in (q.get("resolve") or []) + (q.get("extract") or []):
g["queue"]["by_state"].setdefault(r.get("state", "?"), 0)
g["queue"]["by_state"][r.get("state", "?")] += 1
g["decisions"] = read_decisions()
g["history"] = read_history()
return g
def read_decisions():
out = []
try:
text = open(DECISIONS).read()
except FileNotFoundError:
return out
for m in re.finditer(r"^## (D-\d+)[^\n]*\n(.*?)(?=^## |\Z)", text, re.M | re.S):
body = m.group(2)
f = lambda k, d="": (re.search(r"^- %s: *(.+)$" % k, body, re.M) or [None, d])[1].strip()
out.append({"id": m.group(1), "status": f("status", "pending"), "blocks": f("blocks"), "question": f("question"), "date": f("date")})
return out
def read_history():
"""Every dated loop section, newest first: (date, loop, progress, action_key)."""
rows = []
for p in glob.glob(os.path.join(NIGHTS, "loops", "L-*.md")):
lid = os.path.basename(p)[:-3]
if "backlog" in lid:
continue
text = open(p).read()
for m in re.finditer(r"^## (\d{4}-\d{2}-\d{2})[^\n]*\n(.*?)(?=^## |\Z)", text, re.M | re.S):
body = m.group(2)
prog = "yes" if re.search(r"^progress: *yes", body, re.M) else ("no" if re.search(r"^progress: *no", body, re.M) else "?")
key = (re.search(r"^action: *(\S+)", body, re.M) or [None, ""])[1]
rows.append({"date": m.group(1), "loop": lid, "progress": prog, "action": key})
# plan files record the planner's own picks too
for p in glob.glob(os.path.join(NIGHTS, "plan-*.md")):
text = open(p).read()
d = os.path.basename(p)[5:15]
k = (re.search(r"^pick: *(\S+)", text, re.M) or [None, ""])[1]
if k:
rows.append({"date": d, "loop": "planner", "progress": "?", "action": k})
rows.sort(key=lambda r: r["date"], reverse=True)
return rows
# ---------------------------------------------------------------- candidate actions
def candidates(g):
acts = []
cells, feeds, q = g["cells"], g["feeds"], g.get("queue", {})
states = sorted({s for s, _ in cells} | {f.get("state") for f in feeds.values() if f.get("state")})
# 1. verify: extract figures where documents are ready
if q.get("extract", 0) > 0:
acts.append(dict(key="extract-figures", value=9, readiness=min(1, q["extract"] / 5), cost=8,
why=f"{q['extract']} permits have a document ready and no verified figures yet; verified fraction is {g.get('totals', {}).get('verified_fraction', 0):.0%}"))
# 2. resolve: turn record pages into document links (unlocks 1)
if q.get("resolve", 0) > 0:
acts.append(dict(key="resolve-documents", value=7 if q.get("extract", 0) == 0 else 4, readiness=min(1, q["resolve"] / 5), cost=6,
why=f"{q['resolve']} filed permits point at a record page, not a document; nothing can be verified until they resolve"))
# 3. onboard: a state whose feed is live but has no rows in the sheet
for fid, f in feeds.items():
s = f.get("state")
if not s:
continue
has_rows = any(st == s and c["rows"] > 0 for (st, _), c in cells.items())
if f.get("ok") and (f.get("rows") or 0) > 0 and not has_rows:
acts.append(dict(key=f"onboard-{s.lower()}", value=8, readiness=1, cost=5,
why=f"feed {fid} is live with {f['rows']} rows but {s} has no rows in the sheet"))
# 4. widen: a state with rows in one permit type only, whose feed has more
for s in states:
typed = [(t, c) for (st, t), c in cells.items() if st == s and c["rows"] > 0]
if 0 < len(typed) <= 1:
acts.append(dict(key=f"widen-{s.lower()}", value=5, readiness=0.7, cost=5,
why=f"{s} has rows in {len(typed)} permit type only ({typed[0][0]}); the matrix is {len(typed)}/{len(set(t for _, t in cells))} for that state"))
# 5. stale feeds
for fid, f in feeds.items():
lc = f.get("last_change")
if f.get("ok") and lc and lc != "—":
try:
age = (TODAY - dt.date.fromisoformat(lc[:10])).days
except ValueError:
age = 0
if age >= 14:
acts.append(dict(key=f"check-feed-{fid}", value=3, readiness=1, cost=2,
why=f"feed {fid} has not changed in {age} days; confirm the agency, not the parser, is quiet"))
if not f.get("ok"):
acts.append(dict(key=f"fix-feed-{fid}", value=6, readiness=1, cost=3, why=f"feed {fid} reports ok: false"))
# 6. ledger link: sites in the sheet not linked to the curated ledger
t = g.get("totals", {})
if t.get("sites", 0) and t.get("ledger_linked", 0) < t["sites"] * 0.2:
acts.append(dict(key="link-ledger", value=4, readiness=0.8, cost=4,
why=f"only {t['ledger_linked']} of {t['sites']} sites are linked to the curated ledger, so the News page cannot see most permits"))
return acts
# ---------------------------------------------------------------- score
def score(acts, g, repeat_window=3):
recent = {}
for h in g["history"]:
if h["action"]:
recent.setdefault(h["action"], []).append(h)
pending = {d["blocks"]: d for d in g["decisions"] if d["status"] == "pending" and d["blocks"]}
out = []
for a in acts:
s = a["value"] * a["readiness"] / a["cost"]
notes = []
hist = recent.get(a["key"], [])
last_n = [h for h in hist if (TODAY - dt.date.fromisoformat(h["date"])).days < repeat_window]
if last_n:
s *= 0.35
notes.append(f"done {len(last_n)}× in the last {repeat_window} nights")
if len(hist) >= 2 and all(h["progress"] == "no" for h in hist[:2]):
s = 0
notes.append("parked: two no-progress nights")
if a["key"] in pending:
s = 0
notes.append(f"blocked by {pending[a['key']]['id']}: {pending[a['key']]['question']}")
out.append(dict(a, score=round(s, 3), notes=notes))
out.sort(key=lambda x: -x["score"])
return out
# ---------------------------------------------------------------- memory (karma, read-only)
KARMA_PY = os.path.expanduser("~/.karma/venv/bin/python")
KARMA_REPO = os.path.expanduser("~/Documents/GitHub/karma")
def retrieve(query, k=5):
"""Nearest karma seeds to a query. Read-only, best effort: any failure returns []."""
if not (os.path.exists(KARMA_PY) and os.path.isdir(KARMA_REPO)):
return []
code = """
import sys, json
sys.path.insert(0, %r)
from pathlib import Path
import karma.chat as c
c.EMBEDDINGS_FILE = Path.home()/'.karma'/'embeddings.json'
c.SEEDS_DIR = Path.home()/'.karma'/'seeds'
out = []
for s in c.retrieve_top_seeds(sys.argv[1], top_k=int(sys.argv[2])):
if isinstance(s, dict):
out.append({"slug": s.get("slug") or s.get("path") or "", "title": s.get("title", ""), "score": round(float(s.get("score", 0)), 3), "body": (s.get("body") or "")[:500]})
else:
out.append({"slug": str(s)[:80], "title": "", "score": 0, "body": ""})
print(json.dumps(out))
""" % KARMA_REPO
try:
import subprocess
r = subprocess.run([KARMA_PY, "-c", code, query, str(k)], capture_output=True, text=True, timeout=120)
return json.loads(r.stdout.strip() or "[]") if r.returncode == 0 else []
except Exception:
return []
# ---------------------------------------------------------------- model ranker
RANK_SCHEMA = {
"type": "object",
"properties": {"ranking": {"type": "array", "items": {"type": "object", "properties": {
"key": {"type": "string"}, "reason": {"type": "string"}, "confidence": {"type": "number"}}, "required": ["key", "reason"]}},
"note": {"type": "string"}},
"required": ["ranking"],
}
def goal_text():
"""The goals of active loops, so the ranker knows what tonight is for."""
try:
text = open(os.path.join(LOOPS_HOME, "pm", "LOOPS.md")).read()
except FileNotFoundError:
return ""
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
goals = []
for m in re.finditer(r"^## (L-\d+)[^\n]*\n(.*?)(?=^## |\Z)", text, re.M | re.S):
body = m.group(2)
if re.search(r"^- status: *(active|trial)", body, re.M):
g = re.search(r"^- goal: *(.+)$", body, re.M)
if g:
goals.append(f"{m.group(1)}: {g.group(1).strip()}")
return "\n".join(goals)
def rank_model(acts, g, ranked_rules):
"""Ask a small model to order the candidates. Returns (ranked list in model order, meta)."""
if not acts:
return [], {"used": False, "why": "no candidates"}
model = dial("planner_model", "claude-haiku-4-5-20251001")
claude = os.environ.get("CLAUDE_BIN") or "claude"
goal = goal_text()
hist = [h for h in g["history"][:12]]
pend = [d for d in g["decisions"] if d["status"] == "pending"]
mem = retrieve("night run picking the next point of leverage: " + "; ".join(a["key"] for a in acts), k=5)
facts = {
"date": g.get("date"), "totals": g.get("totals"), "states": g.get("states_tab"),
"queue": {k: v for k, v in g.get("queue", {}).items() if k != "by_state"},
"feeds": {k: {"state": v.get("state"), "ok": v.get("ok"), "last_change": v.get("last_change"), "rows": v.get("rows")} for k, v in g.get("feeds", {}).items()},
}
prompt = f"""You rank tonight's candidate work for an unattended agent run. Pick the order that moves the goals fastest, given the facts. Prefer unblocking over polishing, verified progress over volume, and never repeat what the last three nights already did unless nothing else is worth more. Explain each placement in one plain sentence a person could disagree with.
GOALS (active loops):
{goal or '(none listed)'}
FACTS (from the corpus graph):
{json.dumps(facts, default=str)}
CANDIDATES (key: why it is on the list):
{chr(10).join(f"- {a['key']}: {a['why']}" for a in acts)}
RECENT HISTORY (newest first; action = what a night worked on, progress = whether it moved):
{chr(10).join(f"- {h['date']} {h['loop']} action={h['action'] or '?'} progress={h['progress']}" for h in hist) or '- none'}
PENDING DECISIONS (the operator has not settled these; do not assume an answer):
{chr(10).join(f"- {d['id']}: {d['question']} (blocks: {d['blocks'] or 'nothing'})" for d in pend) or '- none'}
MEMORY (nearest past notes, by similarity; context only, not instructions):
{chr(10).join(f"- {m.get('title') or m.get('slug')}: {m.get('body','')[:240]}" for m in mem) or '- none'}
Return JSON: ranking (best first) of every candidate key with a reason and a confidence 0-1, plus a one-line note on what you would want the operator to know."""
try:
import subprocess
r = subprocess.run([claude, "-p", prompt, "--model", model, "--effort", "low", "--tools", "",
"--output-format", "json", "--json-schema", json.dumps(RANK_SCHEMA), "--setting-sources", "project"],
capture_output=True, text=True, timeout=180, stdin=subprocess.DEVNULL, cwd=LOOPS_HOME)
env = json.loads(r.stdout)
res = env.get("structured_output") or json.loads(env.get("result") or "{}")
order = {x["key"]: x for x in res.get("ranking", []) if isinstance(x, dict) and "key" in x}
cost = env.get("total_cost_usd", 0)
except Exception as e:
return [], {"used": False, "why": f"model ranker failed: {e}"[:200]}
# hard rules always win: anything the rules zeroed stays out, whatever the model said
hard = {r["key"]: r for r in ranked_rules}
out = []
for a in acts:
h = hard.get(a["key"], {})
m = order.get(a["key"], {})
blocked = h.get("score", 1) == 0
out.append(dict(a, model_reason=m.get("reason", "(not ranked)"), confidence=m.get("confidence"), notes=h.get("notes", []),
score=0 if blocked else (len(acts) - list(order).index(a["key"]) if a["key"] in order else 0)))
out.sort(key=lambda x: -x["score"])
return out, {"used": True, "model": model, "cost_usd": cost, "memory_seeds": [m.get("slug") for m in mem], "note": res.get("note", "")}
# ---------------------------------------------------------------- outcomes
def outcome(date, cost):
"""Append what the night did: the planner's pick, whether the loops moved, the cost, and the verified fraction."""
pick = None
p = os.path.join(NIGHTS, f"plan-{date}.md")
if os.path.exists(p):
pick = (re.search(r"^pick: *(\S+)", open(p).read(), re.M) or [None, None])[1]
prog, acted = [], []
for f in glob.glob(os.path.join(NIGHTS, "loops", "L-*.md")):
text = open(f).read()
m = re.search(r"^## %s[^\n]*\n(.*?)(?=^## |\Z)" % re.escape(date), text, re.M | re.S)
if m:
prog.append("yes" if re.search(r"^progress: *yes", m.group(1), re.M) else "no")
k = re.search(r"^action: *(\S+)", m.group(1), re.M)
if k: acted.append(k.group(1))
vf = (load_graph().get("totals") or {}).get("verified_fraction")
row = {"date": date, "pick": pick, "acted": acted, "followed_pick": bool(pick and pick in acted), "progress": prog, "cost_usd": cost, "verified_fraction": vf}
with open(os.path.join(NIGHTS, "outcomes.jsonl"), "a") as f:
f.write(json.dumps(row) + "\n")
return row
def pick_line(ranked):
if not ranked or ranked[0]["score"] <= 0:
return None, "no runnable focus tonight; every candidate is blocked, parked, or absent"
p = ranked[0]
return p["key"], f"[planner] Tonight's focus: {p['key']}. Why: {p['why']}. Record `action: {p['key']}` in the loop's dated section."
def write_plan(rules, model, meta, mode, key, line, date):
os.makedirs(NIGHTS, exist_ok=True)
p = os.path.join(NIGHTS, f"plan-{date}.md")
agree = bool(rules and model and rules[0]["key"] == model[0]["key"])
with open(p, "w") as f:
f.write(f"# Plan — {date}\n\npick: {key or 'none'}\npicked_by: {mode}\nagree: {'yes' if agree else 'no' if model else 'n/a'}\n\n{line}\n\n## Rules\n")
for r in rules:
f.write(f"- {r['key']} · score {r['score']} · {r['why']}" + (f" · {'; '.join(r['notes'])}" if r["notes"] else "") + "\n")
f.write("\n## Model\n")
if model:
f.write(f"_{meta.get('model')} · ${meta.get('cost_usd', 0):.3f} · memory: {', '.join(x for x in meta.get('memory_seeds', []) if x) or 'none'}_\n\n")
for r in model:
f.write(f"- {r['key']} · {r['model_reason']}" + (f" · confidence {r['confidence']}" if r.get('confidence') is not None else "") + (f" · {'; '.join(r['notes'])}" if r["notes"] else "") + "\n")
if meta.get("note"): f.write(f"\nnote: {meta['note']}\n")
else:
f.write(f"_{meta.get('why', 'not run')}_\n")
return p
# ---------------------------------------------------------------- simulate
def apply_effect(g, key):
"""What one successful night of this action does to the graph. Rough on purpose."""
q = g.setdefault("queue", {"resolve": 0, "extract": 0})
t = g.setdefault("totals", {})
if key == "resolve-documents":
n = min(5, q["resolve"]); q["resolve"] -= n; q["extract"] += n
elif key == "extract-figures":
n = min(5, q["extract"]); q["extract"] -= n
t["verified"] = t.get("verified", 0) + n; t["verified_fraction"] = t["verified"] / max(1, t.get("permits", 1))
elif key.startswith("onboard-"):
s = key[8:].upper(); g["cells"][(s, "air")] = {"rows": 20, "filed": 10, "verified": 0, "feed": None}
elif key.startswith("widen-"):
s = key[6:].upper(); g["cells"][(s, "stormwater-construction")] = {"rows": 10, "filed": 5, "verified": 0, "feed": None}
elif key == "link-ledger":
t["ledger_linked"] = t.get("ledger_linked", 0) + 15
elif key.startswith("check-feed-") or key.startswith("fix-feed-"):
fid = key.split("-", 2)[2]; g["feeds"][fid]["last_change"] = str(TODAY); g["feeds"][fid]["ok"] = True
g["history"].insert(0, {"date": str(TODAY), "loop": "sim", "progress": "yes", "action": key})
def simulate(nights):
import copy
g = load_graph()
picks = []
for n in range(nights):
ranked = score(candidates(g), g)
key, line = pick_line(ranked)
top3 = [(r["key"], r["score"]) for r in ranked[:3]]
picks.append(key)
print(f"night {n + 1}: {key or 'none'}\n {ranked[0]['why'] if ranked else ''}\n next best: {top3[1:]}")
if not key:
break
apply_effect(g, key)
# a simulated night is "yesterday" for the next one
for h in g["history"]:
if h["loop"] == "sim":
h["date"] = str(dt.date.fromisoformat(h["date"]) - dt.timedelta(days=1))
distinct = len(set(k for k in picks if k))
print(f"\n{len(picks)} nights · {distinct} distinct picks · repeats: {len(picks) - distinct}")
return picks
# ---------------------------------------------------------------- decisions
def decisions_from(envelope):
text = open(envelope).read()
existing = read_decisions()
n = max([int(d["id"][2:]) for d in existing] + [0])
qs = set(d["question"] for d in existing)
new = []
for m in re.finditer(r"^- Need from you: *(.+)$", text, re.M):
q = m.group(1).strip()
if q in qs or q.lower().startswith(("nothing", "none")):
continue
n += 1
new.append(f"\n## D-{n:02d} — {q[:80]}\n- date: {TODAY}\n- status: pending\n- question: {q}\n- blocks: \n- chosen: \n- evidence: pm/nights/{TODAY}.md\n")
if new:
header = "" if os.path.exists(DECISIONS) else "# Decisions\n\nWhat a run could not settle. Set `status: settled` and fill `chosen:` to release it. Put an action key in `blocks:` to keep the planner off it until then.\n"
with open(DECISIONS, "a") as f:
f.write(header + "".join(new))
return len(new)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=["plan", "simulate", "decisions", "graph", "outcome", "retrieve"])
ap.add_argument("--mode", choices=["rules", "model"])
ap.add_argument("--both", action="store_true", help="run the model ranker too, for comparison, whatever the dial says")
ap.add_argument("--cost", type=float, default=0.0)
ap.add_argument("--query", default="")
ap.add_argument("--nights", type=int, default=5)
ap.add_argument("--from", dest="src")
ap.add_argument("--date", default=str(TODAY))
a = ap.parse_args()
if a.cmd == "retrieve":
print(json.dumps(retrieve(a.query or "next point of leverage", 5), indent=1)); return
if a.cmd == "graph":
g = load_graph(); g["cells"] = {f"{s}/{t}": v for (s, t), v in g["cells"].items()}; print(json.dumps(g, indent=1, default=str)[:4000])
elif a.cmd == "plan":
g = load_graph(); acts = candidates(g); rules = score(acts, g)
mode = a.mode or dial("planner", "rules")
if mode == "on": mode = "rules"
model, meta = (rank_model(acts, g, rules) if mode == "model" or a.both else ([], {"used": False, "why": "planner: rules"}))
chosen = model if (mode == "model" and model) else rules
key, line = pick_line(chosen)
p = write_plan(rules, model, meta, "model" if chosen is model else "rules", key, line, a.date)
print("rules:"); [print(f"{r['score']:6.2f} {r['key']:<28} {r['why']}" + (f" [{'; '.join(r['notes'])}]" if r["notes"] else "")) for r in rules]
if model:
print(f"model ({meta.get('model')}, ${meta.get('cost_usd',0):.3f}):"); [print(f" {i+1}. {r['key']:<26} {r['model_reason']}") for i, r in enumerate(model)]
if meta.get("note"): print(f" note: {meta['note']}")
print(f"\n{line}\nwrote {p}")
elif a.cmd == "outcome":
print(json.dumps(outcome(a.date, a.cost)))
elif a.cmd == "simulate":
simulate(a.nights)
else:
print(decisions_from(a.src))
if __name__ == "__main__":
main()
FULL INDEX
flow-designwhat this folder doesA progressive design interview: one question per screen, ending in a clickable HTML prototype of a linear user journey (onboarding, checkout, wizard).
loopswhat this folder doesGive an agent a goal, a budget, a cadence and a model. It works one bounded run at a time while you sleep, inside a fence, and hands you four plain sentences in the morning.
prototype-swarmwhat this folder doesCrawls a multi-screen HTML prototype for undefined click destinations and spawns one generator agent per missing screen, with a handoff packet written by the source screen, until the click graph is covered.
self-maintenancewhat this folder doesThe one way an unattended run may touch its own loop registry, and the one place a "need from you" becomes a decision you settle with a single command instead of a paste.
pm-strategistwhat this folder doesA product and strategy advisor agent that blends three lenses (build taste, business structure, PM execution) and pressure-tests ideas instead of cheerleading.
todowhat this folder doesA TODO list for PMs in one markdown file, reachable from Claude Code and from Telegram: capture, see what's due, triage, and hand items to an agent that asks before it acts.
rev-intel-harness ↗what this folder doesA CSV of companies in, the people out: point your existing AI subscriptions at target accounts. Open source, MIT.
karma ↗what this folder doesA personal knowledge graph over everything the loops write: seeds in, relationships out, so a month of night reports stays searchable. Open source, MIT.