Build a Stage-Gate Lifecycle Tracker

Encode nine real estate development gates and prove a broken deal stops.

Introduction

30 Second Summary

Every real estate deal passes through a series of checkpoints before it becomes a building. At each checkpoint, someone decides: keep going or walk away. Most people can name a few of these stages, but they cannot tell you exactly where a deal should stop or why.

In this project, you will build a stage-gate tracker that encodes nine development stages as decision gates, then prove it works by running two deals through it. One deal advances through every gate. The other is engineered to fail at a specific gate, proving the tracker catches what should die.

What You'll Build

A running Streamlit app where you click through tabs showing a nine-row tracker, watch a good deal advance through every gate with reasoning displayed, and see a broken deal stop dead at the exact gate it should.

By the end of this project, you'll have:

  • A passing-deal run that shows GO at every gate with one-line reasoning, proving the tracker advances a healthy deal all the way to disposition.
  • A broken-deal run that shows GO, GO, GO... KILL at one named gate, with the specific kill trigger displayed and no further gates evaluated.
  • A coverage-check report, a teach-back instrument, a Mermaid topology diagram, and a plain-language guide documenting the entire system as a reusable portfolio artifact.
  • Secret Mission: Present a two-audience walkthrough with lessons learned, proving you can teach the system at two levels of abstraction.

Are there any prerequisites?

None. This guide includes full setup for Python, Streamlit, and Cursor. The entire system runs locally with no cloud resources and no metered APIs.

Before We Start

You are about to build a stage-gate tracker that turns the real estate development lifecycle into a decision tool. Instead of memorizing nine stages in sequence, you will encode each one as a gate with criteria, risks, and a kill trigger that stops a bad deal.

Before any code can run, you need Python, Streamlit, and Cursor installed locally. You also need the delivery rails in place: a private GitHub repo and a Linear project so every step ships through pull requests.

In this step, get ready to:
  • Install Python 3.14 and Cursor.
  • Create the project workspace with a virtual environment and Git repo.
  • Install Streamlit and set up the Linear delivery project.
Install Python 3.14

Python is the language powering every agent in the tracker. Check whether you already have it by running the version command for your platform.

macOS

  • Open Terminal by pressing Cmd+Space and typing Terminal, then pressing Enter.
  • Check your Python version by running this command:
python3 --version

Windows

  • Open a terminal by pressing the Windows key and typing cmd, then pressing Enter.
  • Check your Python version by running this command:
python --version

Check the output in your terminal and pick the tab that matches what you see:

✔️ I see version 3.14 or higher

You have the right version installed. Move on to the next substep.

ⓧ I see an older version

Your system has an older Python that will not work for this project. Install Python 3.14 alongside it.

  • Go to python.org/downloads in your browser.
  • Download the installer for Python 3.14.6 (the latest stable release).
  • Run the installer.

macOS

  • Double-click the downloaded .pkg file to launch the macOS Installer.
  • Click Continue through the prompts and then Install.
  • After installation completes, double-click Install Certificates.command in the /Applications/Python 3.14/ folder.
  • Close the installer and confirm by running:
python3 --version

You should see Python 3.14.6 (or similar 3.14.x).

Windows

  • Run the downloaded .exe installer.
  • Check the box that says Add python.exe to PATH at the bottom of the first screen.
  • Click Install Now.
  • Close the installer and open a fresh terminal by pressing the Windows key, typing cmd, and pressing Enter.
  • Confirm by running:
python --version

You should see Python 3.14.6 (or similar 3.14.x).

Why a fresh terminal?

The installer updates your system PATH so your terminal can find the new Python. A terminal that was already open before the install will not see the change.

ⓧ Command not found

Python is not installed yet. Install it now.

  • Go to python.org/downloads in your browser.
  • Download the installer for Python 3.14.6 (the latest stable release).
  • Run the installer.

macOS

  • Double-click the downloaded .pkg file to launch the macOS Installer.
  • Click Continue through the prompts and then Install.
  • After installation completes, double-click Install Certificates.command in the /Applications/Python 3.14/ folder.
  • Close the installer and confirm by running:
python3 --version

You should see Python 3.14.6 (or similar 3.14.x).

Windows

  • Run the downloaded .exe installer.
  • Check the box that says Add python.exe to PATH at the bottom of the first screen.
  • Click Install Now.
  • Close the installer and open a fresh terminal by pressing the Windows key, typing cmd, and pressing Enter.
  • Confirm by running:
python --version

You should see Python 3.14.6 (or similar 3.14.x).

Still not seeing a version number?

On Windows, make sure you checked Add python.exe to PATH during installation. If you missed it, re-run the installer, select Modify, and enable the PATH option.

On macOS, try python3.14 --version if python3 --version still shows an older version. The macOS system ships its own older python3 that may shadow the new install.

help me troubleshoot my Python installation

Set up Cursor and your project workspace

Cursor is the AI-powered code editor you will use for the entire build. It includes a built-in terminal, so once it is open you will not need to switch to a separate app.

  • Download Cursor from cursor.com/download and run the installer.
  • Once installed, press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Sign in when prompted (free Hobby tier works for this project).

Cursor opens to a welcome screen. Now you will create the project folder and open it.

  • Open Cursor's integrated terminal by pressing Ctrl+` (backtick key, top-left of keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder by running:
mkdir gauntlet-tracker
  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the gauntlet-tracker folder, then click Open.

You should see gauntlet-tracker in Cursor's sidebar and at the top of the window.

Don't see the folder in Cursor's sidebar?

Make sure you selected the gauntlet-tracker folder itself, not the Desktop. The folder name should appear in Cursor's title bar.

help me open a folder in Cursor

Next, create the virtual environment and activate it. A virtual environment isolates this project's packages from your system Python.

  • Open Cursor's terminal with Ctrl+` (it should now open inside gauntlet-tracker).
  • Create the virtual environment by running this command:

macOS

python3 -m venv .venv
  • Activate the virtual environment by running:
source .venv/bin/activate

Windows

python -m venv .venv
  • Activate the virtual environment by running:
.venv\Scripts\activate

Your terminal prompt should now show (.venv) at the beginning of the line. This confirms the virtual environment is active.

No (.venv) in your prompt?

On Windows, if you see a permissions error, you may need to allow script execution. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.

help me activate my Python virtual environment

Now initialize Git and connect to a private GitHub remote.

  • Initialize the local Git repository by running:
git init
  • Go to github.com/new in your browser.
  • Enter gauntlet-tracker as the repository name.
  • Select Private for visibility.
  • Leave all other options unchecked (no README, no .gitignore).
  • Click Create repository.
  • Copy the HTTPS URL shown on the next page (it looks like https://github.com/your-username/gauntlet-tracker.git).
  • Back in Cursor's terminal, add the remote by running:
git remote add origin [[REPO_URL="https://github.com/your-username/gauntlet-tracker.git"]]
  • Confirm the remote is set by running:
git remote -v

You should see your repository URL listed twice (for fetch and push).

Seeing 'fatal: not a git repository'?

Make sure your terminal is inside the gauntlet-tracker folder. Run pwd (macOS) or cd (Windows) to check the current directory. The path should end in gauntlet-tracker.

help me connect my local Git repo to GitHub

Install Streamlit and initialize the delivery project

Streamlit is the framework that turns your Python agents into an interactive web app. You will also set up your Linear project to track each step as a ticket.

  • Create the requirements.txt file that pins your dependencies by running:

macOS

cat > requirements.txt << 'EOF'
streamlit==1.59.2
pandas>=2.0.0
EOF

Windows

@"
streamlit==1.59.2
pandas>=2.0.0
"@ | Out-File -Encoding utf8 requirements.txt
  • Install the dependencies by running:
pip install -r requirements.txt

What is this installing?

Streamlit 1.59.2 is the web framework that renders the tracker as an interactive app. Pandas is used for displaying the stage-gate table with proper column formatting.

Before you run the verification command, what do you think will happen when you ask Streamlit to say hello?

  • Verify Streamlit works by running:
streamlit hello

Your default browser opens a Streamlit demo page with animated charts and maps. This confirms Streamlit is installed correctly.

  • Return to your terminal and press Ctrl+C to stop the Streamlit server.

Browser did not open?

Check that the terminal shows a local URL (something like http://localhost:8501). Copy that URL into your browser manually.

If you see a "command not found" error, make sure your virtual environment is still active (you should see (.venv) in your prompt).

help me get streamlit hello working

Now set up your Linear project for delivery tracking.

  • Go to linear.app and sign up or sign in.
  • From your workspace, click the + button to create a new project.
  • Enter The Gauntlet as the project name.
  • Create one issue (ticket) for each remaining step: Design Agent System, Run Passing Deal, Run Broken Deal, Teach-Back and Guide.

Why track tickets in Linear?

Each step ships through a pull request that closes its matching Linear ticket when merged. This gives you a visible trail of decisions and completed work, just like a professional team.

Finally, commit requirements.txt as the initial commit.

  • Stage and commit by running these commands:
git add requirements.txt
git commit -m "Initial commit: requirements.txt"
  • Confirm the commit landed by running:
git log --oneline

You should see a single commit with the message Initial commit: requirements.txt.

✔️ Awesome, I've got everything!

Great. Double check you have (.venv) in your prompt, a commit in git log, and the Streamlit hello page opened earlier.

ⓧ I'd like to double check the full code

Here is what requirements.txt should contain:

streamlit==1.59.2
pandas>=2.0.0

Your workspace is ready. Next up, you will design the agent system, write the decision records, and encode all nine development stages into the tracker.

Design the Agent System and Encode the Stages

Your environment is ready and your delivery rails are set. Now you need to know what you are building before you write a single line of Python.

This step follows a design-before-build order. You will document the agent system (its principles, roster, and topology) first, then encode the nine development stages as data, then build the first two agents and wire them into a visible tracker.

In this step, get ready to:
  • Document the agent system design in decision records.
  • Encode nine development stages with four gate fields each.
  • Build the first agents and display the full tracker in Streamlit.
Create the design-before-build artifact

The agent system starts as a document, not as code. Writing down the principles, the roster, and the topology before any Python exists forces you to think through the architecture while changes are still free.

  • Create the docs/ folder by running this command in your Cursor terminal:
mkdir docs
  • Inside the docs/ folder, create a new file called DECISIONS.md.
  • Paste the following content at the top of docs/DECISIONS.md:
# Decision Records: The Gauntlet Agent System

## Agent-Building Principles

1. Each agent has one role, one `run()` method, and returns structured data.
2. No LLM dependency. No API call. No metered cost. Rule-based evaluation only.
3. Agents are composable: the orchestrator calls them in sequence, each receiving
   the output of prior agents as needed.
4. Design before build: this document exists before any agent code is written.

## Agent Roster

| Agent | Role | Goal | Input | Output |
|-------|------|------|-------|--------|
| StageExtractor | Extract the ordered spine | Return the nine stage names in lifecycle order | STAGES list | List of strings |
| GateEncoder | Fill the four gate fields | Return decision, criteria, risk, kill trigger per stage | STAGES list | List of dicts |
| DealRunner | Evaluate a deal against gates | Return GO/KILL log, short-circuit at first KILL | Deal dict + STAGES | Run log |
| CoverageChecker | Verify tracker completeness | Confirm 9 stages, 4 fields each, correct order | STAGES list | Pass/fail report |
| TeachBackScorer | Score learner reasoning | Assess go/kill decisions against gate criteria | Learner responses | Scored feedback |
| Orchestrator | Coordinate the pipeline | Run all agents in sequence, return complete results | All inputs | All outputs |

What does this document?

The four principles define how every agent in the system behaves: one role, one method, no external dependency, and the document comes first.

The roster table names each agent, its purpose, and what it takes in and produces. You will build these agents across the next three steps.

  • Continue in docs/DECISIONS.md by pasting the topology sketch below the Agent Roster section:
## Topology (Mermaid Sketch)

```text
graph TD
    O[Orchestrator] --> SE[StageExtractor]
    O --> GE[GateEncoder]
    SE --> TA[TrackerAssembler]
    GE --> TA
    TA --> DR[DealRunner]
    DR --> PDR[Passing Deal Run]
    DR --> BDR[Broken Deal Run]
    TA --> CC[CoverageChecker]
    DR --> TBS[TeachBackScorer]
    CC --> VR[Validation Report]
    TBS --> VR
    O --> DW[DocumentationWriter]
```

What is this Mermaid diagram?

Mermaid is a text-based diagram syntax. This sketch shows how the Orchestrator coordinates the other agents in sequence: extract stages, encode gates, run deals, check coverage, and score the teach-back.

You will render this diagram later in Step 5. For now it lives here as your architecture blueprint.

  • Finish docs/DECISIONS.md by pasting the design rationale and honest limits below the topology:
## Design Rationale

- Why rule-based agents (not LLM-powered): The tracker must run locally, forever,
  for free. No metered API. The kill-trigger logic is deterministic: a deal either
  trips the trigger or it does not. LLM inference adds cost and non-determinism
  where neither is needed.
- Why short-circuit evaluation: A real stage-gate process stops at the first failed
  gate. You do not evaluate construction risk for a deal that cannot get financed.
  The DealRunner mirrors this by returning immediately on KILL.
- Why the broken deal is engineered for the Financing gate: Financing sits in the
  middle of the sequence (gate 6 of 9), proving that prior gates evaluate correctly
  before the kill fires. A Concept-stage kill (gate 1) proves nothing about the
  middle. A Disposition-stage kill (gate 9) exercises everything but proves no
  short-circuit.
- Why Streamlit over a spreadsheet: The agent orchestration pattern requires code.
  Streamlit provides visual output with minimal overhead. A spreadsheet cannot
  encode short-circuit logic or agent composition natively.

## Honest Limits

This builds process knowledge, the tool, and a portfolio artifact.
It does not build a degree, a license, capital, or a track record.
Nothing here is investment, legal, or tax advice.

Why document rationale before coding?

Each "Why" entry answers a question a future reader (or your future self) will ask. Recording these decisions now means you do not have to reverse-engineer your own thinking later.

  • Save docs/DECISIONS.md.
  • Confirm the file exists by running:
ls docs/

You should see DECISIONS.md listed.

Don't see the file?

Make sure you created the docs/ folder first with mkdir docs. If the file is not listed, check that you saved it in the docs/ folder inside gauntlet-tracker, not at the project root.

help me find my DECISIONS.md file

Encode the nine development stages

The tracker's data backbone is a Python list of nine dictionaries, one per development stage. Each dictionary carries the stage name plus four gate fields: the decision, the criteria, the risk, and the kill trigger.

What is a stage-gate framework?

A stage-gate framework divides a process into stages separated by gates. At each gate, a GO/KILL decision determines whether work continues or stops.

The nine stages here (Concept through Disposition) are a practitioner breakdown of the ULI development framework. The kill-trigger thresholds are this project's teaching rubric, not universal industry bars.

  • Create a new file called stages.py in the root of your gauntlet-tracker folder.
  • Paste the first three stages into stages.py:
STAGES = [
    {
        "stage": "Concept",
        "decision": "Does this idea warrant further investigation?",
        "criteria": "Identified market need, preliminary site type, target product, and back-of-envelope pro forma showing positive spread",
        "risk": "Pursuing an idea with no market basis wastes time and early capital",
        "kill_trigger": "No identifiable demand driver or target tenant/buyer within the submarket"
    },
    {
        "stage": "Site Control",
        "decision": "Should we secure control of this specific site?",
        "criteria": "Site physically and legally available, purchase/option terms allow feasibility period, title clear of fatal encumbrances",
        "risk": "Committing earnest money to a site that cannot be developed as envisioned",
        "kill_trigger": "Title defect or existing encumbrance that cannot be cured within the feasibility period"
    },
    {
        "stage": "Feasibility",
        "decision": "Do the numbers work under realistic assumptions?",
        "criteria": "Market study supports absorption, pro forma IRR exceeds hurdle rate, DSCR above lender minimum, construction cost estimate within budget",
        "risk": "Advancing a deal whose economics depend on optimistic assumptions",
        "kill_trigger": "Pro forma IRR below hurdle rate after stress-testing rent and cost assumptions by 10 percent"
    },

What do these first three gates cover?

Concept asks if there is a market reason to pursue the idea at all. Site Control asks if you can actually secure the land. Feasibility asks if the numbers hold under stress.

Each kill trigger is specific and testable. "No demand driver" kills at Concept. "Title defect that cannot be cured" kills at Site Control.

  • Continue in stages.py by pasting the next three stages directly below the Feasibility entry:
    {
        "stage": "Entitlement",
        "decision": "Can we obtain the approvals needed to build what the pro forma assumes?",
        "criteria": "Zoning allows proposed use (or variance/rezoning path is viable), no fatal environmental condition, community and political landscape navigable",
        "risk": "Spending design and soft-cost dollars on a project that cannot be permitted",
        "kill_trigger": "Zoning denial with no viable appeal or alternative use that meets the pro forma"
    },
    {
        "stage": "Design",
        "decision": "Does the design deliver the program within the budget?",
        "criteria": "Schematic design matches entitled program, construction cost estimate aligns with pro forma budget, design supports target lease rates or sale prices",
        "risk": "Locking in a design whose cost exceeds the budget or whose program undershoots revenue",
        "kill_trigger": "Construction cost estimate exceeds pro forma hard-cost budget by more than 15 percent with no value-engineering path"
    },
    {
        "stage": "Financing",
        "decision": "Can the capital structure support this project through completion?",
        "criteria": "Construction loan committed at terms consistent with pro forma, equity raised, debt-service coverage ratio meets lender covenant, interest reserve funded",
        "risk": "Starting construction without committed capital or with terms that create covenant-breach exposure",
        "kill_trigger": "DSCR below 1.20x on committed loan terms, or equity gap exceeding 20 percent of total equity requirement"
    },

What do gates 4 through 6 cover?

Entitlement asks if you can get permission to build. Design asks if the building fits the budget. Financing asks if the capital structure holds together.

The Financing kill trigger (DSCR below 1.20x or equity gap exceeding 20%) is the one that will stop the broken deal later in Step 4.

  • Finish stages.py by pasting the final three stages and closing the list:
    {
        "stage": "Construction",
        "decision": "Is the project on track to deliver on budget and schedule?",
        "criteria": "Monthly draw requests match percent-complete, change orders within contingency, schedule variance less than 30 days, no unresolved lien or payment disputes",
        "risk": "Cost overruns or delays that exhaust contingency and trigger lender intervention",
        "kill_trigger": "Contingency fully consumed with more than 20 percent of work remaining"
    },
    {
        "stage": "Lease-Up",
        "decision": "Is the project reaching stabilized occupancy at projected rates?",
        "criteria": "Leasing velocity on pace to reach stabilization within pro forma timeline, effective rents within 5 percent of projection, concessions within budget, tenant credit quality meets lender requirements",
        "risk": "Carrying costs mounting while occupancy lags, eroding returns and threatening debt service",
        "kill_trigger": "Leasing velocity below 50 percent of projection for two consecutive quarters with no pipeline"
    },
    {
        "stage": "Disposition",
        "decision": "Is the exit achievable at or above the target return?",
        "criteria": "Stabilized NOI supports target cap rate, buyer pool active, hold/sell analysis favors sale, tax and transaction costs modeled",
        "risk": "Holding past the optimal exit window or selling into a compressed market",
        "kill_trigger": "Achievable sale price implies IRR more than 300 basis points below target with no hold-and-improve path"
    }
]

What do the final three gates cover?

Construction checks progress against budget. Lease-Up checks whether the market absorbs the product. Disposition checks whether the exit achieves the target return.

Notice that the last entry has no trailing comma after the closing brace. The ] closes the STAGES list.

  • Save stages.py.
  • Verify the file loads correctly by running:
python -c "from stages import STAGES; print(len(STAGES), 'stages loaded')"

You should see 9 stages loaded printed in the terminal.

Seeing a SyntaxError?

Check that every dictionary has matching braces {} and that each entry except the last is followed by a comma. A missing comma between dictionaries is the most common cause.

help me fix a SyntaxError in stages.py

Build the first agents and display the tracker

The decision records named six agents. This step builds the first two: StageExtractor (returns the ordered spine of stage names) and GateEncoder (fills the four gate fields per stage).

  • Create a new file called agents.py in your gauntlet-tracker folder and paste the StageExtractor class:
class StageExtractor:
    """Returns the ordered spine of nine development stage names."""

    def __init__(self, stages):
        self.stages = stages

    def run(self):
        # Return just the stage names in lifecycle order
        return [s["stage"] for s in self.stages]

What does StageExtractor do?

It takes the full STAGES list and returns only the nine stage names in order. Other agents use this spine for display, validation, and sequencing.

  • Add the GateEncoder class below StageExtractor in agents.py:
class GateEncoder:
    """Returns the four gate fields for a given stage."""

    def __init__(self, stages):
        self.stages = stages

    def run(self, stage_name):
        # Look up a single stage by name and return its gate fields
        for s in self.stages:
            if s["stage"] == stage_name:
                return {
                    "decision": s["decision"],
                    "criteria": s["criteria"],
                    "risk": s["risk"],
                    "kill_trigger": s["kill_trigger"]
                }
        return None

    def run_all(self):
        # Return all stages with their gate fields for the full tracker view
        return [
            {
                "stage": s["stage"],
                "decision": s["decision"],
                "criteria": s["criteria"],
                "risk": s["risk"],
                "kill_trigger": s["kill_trigger"]
            }
            for s in self.stages
        ]

What does GateEncoder do?

The run() method looks up a single stage by name and returns its four gate fields as a dictionary. The run_all() method returns the complete tracker data for all nine stages at once.

  • Save agents.py.
  • Confirm both agents import correctly by running:
python -c "from stages import STAGES; from agents import StageExtractor, GateEncoder; print('Agents loaded')"

You should see Agents loaded with no errors.

Seeing an ImportError?

Make sure agents.py and stages.py are both saved in the root of your gauntlet-tracker folder (not inside docs/). Also confirm your virtual environment is still active (you should see (.venv) in the terminal prompt).

help me fix my import error in agents.py

  • Create app.py in the root of your gauntlet-tracker folder to wire the agents into a Streamlit display:
import pandas as pd
import streamlit as st
from agents import StageExtractor, GateEncoder
from stages import STAGES

# Configure the page
st.set_page_config(page_title="The Gauntlet: Stage-Gate Tracker", layout="wide")

# Title and caveat
st.title("The Gauntlet: Stage-Gate Development Lifecycle Tracker")
st.caption(
    "Kill-trigger thresholds are the project's own teaching rubric, not universal bars. "
    "Sample deals are hypothetical. Nothing here is investment, legal, or tax advice."
)

# Run the GateEncoder to get all tracker data
encoder = GateEncoder(STAGES)
tracker_data = encoder.run_all()

# Convert to a DataFrame and display as an interactive table
df = pd.DataFrame(tracker_data)
df.columns = ["Stage", "Decision", "Criteria", "Risk", "Kill Trigger"]

st.dataframe(
    df,
    hide_index=True,
    column_config={
        "Stage": st.column_config.TextColumn("Stage", width="small"),
        "Decision": st.column_config.TextColumn("Decision", width="medium"),
        "Criteria": st.column_config.TextColumn("Criteria", width="large"),
        "Risk": st.column_config.TextColumn("Risk", width="medium"),
        "Kill Trigger": st.column_config.TextColumn("Kill Trigger", width="large"),
    }
)

What does this app do?

The app imports STAGES and runs GateEncoder.run_all() to produce a list of dictionaries. It converts that list into a pandas DataFrame and displays it as a wide interactive table with st.dataframe.

The column_config parameter controls column widths so the longer text fields get more space.

  • Save app.py.

Before you run the app, what do you expect to see in the browser?

  • Launch the tracker by running:
streamlit run app.py

A local Streamlit server starts and the app opens in a new browser tab. You should see a table titled "The Gauntlet" with nine rows and five columns: Stage, Decision, Criteria, Risk, and Kill Trigger.

Every cell is filled. No empty cells anywhere. That is your nine-stage tracker, live.

  • Stop the Streamlit server by pressing Ctrl+C in the terminal.

App not opening in the browser?

If the browser does not open automatically, look at the terminal output for a local URL (typically http://localhost:8501) and open it manually.

If you see a ModuleNotFoundError, confirm your virtual environment is active and that you ran pip install streamlit==1.59.2 inside it.

help me get my Streamlit app running

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: docs/DECISIONS.md, stages.py, agents.py, and app.py.

ⓧ I'd like to double check the full code

Since each file is long, confirm you have these sections in order:

  • docs/DECISIONS.md: Title, Agent-Building Principles (4 items), Agent Roster table (6 agents), Topology Mermaid sketch, Design Rationale (4 bullet explanations), Honest Limits.
  • stages.py: STAGES list with exactly 9 dictionaries (Concept, Site Control, Feasibility, Entitlement, Design, Financing, Construction, Lease-Up, Disposition), each with 5 keys (stage, decision, criteria, risk, kill_trigger).
  • agents.py: StageExtractor class (run() returns list of names) then GateEncoder class (run() returns one gate dict, run_all() returns all).
  • app.py: imports, page config, title, caption, encoder call, DataFrame creation, st.dataframe with column_config.
  • Commit your changes and push to GitHub by running:
git add .
git commit -m "Add agent design records, stage encoding, and tracker display"
  • Push the branch, open a pull request, and merge it to close the corresponding Linear ticket.

Your tracker is live and every gate has its teeth. Next up, you will run a passing deal through all nine gates and prove the tracker says GO when it should.

Run the Passing Deal Through All Gates

Your tracker has all nine gates encoded, but a gate that has never been tested is just a list. Does it actually advance a good deal? Does it say GO when it should?

In this step you feed a healthy deal through every gate to prove the tracker works as intended. You will build the deal data, write the agent that evaluates it, and display nine sequential GO decisions in the app.

In this step, get ready to:
  • Create a passing deal representing a hypothetical 120-unit mixed-use development.
  • Build the DealRunner agent that evaluates a deal gate-by-gate and short-circuits at the first KILL.
  • Update the app with a tabbed layout showing the deal run with nine GO decisions reaching Disposition.
Create the passing deal data

The DealRunner agent needs something to evaluate. A deal is a dictionary keyed by stage name, where each entry says whether the kill trigger fired and gives a reason.

  • Create a new file called deals.py in your gauntlet-tracker folder and paste the following code:
# Passing deal: hypothetical 120-unit mixed-use development
# Each stage entry has kill_triggered (bool) and reason (string)
passing_deal = {
    "name": "Riverside Commons",
    "description": "A hypothetical 120-unit mixed-use development with ground-floor retail in a growing suburban submarket.",
    "stages": {
        "Concept": {"kill_triggered": False, "reason": "Strong multifamily demand driver: submarket vacancy below 4%, population growth 3% annually, no competing supply in pipeline within 2-mile radius."},
        "Site Control": {"kill_triggered": False, "reason": "Purchase agreement executed with 90-day feasibility period. Title search clear. No encumbrances that restrict multifamily use."},
        "Feasibility": {"kill_triggered": False, "reason": "Pro forma IRR of 18% exceeds 15% hurdle rate after 10% rent stress test. DSCR at 1.35x. Market study confirms 8 units/month absorption."},
        "Entitlement": {"kill_triggered": False, "reason": "Site zoned MU-2 (mixed-use, multifamily permitted by right up to 5 stories). No rezoning required. Phase I environmental clear."},
        "Design": {"kill_triggered": False, "reason": "Schematic design at 118,000 SF. GC budget estimate of $22.1M is 6% below pro forma hard-cost budget of $23.5M. Value engineering not required."},
        "Financing": {"kill_triggered": False, "reason": "Construction loan committed at 65% LTC. DSCR at 1.35x exceeds 1.20x covenant. Equity fully subscribed. 18-month interest reserve funded."},
        "Construction": {"kill_triggered": False, "reason": "Month 10 of 18: 58% complete, 55% of budget drawn. Change orders total $340K against $1.2M contingency. Schedule on track."},
        "Lease-Up": {"kill_triggered": False, "reason": "Month 4 post-CO: 52 of 120 units leased (43%). Velocity at 13 units/month vs. 8 projected. Effective rents 2% above pro forma. Minimal concessions."},
        "Disposition": {"kill_triggered": False, "reason": "Stabilized NOI of $2.8M supports 5.2% cap rate sale at $53.8M. Three qualified offers received. IRR projects at 19.2%, exceeding 15% target."}
    }
}

What does this data represent?

Each stage entry has two fields. kill_triggered is a boolean that tells the DealRunner whether the deal tripped the kill trigger at this gate. reason provides the one-line explanation of why the deal passes or fails.

Every stage in Riverside Commons has kill_triggered: False because this deal is designed to pass all nine gates. The numbers are hypothetical and illustrative only.

  • Save deals.py.
  • Confirm the file exists by running:
ls deals.py

You should see deals.py listed in the output.

Add the DealRunner agent

The DealRunner is the agent that takes a deal and walks it through the gates in order. It records GO at each gate the deal passes, and short-circuits with KILL at the first gate the deal fails.

  • Open agents.py in Cursor. Scroll to the bottom of the file, below the GateEncoder class.
  • Add the DealRunner class by pasting this code at the end of the file:
class DealRunner:
    """Evaluates a deal against each gate in sequence. Short-circuits at first KILL."""

    def __init__(self, stages):
        self.stages = stages

    def run(self, deal):
        log = []
        for stage in self.stages:
            stage_name = stage["stage"]
            deal_data = deal.get("stages", {}).get(stage_name)
            if deal_data is None:
                log.append({"stage": stage_name, "decision": "KILL", "reason": f"No deal data provided for {stage_name} gate."})
                return log
            triggered = deal_data.get("kill_triggered", False)
            reason = deal_data.get("reason", "")
            if triggered:
                log.append({"stage": stage_name, "decision": "KILL", "reason": f"Kill trigger fired: {reason}"})
                return log
            else:
                log.append({"stage": stage_name, "decision": "GO", "reason": reason})
        return log

What does this code do?

The run(deal) method iterates through the STAGES list in lifecycle order. At each gate, it looks up the deal's data for that stage.

If kill_triggered is True, the agent records KILL and immediately returns the log. No further gates are evaluated. This mirrors a real stage-gate process where you stop spending money the moment a gate fails.

  • Save agents.py.
  • Test the agent by running this quick check in your terminal:
python -c "from agents import DealRunner; from stages import STAGES; from deals import passing_deal; result = DealRunner(STAGES).run(passing_deal); print(f'{len(result)} gates evaluated, first: {result[0]}')"

You should see output showing 9 gates evaluated and the first entry with a GO decision for the Concept stage.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is still active. The terminal prompt should start with (.venv). If not, reactivate it with .venv\Scripts\activate on Windows.

Also confirm you are running the command from inside the gauntlet-tracker folder where agents.py, stages.py, and deals.py all live.

Still stuck? Help me fix my DealRunner import error in the gauntlet-tracker project

Update the app with a tabbed layout

The app currently shows the tracker table as a single page. Now you will restructure it into tabs so the deal run gets its own dedicated view.

  • Open app.py in Cursor. Select all the existing content and replace it with the following code for the imports, config, and Tracker tab:
import pandas as pd
import streamlit as st
from agents import StageExtractor, GateEncoder, DealRunner
from stages import STAGES
from deals import passing_deal

st.set_page_config(page_title="The Gauntlet: Stage-Gate Tracker", layout="wide")
st.title("The Gauntlet: Stage-Gate Development Lifecycle Tracker")
st.caption("Kill-trigger thresholds are the project's own teaching rubric, not universal bars. Sample deals are hypothetical. Nothing here is investment, legal, or tax advice.")

# Create a tabbed layout with two sections
tab_tracker, tab_passing = st.tabs(["Tracker", "Run Passing Deal"])

# First tab: the nine-stage tracker table
with tab_tracker:
    st.header("Nine-Stage Tracker")
    encoder = GateEncoder(STAGES)
    tracker_data = encoder.run_all()
    df = pd.DataFrame(tracker_data)
    df.columns = ["Stage", "Decision", "Criteria", "Risk", "Kill Trigger"]
    st.dataframe(df, hide_index=True)

What changed here?

The imports now include DealRunner and passing_deal. The layout uses st.tabs to create two tab containers. The existing tracker table moves inside the first tab.

  • Directly below the code you just pasted, add the second tab that runs the passing deal:
# Second tab: run the passing deal through all nine gates
with tab_passing:
    st.header(f"Passing Deal: {passing_deal['name']}")
    st.write("This deal advances through every gate to disposition.")
    runner = DealRunner(STAGES)
    passing_run = runner.run(passing_deal)
    for entry in passing_run:
        if entry["decision"] == "GO":
            with st.expander(f"Gate: {entry['stage']} — GO", expanded=False):
                st.success(entry["reason"])
        else:
            with st.expander(f"Gate: {entry['stage']} — KILL", expanded=True):
                st.error(entry["reason"])
    if all(e["decision"] == "GO" for e in passing_run):
        st.balloons()
        st.success("Deal reached Disposition. All nine gates passed.")

How does the deal run display work?

The code iterates through the run log returned by DealRunner. Each gate gets an st.expander that the learner can click open to read the reasoning.

If all nine decisions are GO, st.balloons() fires a confetti animation and a success banner confirms the deal reached Disposition.

  • Save app.py.

Before you run the app, what do you expect to see in the "Run Passing Deal" tab?

  • Run the app by executing:
streamlit run app.py

Your browser opens the app. Click the "Run Passing Deal" tab. You should see nine expanders labeled with each gate name followed by GO. Open any expander to read the reasoning. Confetti fires and a green banner reads "Deal reached Disposition. All nine gates passed."

Not seeing the tabs or getting an import error?

If the app crashes with an import error for DealRunner or passing_deal, confirm you saved both agents.py and deals.py before running the app.

If you see the app but no tabs, check that you replaced the entire contents of app.py with the new code. The old version did not use st.tabs.

Stop the server with Ctrl+C when you are done reviewing.

Still stuck? Help me debug why my Streamlit app is not showing the tabbed layout with the passing deal run

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Confirm you have these three chunks in app.py, in order:

  • Imports and config (lines importing pandas, streamlit, DealRunner, STAGES, passing_deal, then set_page_config, title, and caption).
  • Tracker tab (with tab_tracker: containing the header, GateEncoder, DataFrame, and st.dataframe call).
  • Passing Deal tab (with tab_passing: containing the header, DealRunner, for loop with expanders, and the balloons/success check).

For deals.py, confirm the full file looks like this:

passing_deal = {
    "name": "Riverside Commons",
    "description": "A hypothetical 120-unit mixed-use development with ground-floor retail in a growing suburban submarket.",
    "stages": {
        "Concept": {"kill_triggered": False, "reason": "Strong multifamily demand driver: submarket vacancy below 4%, population growth 3% annually, no competing supply in pipeline within 2-mile radius."},
        "Site Control": {"kill_triggered": False, "reason": "Purchase agreement executed with 90-day feasibility period. Title search clear. No encumbrances that restrict multifamily use."},
        "Feasibility": {"kill_triggered": False, "reason": "Pro forma IRR of 18% exceeds 15% hurdle rate after 10% rent stress test. DSCR at 1.35x. Market study confirms 8 units/month absorption."},
        "Entitlement": {"kill_triggered": False, "reason": "Site zoned MU-2 (mixed-use, multifamily permitted by right up to 5 stories). No rezoning required. Phase I environmental clear."},
        "Design": {"kill_triggered": False, "reason": "Schematic design at 118,000 SF. GC budget estimate of $22.1M is 6% below pro forma hard-cost budget of $23.5M. Value engineering not required."},
        "Financing": {"kill_triggered": False, "reason": "Construction loan committed at 65% LTC. DSCR at 1.35x exceeds 1.20x covenant. Equity fully subscribed. 18-month interest reserve funded."},
        "Construction": {"kill_triggered": False, "reason": "Month 10 of 18: 58% complete, 55% of budget drawn. Change orders total $340K against $1.2M contingency. Schedule on track."},
        "Lease-Up": {"kill_triggered": False, "reason": "Month 4 post-CO: 52 of 120 units leased (43%). Velocity at 13 units/month vs. 8 projected. Effective rents 2% above pro forma. Minimal concessions."},
        "Disposition": {"kill_triggered": False, "reason": "Stabilized NOI of $2.8M supports 5.2% cap rate sale at $53.8M. Three qualified offers received. IRR projects at 19.2%, exceeding 15% target."}
    }
}

For the DealRunner class at the bottom of agents.py, confirm it matches:

class DealRunner:
    def __init__(self, stages):
        self.stages = stages

    def run(self, deal):
        log = []
        for stage in self.stages:
            stage_name = stage["stage"]
            deal_data = deal.get("stages", {}).get(stage_name)
            if deal_data is None:
                log.append({"stage": stage_name, "decision": "KILL", "reason": f"No deal data provided for {stage_name} gate."})
                return log
            triggered = deal_data.get("kill_triggered", False)
            reason = deal_data.get("reason", "")
            if triggered:
                log.append({"stage": stage_name, "decision": "KILL", "reason": f"Kill trigger fired: {reason}"})
                return log
            else:
                log.append({"stage": stage_name, "decision": "GO", "reason": reason})
        return log
  • Commit your changes and push a branch for this step:
git add deals.py agents.py app.py
git commit -m "feat: add passing deal run with DealRunner agent and tabbed layout"
git push origin step-3-passing-deal
  • Open a pull request in GitHub and merge it. Close the corresponding Linear ticket for this step.

Your tracker now proves it says GO when a deal deserves it. Next up, you will engineer a deal that is designed to fail at one specific gate, proving the tracker catches what should die.

Run the Broken Deal and Prove It Stops

Your tracker says GO at every gate for Riverside Commons. That proves it can advance a healthy deal. But a tracker that only ever says GO is a rubber stamp, not a gate.

The real test is the negative test. You need a deal engineered to fail at one specific gate, proving the tracker catches what should die. If it stops the deal at the right gate (not before, not after), your tracker decides rather than describes.

In this step, get ready to:
  • Add a broken deal (Grayfield Tower) engineered to hit the Financing kill trigger.
  • Run the broken deal and verify GO, GO, GO, GO, GO, KILL at the correct gate.
  • Add a coverage checker that validates all nine stages and all four fields.
Add the broken deal to deals.py

The broken deal is a hypothetical speculative office project called Grayfield Tower. It passes Concept through Design, then hits both kill triggers at the Financing gate: DSCR below 1.20x and an equity gap exceeding 20%.

  • In deals.py, add the following broken_deal dictionary below the existing passing_deal:
broken_deal = {
    "name": "Grayfield Tower",
    "description": "A hypothetical 200,000 SF speculative office development on a former retail site. Engineered to fail at the Financing gate.",
    "stages": {
        "Concept": {"kill_triggered": False, "reason": "Submarket office vacancy rising but still below 15%. Corporate relocations creating some demand. Concept proceeds to investigation."},
        "Site Control": {"kill_triggered": False, "reason": "Option agreement on former big-box retail site. Title clear. 120-day feasibility period. Control secured at low earnest money."},
        "Feasibility": {"kill_triggered": False, "reason": "Pro forma IRR of 16.5% exceeds 15% hurdle rate under base-case assumptions. DSCR at 1.25x. Absorption assumes 15,000 SF/quarter."},
        "Entitlement": {"kill_triggered": False, "reason": "Site zoned C-3 (commercial, office permitted). Building height within overlay limits. Phase I environmental clear after remediation complete."},
        "Design": {"kill_triggered": False, "reason": "Class A office design at 200,000 SF. GC estimate of $48M is 12% below pro forma hard-cost budget of $54.5M. Spec finish level appropriate for submarket."},
        "Financing": {"kill_triggered": True, "reason": "DSCR fallen to 1.08x on updated loan terms (rate increased 150bps since feasibility). Equity gap of 28% of total equity requirement after lead investor reduced commitment. Both triggers fired: DSCR below 1.20x and equity gap exceeds 20%."},
        "Construction": {"kill_triggered": False, "reason": "NOT EVALUATED. Deal killed at Financing gate."},
        "Lease-Up": {"kill_triggered": False, "reason": "NOT EVALUATED. Deal killed at Financing gate."},
        "Disposition": {"kill_triggered": False, "reason": "NOT EVALUATED. Deal killed at Financing gate."}
    }
}

What does this code do?

Grayfield Tower passes five gates (Concept through Design) with plausible data, then fires kill_triggered: True at Financing.

The flaw is specific: DSCR at 1.08x (below the 1.20x covenant) and a 28% equity gap (above the 20% threshold). Both conditions map directly to the Financing gate's kill trigger in stages.py. The DealRunner will short-circuit here and never evaluate Construction, Lease-Up, or Disposition.

  • Save deals.py.

✔️ Awesome, I've got everything!

Great. Make sure you saved deals.py with both passing_deal and broken_deal present.

ⓧ I'd like to double check the full code

Your deals.py should now contain two deal dictionaries. Scroll through and confirm:

  • passing_deal (Riverside Commons) with all 9 stages set to kill_triggered: False.
  • broken_deal (Grayfield Tower) with Concept through Design set to False and Financing set to True.
Wire up the broken deal tab in the app

The broken deal exists in data, but the app does not know about it yet. You need to import it, add a third tab, and display the DealRunner's output with the kill gate visually distinct.

  • In app.py, find this line near the top:
from deals import passing_deal
  • Replace it with:
from deals import passing_deal, broken_deal
  • Now find the tabs line:
tab_tracker, tab_passing = st.tabs(["Tracker", "Run Passing Deal"])
  • Replace it with:
tab_tracker, tab_passing, tab_broken = st.tabs(["Tracker", "Run Passing Deal", "Run Broken Deal"])
  • Add the following block at the bottom of app.py, below the existing with tab_passing: block:
with tab_broken:
    st.header(f"Broken Deal: {broken_deal['name']}")
    st.write("This deal is engineered to fail at one named gate.")
    # Run the DealRunner on the broken deal
    runner2 = DealRunner(STAGES)
    broken_run = runner2.run(broken_deal)
    # Display each gate result with expanders
    for entry in broken_run:
        if entry["decision"] == "GO":
            with st.expander(f"Gate: {entry['stage']} — GO", expanded=False):
                st.success(entry["reason"])
        else:
            with st.expander(f"Gate: {entry['stage']} — KILL", expanded=True):
                st.error("DEAL KILLED AT THIS GATE")
                st.write(entry["reason"])
    # Show the final kill summary
    killed_at = next((e["stage"] for e in broken_run if e["decision"] == "KILL"), None)
    if killed_at:
        st.error(f"Tracker stopped the deal at the {killed_at} gate. No further gates evaluated.")

What does this code do?

  • runner2.run(broken_deal) iterates through the nine gates. At each one, it checks kill_triggered. If True, it logs KILL and stops (short-circuit).
  • The KILL gate gets st.error() (red indicator) and its expander is expanded=True so the learner sees the kill reason immediately.
  • The final st.error() summary confirms which gate killed the deal and that no further gates were evaluated.
  • Save app.py.

Before you run the app, think about what you expect to see. Grayfield Tower passes Concept, Site Control, Feasibility, Entitlement, and Design. What should happen at Financing?

  • Run the app by entering this command in your terminal:
streamlit run app.py
  • Click the Run Broken Deal tab in the app.

You should see five GO gates (Concept through Design) in collapsed expanders, then a KILL at the Financing gate in an expanded red panel. The specific kill trigger is displayed: DSCR at 1.08x and equity gap of 28%. No gates after Financing are shown because the DealRunner short-circuited.

Why does the Financing gate kill this deal?

The Financing gate's kill trigger in stages.py fires when DSCR drops below 1.20x OR the equity gap exceeds 20%. Grayfield Tower trips both: rates rose 150bps since feasibility (pushing DSCR to 1.08x) and the lead investor pulled back (opening a 28% equity gap).

This is why Financing sits in the middle of the sequence (gate 6 of 9). A Concept-stage kill proves nothing about the middle gates. Financing proves five prior gates evaluate correctly before the kill fires.

🙋‍♀️ Not seeing the broken deal tab?

  • Check that your import line says from deals import passing_deal, broken_deal. A missing import causes a NameError.
  • Confirm the tabs line unpacks three variables: tab_tracker, tab_passing, tab_broken. A mismatch between the list and the variable count causes a crash.
  • Make sure the with tab_broken: block is at the same indentation level as with tab_passing: (no extra nesting).

Help me debug why my broken deal tab isn't appearing in Streamlit.

Add the coverage checker agent

The tracker has nine stages and the deals run through them. But how do you know nothing is missing? The CoverageChecker agent verifies completeness: all nine stages present, all four fields filled, correct lifecycle order.

  • In agents.py, add the CoverageChecker class below the existing DealRunner class:
class CoverageChecker:
    # The expected nine stages in lifecycle order
    EXPECTED_STAGES = ["Concept", "Site Control", "Feasibility", "Entitlement", "Design", "Financing", "Construction", "Lease-Up", "Disposition"]
    # The four fields every gate must have
    REQUIRED_FIELDS = ["decision", "criteria", "risk", "kill_trigger"]

    def __init__(self, stages):
        self.stages = stages

    def run(self):
        results = {"all_stages_present": True, "all_fields_filled": True, "order_correct": True, "gaps": [], "overall": "PASS"}
        actual_names = [s["stage"] for s in self.stages]
        # Check order and presence
        if actual_names != self.EXPECTED_STAGES:
            results["order_correct"] = False
            missing = set(self.EXPECTED_STAGES) - set(actual_names)
            extra = set(actual_names) - set(self.EXPECTED_STAGES)
            if missing:
                results["all_stages_present"] = False
                results["gaps"].append(f"Missing stages: {', '.join(missing)}")
            if extra:
                results["gaps"].append(f"Unexpected stages: {', '.join(extra)}")
            if not missing and not extra:
                results["gaps"].append(f"Order mismatch.")
        # Check all four fields are non-empty
        for stage in self.stages:
            for field in self.REQUIRED_FIELDS:
                value = stage.get(field, "")
                if not value or not value.strip():
                    results["all_fields_filled"] = False
                    results["gaps"].append(f"Empty field '{field}' in stage '{stage['stage']}'")
        if not (results["all_stages_present"] and results["all_fields_filled"] and results["order_correct"]):
            results["overall"] = "FAIL"
        return results

What does this code do?

  • EXPECTED_STAGES is the authoritative list of nine stages in lifecycle order. The checker compares the actual stages against this list.
  • The method checks three things: all nine stage names are present, they appear in the correct order, and all four gate fields are non-empty strings.
  • If any check fails, the gaps list names the specific problem (missing stages, empty fields, order mismatch). The overall result is PASS or FAIL.
  • Save agents.py.

Now wire the coverage checker into the app as a fourth tab.

  • In app.py, find this import line:
from agents import StageExtractor, GateEncoder, DealRunner
  • Replace it with:
from agents import StageExtractor, GateEncoder, DealRunner, CoverageChecker
  • Find the tabs line:
tab_tracker, tab_passing, tab_broken = st.tabs(["Tracker", "Run Passing Deal", "Run Broken Deal"])
  • Replace it with:
tab_tracker, tab_passing, tab_broken, tab_coverage = st.tabs(["Tracker", "Run Passing Deal", "Run Broken Deal", "Coverage Check"])
  • Add the following block at the bottom of app.py, below the with tab_broken: block:
with tab_coverage:
    st.header("Coverage Check Report")
    # Run the coverage checker on our stages
    checker = CoverageChecker(STAGES)
    coverage = checker.run()
    # Display three metric columns
    col1, col2, col3 = st.columns(3)
    with col1:
        if coverage["all_stages_present"]: st.success("All 9 stages present")
        else: st.error("Missing stages")
    with col2:
        if coverage["all_fields_filled"]: st.success("All fields filled")
        else: st.error("Empty fields detected")
    with col3:
        if coverage["order_correct"]: st.success("Order matches framework")
        else: st.error("Order mismatch")
    # Overall pass/fail
    if coverage["overall"] == "PASS": st.success("COVERAGE CHECK: PASS")
    else:
        st.error("COVERAGE CHECK: FAIL")
        for gap in coverage["gaps"]: st.write(f"- {gap}")

What does this code do?

  • st.columns(3) creates a three-column layout showing each coverage metric (stages present, fields filled, order correct) as a green success or red error indicator.
  • The overall PASS/FAIL summary appears below the columns. If anything fails, the specific gaps are listed so you know exactly what to fix.
  • Save app.py.
  • Return to your browser. Streamlit should auto-reload. Click the Coverage Check tab.

You should see three green success indicators (All 9 stages present, All fields filled, Order matches framework) and a green COVERAGE CHECK: PASS at the bottom.

Seeing an ImportError or NameError?

  • If the error mentions CoverageChecker, check that the class name in agents.py matches the import in app.py exactly (capital C, capital C).
  • Make sure the CoverageChecker class is defined at the module level in agents.py (not accidentally nested inside DealRunner).
  • Confirm the tabs line now unpacks four variables matching the four tab names in the list.

Help me debug why CoverageChecker is not working in my Streamlit app.

✔️ Awesome, I've got everything!

Great. Confirm you saved both agents.py and app.py before moving on.

ⓧ I'd like to double check the full code

Your agents.py should contain four classes in this order:

  • StageExtractor (returns ordered list of stage names).
  • GateEncoder (fills four fields per stage, with run() and run_all() methods).
  • DealRunner (evaluates a deal, short-circuits at first KILL).
  • CoverageChecker (verifies 9 stages, 4 fields each, correct order).

Your app.py should have these elements from top to bottom:

  • Imports: pandas, streamlit, all four agent classes, STAGES, passing_deal, and broken_deal.
  • Four tabs: Tracker, Run Passing Deal, Run Broken Deal, Coverage Check.
  • The tab_broken block with DealRunner on broken_deal, expanders, and st.error for the kill.
  • The tab_coverage block with CoverageChecker, three columns, and overall PASS/FAIL.
  • Stop the Streamlit server with Ctrl+C in your terminal.
  • Commit your changes and push the branch by running these commands:
git add -A
git commit -m "Add broken deal, coverage checker, and two new tabs"
git push origin main
  • In Linear, close the ticket for this step by linking the commit or creating a PR.

Your tracker now decides. It advances a good deal all the way to disposition and kills a broken deal at the right gate. Next up, you will add the teach-back instrument, the topology diagram, and the plain-language guide that turns this tool into a reusable artifact anyone can pick up.

Build the Teach-Back, Topology, and Guide

The tracker works and the deal runs prove it. A good deal advances through all nine gates. A broken deal stops at the right one.

Now you prove you can explain it (teach-back), document the architecture visibly (topology), and write plain-language docs so anyone can pick it up (guide). Without the guide, the tracker is a personal tool. With it, it is a reusable artifact.

In this step, get ready to:
  • Add the TeachBackScorer agent to score gate-by-gate reasoning.
  • Create the topology diagram and orchestrator to coordinate the full pipeline.
  • Write the plain-language guide for the repo.
Add the TeachBackScorer agent

The tracker can evaluate a deal. But can the learner evaluate a deal? The TeachBackScorer takes a dictionary of the learner's gate-by-gate reasoning and scores each response against the tracker's criteria.

  • In agents.py, scroll to the bottom of the file (after the CoverageChecker class).
  • Add the TeachBackScorer class by pasting the following code at the end of the file:
class TeachBackScorer:
    """Scores a learner's gate-by-gate reasoning against the tracker criteria."""

    def __init__(self, stages):
        self.stages = stages

    def run(self, learner_responses):
        scores = []
        for stage in self.stages:
            stage_name = stage["stage"]
            response = learner_responses.get(stage_name, "").strip()
            if not response:
                scores.append({"stage": stage_name, "result": "FAIL", "feedback": "No response provided. Name the decision and the kill trigger for this gate."})
                continue
            # Check if the response mentions a go/kill decision
            has_decision = any(keyword in response.lower() for keyword in ["go", "kill", "stop", "advance", "proceed", "reject", "pass"])
            # Check if the response references the gate's kill trigger
            has_kill_ref = any(keyword in response.lower() for keyword in stage["kill_trigger"].lower().split()[:5])
            if has_decision and has_kill_ref:
                scores.append({"stage": stage_name, "result": "PASS", "feedback": "Names a decision and references the gate's kill condition."})
            elif has_decision:
                scores.append({"stage": stage_name, "result": "PARTIAL", "feedback": f"Names a decision but does not reference the specific kill trigger. Hint: this gate's kill trigger involves: {stage['kill_trigger'][:60]}..."})
            else:
                scores.append({"stage": stage_name, "result": "FAIL", "feedback": "Response does not clearly name a go/kill decision. State whether the deal advances or dies, and why."})
        passed = sum(1 for s in scores if s["result"] == "PASS")
        total = len(scores)
        overall = "PASS" if passed >= 7 else "PARTIAL" if passed >= 5 else "FAIL"
        return {"scores": scores, "passed": passed, "total": total, "overall": overall}

What does this code do?

The scorer iterates through all nine stages and checks each learner response for two things: a clear go/kill decision keyword, and a reference to the gate's specific kill trigger (using the first five words of the trigger as a keyword match).

Responses that hit both earn PASS. Responses that name a decision but miss the trigger earn PARTIAL with a hint. Everything else is FAIL. The overall score requires 7+ passes for PASS, 5+ for PARTIAL.

  • Save agents.py.
  • Verify the new class imports cleanly by running this command in the terminal:
python -c "from agents import TeachBackScorer; print('TeachBackScorer loaded OK')"

You should see TeachBackScorer loaded OK printed in the terminal.

Seeing a SyntaxError?

Check that the f-string on the PARTIAL feedback line has matching quotes. The outer string uses double quotes and the inner dictionary access uses single quotes.

Also confirm the class is not indented inside the CoverageChecker class above it. It should start at column 0.

Help me fix my TeachBackScorer import error

Create the topology and orchestrator

The topology diagram documents the agent roster visually. The orchestrator coordinates the full pipeline so app.py calls one class instead of wiring each agent manually.

  • Create a new file called topology.py in your gauntlet-tracker folder and paste the following code:
"""
Generates the Mermaid topology diagram for the agent roster and orchestration flow.
"""


def generate_mermaid():
    # Returns a styled Mermaid graph showing how agents connect
    return """graph TD
    O[Orchestrator] --> SE[StageExtractor]
    O --> GE[GateEncoder]
    SE --> TA[TrackerAssembler]
    GE --> TA
    TA --> DR[DealRunner]
    DR --> PDR[Passing Deal Run]
    DR --> BDR[Broken Deal Run]
    TA --> CC[CoverageChecker]
    DR --> TBS[TeachBackScorer]
    CC --> VR[Validation Report]
    TBS --> VR
    O --> DW[DocumentationWriter]

    style O fill:#2d3748,color:#fff
    style SE fill:#4a5568,color:#fff
    style GE fill:#4a5568,color:#fff
    style TA fill:#4a5568,color:#fff
    style DR fill:#4a5568,color:#fff
    style CC fill:#2b6cb0,color:#fff
    style TBS fill:#2b6cb0,color:#fff
    style PDR fill:#276749,color:#fff
    style BDR fill:#9b2c2c,color:#fff
    style VR fill:#2b6cb0,color:#fff
    style DW fill:#4a5568,color:#fff
"""

What does this code do?

The function returns a Mermaid diagram string. Mermaid is a text-based diagramming language that renders as a flowchart. The style lines color-code agents by role: dark gray for core agents, blue for validation agents, green for the passing deal, and red for the broken deal.

  • Save topology.py.
  • Now create a new file called orchestrator.py in the same folder and paste the following code:
"""
Orchestrator: coordinates the full agent pipeline.
Runs agents in sequence, handles deal runs, and returns complete results.
"""

from stages import STAGES
from agents import (
    StageExtractor,
    GateEncoder,
    DealRunner,
    CoverageChecker,
    TeachBackScorer
)
from deals import passing_deal, broken_deal
from topology import generate_mermaid


class Orchestrator:
    """Runs the full stage-gate tracker pipeline."""

    def __init__(self):
        self.stages = STAGES
        self.results = {}

    def run(self):
        # Extract the ordered spine of stage names
        extractor = StageExtractor(self.stages)
        self.results["stage_names"] = extractor.run()
        # Encode all gate fields
        encoder = GateEncoder(self.stages)
        self.results["tracker"] = encoder.run_all()

What does this first section do?

The Orchestrator imports every agent and both deals. Its run() method calls each agent in sequence, starting with the StageExtractor and GateEncoder. All results go into a single dictionary.

  • Continue adding the rest of the run() method directly below what you just pasted:
        # Run both deals through the gates
        runner = DealRunner(self.stages)
        self.results["passing_run"] = runner.run(passing_deal)
        self.results["broken_run"] = runner.run(broken_deal)
        # Run coverage check
        checker = CoverageChecker(self.stages)
        self.results["coverage"] = checker.run()
        # Generate topology diagram
        self.results["topology"] = generate_mermaid()
        # Store deal metadata for display
        self.results["passing_deal_name"] = passing_deal["name"]
        self.results["broken_deal_name"] = broken_deal["name"]
        self.results["broken_deal_description"] = broken_deal["description"]
        return self.results

What does this section do?

The orchestrator runs both deals (passing and broken) through the DealRunner, runs the coverage check, generates the topology diagram, and stores deal names for display. The complete results dictionary is what app.py will consume.

  • Save orchestrator.py.
  • Verify the orchestrator runs the full pipeline by running this command:
python -c "from orchestrator import Orchestrator; o = Orchestrator(); r = o.run(); print(f'{len(r[chr(34)+chr(34)+'passing_run'+chr(34)+chr(34)])} gates in passing run')"

You should see 9 gates in passing run printed. All nine stages evaluated successfully.

Seeing an ImportError?

Make sure topology.py and orchestrator.py are saved in the same folder as agents.py and stages.py. All files must be at the same level inside gauntlet-tracker.

Help me fix my orchestrator import error

Rewire the app to use the orchestrator

The current app.py wires each agent manually. Now that the Orchestrator handles the full pipeline, you will replace the file content to use the orchestrator and add two new tabs: Teach-Back and Topology.

  • Select all the content in app.py and delete it.
  • Paste the following imports and configuration at the top of the now-empty file:
"""Stage-Gate Development Lifecycle Tracker. Powered by rule-based agents."""

import pandas as pd
import streamlit as st
from orchestrator import Orchestrator
from agents import TeachBackScorer
from stages import STAGES

# Page config and header
st.set_page_config(page_title="The Gauntlet: Stage-Gate Tracker", layout="wide")
st.title("The Gauntlet: Stage-Gate Development Lifecycle Tracker")
st.caption("Kill-trigger thresholds are the project's own teaching rubric, not universal bars. Sample deals are hypothetical. Nothing here is investment, legal, or tax advice.")

# Cache the pipeline so it only runs once per session
@st.cache_data
def run_pipeline():
    orchestrator = Orchestrator()
    return orchestrator.run()

results = run_pipeline()

# Six tabs: existing four plus Teach-Back and Topology
tab_tracker, tab_passing, tab_broken, tab_coverage, tab_teachback, tab_topology = st.tabs(["Tracker", "Run Passing Deal", "Run Broken Deal", "Coverage Check", "Teach-Back", "Topology"])

What changed in the imports?

Instead of importing each agent individually, app.py now imports Orchestrator (which runs the full pipeline) and TeachBackScorer (which needs to run on-demand when the form submits). The @st.cache_data decorator ensures the pipeline only runs once per session, not on every interaction.

  • Continue pasting the Tracker and Passing Deal tabs below what you just added:
with tab_tracker:
    st.header("Nine-Stage Tracker")
    st.write("Each row is a gate. Each gate can kill a deal.")
    df = pd.DataFrame(results["tracker"])
    df.columns = ["Stage", "Decision", "Criteria", "Risk", "Kill Trigger"]
    st.dataframe(df, hide_index=True, column_config={"Stage": st.column_config.TextColumn("Stage", width="small"), "Decision": st.column_config.TextColumn("Decision", width="medium"), "Criteria": st.column_config.TextColumn("Criteria", width="large"), "Risk": st.column_config.TextColumn("Risk", width="medium"), "Kill Trigger": st.column_config.TextColumn("Kill Trigger", width="large")})

with tab_passing:
    st.header(f"Passing Deal: {results['passing_deal_name']}")
    st.write("This deal advances through every gate to disposition.")
    for entry in results["passing_run"]:
        if entry["decision"] == "GO":
            with st.expander(f"Gate: {entry['stage']} \u2014 GO", expanded=False): st.success(entry["reason"])
        else:
            with st.expander(f"Gate: {entry['stage']} \u2014 KILL", expanded=True): st.error(entry["reason"])
    if all(e["decision"] == "GO" for e in results["passing_run"]):
        st.balloons()
        st.success("Deal reached Disposition. All nine gates passed.")
  • Continue pasting the Broken Deal and Coverage Check tabs:
with tab_broken:
    st.header(f"Broken Deal: {results['broken_deal_name']}")
    st.write("This deal is engineered to fail at one named gate.")
    for entry in results["broken_run"]:
        if entry["decision"] == "GO":
            with st.expander(f"Gate: {entry['stage']} \u2014 GO", expanded=False): st.success(entry["reason"])
        else:
            with st.expander(f"Gate: {entry['stage']} \u2014 KILL", expanded=True):
                st.error("DEAL KILLED AT THIS GATE")
                st.write(entry["reason"])
    killed_at = next((e["stage"] for e in results["broken_run"] if e["decision"] == "KILL"), None)
    if killed_at: st.error(f"Tracker stopped the deal at the {killed_at} gate. No further gates evaluated.")

with tab_coverage:
    st.header("Coverage Check Report")
    coverage = results["coverage"]
    col1, col2, col3 = st.columns(3)
    with col1:
        if coverage["all_stages_present"]: st.success("All 9 stages present")
        else: st.error("Missing stages")
    with col2:
        if coverage["all_fields_filled"]: st.success("All fields filled")
        else: st.error("Empty fields detected")
    with col3:
        if coverage["order_correct"]: st.success("Order matches framework")
        else: st.error("Order mismatch")
    if coverage["overall"] == "PASS": st.success("COVERAGE CHECK: PASS")
    else:
        st.error("COVERAGE CHECK: FAIL")
        for gap in coverage["gaps"]: st.write(f"- {gap}")
  • Now paste the two new tabs (Teach-Back and Topology) at the end of the file:
with tab_teachback:
    st.header("Teach-Back Instrument")
    st.write("Walk a deal through the gates. For each stage, explain: what decision you would make (GO or KILL) and what would trigger a kill.")
    with st.form("teachback_form"):
        responses = {}
        for stage in STAGES:
            responses[stage["stage"]] = st.text_area(f"{stage['stage']} \u2014 Your reasoning:", height=80, key=f"tb_{stage['stage']}")
        submitted = st.form_submit_button("Score My Teach-Back")
    if submitted:
        scorer = TeachBackScorer(STAGES)
        result = scorer.run(responses)
        st.subheader(f"Overall: {result['overall']} ({result['passed']}/{result['total']} gates passed)")
        for score in result["scores"]:
            if score["result"] == "PASS": st.success(f"{score['stage']}: PASS \u2014 {score['feedback']}")
            elif score["result"] == "PARTIAL": st.warning(f"{score['stage']}: PARTIAL \u2014 {score['feedback']}")
            else: st.error(f"{score['stage']}: FAIL \u2014 {score['feedback']}")

with tab_topology:
    st.header("Agent Topology (Mermaid)")
    st.write("Copy the code below and paste it into [mermaid.live](https://mermaid.live) to render the diagram.")
    st.code(results["topology"], language="text")
    st.write("The orchestrator coordinates a roster of rule-based agents:")
    st.write("- StageExtractor: reads the framework, returns the ordered spine")
    st.write("- GateEncoder: fills the four fields per stage")
    st.write("- DealRunner: evaluates a deal against each gate in sequence")
    st.write("- CoverageChecker: verifies all stages present, all fields filled")
    st.write("- TeachBackScorer: scores the learner's gate-by-gate reasoning")

What do the new tabs do?

The Teach-Back tab uses st.form to collect reasoning for all nine gates before scoring. This prevents the app from re-running on every keystroke. The scorer only fires when the learner clicks the submit button.

The Topology tab displays the Mermaid code as a copyable block. The learner can paste it into mermaid.live to render the full agent flowchart.

  • Save app.py.

Before you run the app, do you expect all six tabs to load successfully? Or do you think something might fail?

  • Run the app by executing this command:
streamlit run app.py

Your browser opens with all six tabs: Tracker, Run Passing Deal, Run Broken Deal, Coverage Check, Teach-Back, and Topology. The first four tabs work exactly as before. Click the Teach-Back tab to see the form with nine text areas.

  • Click the **Teach-Back** tab.
  • Type a test response in the Concept field (e.g., "GO because there is demand").
  • Click **Score My Teach-Back**.

You should see the Concept gate scored (likely PARTIAL since you mentioned a decision but not the specific kill trigger) and the remaining eight gates scored as FAIL (no response provided).

  • Click the **Topology** tab to confirm the Mermaid code is displayed and copyable.
  • Stop the app with Ctrl+C in the terminal.

App crashes on load?

Check that orchestrator.py imports TeachBackScorer from agents. If it is missing, the Orchestrator will fail on import.

Also confirm you deleted all old content from app.py before pasting. Leftover import lines from the previous version will conflict with the new orchestrator-based approach.

Help me debug my Streamlit app crash

Write the plain-language guide

Without documentation, the tracker is a personal tool that only you understand. The guide turns it into a reusable artifact that anyone can pick up, rebuild, and run on their own deals.

  • Create a new file called docs/GUIDE.md (inside the existing docs folder) and paste the following content:
# The Gauntlet: Plain-Language Guide

## What This Tracker Is

A stage-gate tracker that encodes the real estate development lifecycle as a series of decision points. Nine stages, concept through disposition. Each stage is a gate. Each gate carries four fields: the decision made at that gate, the go/no-go criteria, the risk the gate screens, and the kill trigger.

## Why It Exists

The development lifecycle is not a straight line of tasks. It is a series of gates, each of which can kill a deal. This tracker makes the stop rules explicit, testable, and reusable.

## How to Rebuild It

The tracker is powered by rule-based Python agents: StageExtractor, GateEncoder, DealRunner, CoverageChecker, TeachBackScorer, Orchestrator. No LLM. No API. No metered cost.

## How to Run It on a New Deal

1. Open `deals.py`
2. Add a new deal dictionary following the same structure as `passing_deal`
3. For each stage, set `kill_triggered` to True or False and provide a `reason`
4. Import your deal in `orchestrator.py` or run it directly with `DealRunner`
5. Run `streamlit run app.py`

## Where the Deeper Material Lives

- Urban Land Institute, *Real Estate Development: Principles and Process* (Miles et al.)
- Robert Cooper, Stage-Gate (PDMA, 2026 version)

## Caveats

- Sample deals are hypothetical. Every number is illustrative.
- Kill-trigger thresholds are a teaching rubric, not universal bars.
- Nothing here is investment, legal, or tax advice.

What does the guide cover?

Four sections: what the tracker is (the lifecycle is a series of gates, not a task list), how to rebuild it (the agent roster), how to run it on a new deal (add a dictionary, run the DealRunner), and where the deeper material lives (the ULI text and Stage-Gate framework).

The caveats are intentionally repeated from the app and decision records. This is a standalone document that someone might read without ever opening the code.

  • Save docs/GUIDE.md.
  • Confirm both docs exist by running:
ls docs/

You should see DECISIONS.md and GUIDE.md listed.

  • Commit your changes and push a new branch for this step:
git add -A
git commit -m "Add teach-back, topology, orchestrator, and guide"
git push origin main
  • In Linear, close the ticket for this step by marking it as done.

Git push rejected?

If you are working on a branch, create a pull request on GitHub and merge it to close the associated Linear ticket. If pushing to main directly, ensure your local repo is up to date with git pull --rebase before pushing.

Help me fix my git push issue

Before you move on, do you expect streamlit run app.py to show all six tabs working end-to-end? Run it one more time to confirm.

streamlit run app.py

All six tabs load. The Teach-Back tab presents a form and scores your reasoning. The Topology tab shows the Mermaid diagram. The guide is committed to the repo.

✔️ Awesome, I've got everything!

Great. Confirm you have saved all files: agents.py, topology.py, orchestrator.py, app.py, and docs/GUIDE.md.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is present in order. Your files should contain:

  • agents.py: StageExtractor, GateEncoder, DealRunner, CoverageChecker (from prior steps) plus TeachBackScorer at the bottom.
  • topology.py: single generate_mermaid() function returning the styled Mermaid string.
  • orchestrator.py: imports all agents, both deals, and topology. Orchestrator class with run() method returning the full results dictionary.
  • app.py: four code sections (imports/config, tracker+passing tabs, broken+coverage tabs, teachback+topology tabs).
  • docs/GUIDE.md: six sections (What, Why, Rebuild, Run, Deeper Material, Caveats).

The tracker now explains itself. The teach-back proves you understand it. The topology documents the architecture visibly. The guide makes it reproducible. Your next move is the Secret Mission: presenting this system at two levels of abstraction and articulating what you learned about where deals die.

Secret mission

Present the Two-Audience Walkthrough with Lessons Learned

The tracker and guide prove you can build and document. Can you teach the same system at two levels of abstraction and articulate what you learned about where deals die? This is the readout artifact.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with no cloud resources and no metered APIs. There are no ongoing costs beyond your existing Cursor subscription.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A private GitHub repository (gauntlet-tracker) with all code and documentation.
  • A Linear project with completed tickets.
  • A local Python virtual environment (.venv folder) with Streamlit installed.
  • The local project folder (gauntlet-tracker) on your Desktop.

Keep everything running

No action needed. Choose this if you plan to keep using the tracker on new deals or want to extend it further.

  • Your local environment and repo stay exactly as they are.
  • Run streamlit run app.py anytime to use the tracker on new deals.
  • Add new deals to deals.py and sharpen kill triggers as you learn more.
  • Your GitHub repo and Linear project remain accessible for reference.

Pause - I'll come back to this later

Deactivate your virtual environment to free up your terminal. Everything stays on disk for when you return.

  • In your terminal, deactivate the virtual environment by running:
  • deactivate (if the virtual environment is currently active).
  • Close Cursor. Your files remain in the gauntlet-tracker folder on your Desktop.
  • When you come back, open the folder in Cursor and reactivate with .venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove all project resources from your machine and online services. This is permanent.

  • Delete the local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf gauntlet-tracker
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force gauntlet-tracker
  • Delete the GitHub repository. In your browser, go to your gauntlet-tracker repo on GitHub.
  • Click Settings in the repo navigation bar.
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.
  • Archive or delete your Linear project. Open the project in Linear, go to project settings, and select the archive or delete option.

Well Done!

Well Done!

Nice work! You just built a stage-gate tracker that turns the real estate development lifecycle from a sequence you memorize into a tool that decides.

You've learned how to:

  • Encode a stage-gate framework as nine gated rows with four decision fields each, turning lifecycle knowledge into a system that advances or kills a deal at named gates.
  • Prove a tracker works through negative testing: running a broken deal engineered to fail at the Financing gate and confirming the short-circuit fires at the right gate, not before or after.
  • Ship a complete system through GitHub with PR-closed Linear tickets, a tagged release, design-before-build decision records, a coverage-check report, and a plain-language guide anyone can follow.
  • Secret Mission: Presented a two-audience walkthrough (technical and non-technical) with three lessons learned about where deals die and why your thresholds might need adjusting.

Ready to quiz yourself?