The Monday Morning Agent

How query-plays.mjs works

This is the one program the agent is allowed to run. It reads one file, throws out the plays the filters exclude, counts what is left, and prints both the count and every filter it used. It is short enough to read in one sitting, and it was written before the agent existed.

What it is written in

JavaScriptOne file, 224 lines. The .mjs ending means it is a JavaScript module: a file that brings in other code with import.
Run by Node.jsNode.js is the program that runs JavaScript outside a web browser. You start the tool by typing node ./query-plays.mjs and then the filters. It was built and tested on Node 24, and the daily refresh on GitHub runs it on Node 22.
Nothing to installIt imports three things, and all three come with Node itself: node:fs to read a file, and node:path and node:url to find the data folder next to the script. There is no package.json and no npm install.
Not written by the agentThe agent does not write or change this code. It only chooses the filters. That is why checking the agent means checking a list of filters, not reading code it made up.

What happens when it runs

Six stages, top to bottom, the same every time. The left column is what the code does. The right column is what that looked like for one real command, node ./query-plays.mjs --seasons 2021-2025 --half 1, on October 11, 2026.

1

Read the command

It reads the filters typed after its name and checks each one. A filter it does not know, or a value that makes no sense, stops it right there with a message and exit code 64. It never guesses what you meant.

Lines 41 to 87

In the example

--seasons 2021-2025   five seasons
--half 1              first half only
everything else       left at its default
If every filter is valid, it goes on.
2

Load the plays

It reads data/plays-slim.csv into memory, one row per play, and splits each row at the commas. This is the only file it opens, and it only reads it.

Lines 89 to 96

In the example

258,611 plays
18 columns each, of the 372 in the raw data
no betting column among them
Every play is now in memory. Nothing has been removed yet.
3

Apply the filters, in order, and write each one down

Nine filters, always in the same order. After each one it records the filter's name and how many plays are left, even when the filter removed nothing. That record is the list it prints at the end.

This is the stage the whole design rests on: a filter cannot be applied without also being written down, because one small function does both.

Lines 98 to 143

In the example, plays left after each

  1. 0. The file258,611
  2. 1. Seasons 2021 to 2025247,284
  3. 2. Regular season and playoffs247,284
  4. 3. All weeks247,284
  5. 4. Only rushes and passes179,045
  6. 5. No kneel-downs, no spikes176,491
  7. 6. First half only87,209
  8. 7. Any score87,209
  9. 8. Any score at halftime87,209
Only the plays that passed every filter go on. Pink is what a filter took out.
4

Add the plays up, one team in one game at a time

For each team in each game it counts rushes and passes. Rush share is rushes divided by rushes plus passes. It notes whether that team won, using the final score. Tied games are dropped, and so is any team-game with fewer than 10 plays left.

Lines 145 to 165

In the example

1,424 games
4 tied games dropped
0 team-games dropped for too few plays
2,840 team-games kept
One row per team per game: its rush share, and whether it won.
5

Sort, cut into equal groups, and count the wins

It sorts the team-games from the highest rush share to the lowest and cuts them into four groups of the same size. For each group it counts the wins and divides. The gap is the top group's win rate minus the bottom group's. If a group has fewer than 30 team-games, it adds a small-sample warning.

Lines 167 to 187

In the example

4 groups of 710
ran most    376 wins of 710    53.0%
ran least   327 wins of 710    46.1%
gap                            6.9 points
The numbers are done. All that is left is to print them.
6

Print the filters first, then the result

It prints the question, the filter list from stage 3, the team-game counts from stage 4, the table from stage 5, and one standing reminder: a gap is an association, not a cause. With --json it prints the same content as data, which is how the explorer page gets its answers.

Lines 189 to 224

In the example

QUESTION
FILTERS, IN ORDER (plays left after each)
THEN, PER TEAM PER GAME
RESULT
NOT IN THIS OUTPUT

The agent reads that printout and nothing else. It never sees a play.

What it never does

These are properties of the code, not promises in a prompt. You can confirm each one by reading the file.

It never writesNo file is created, changed, or deleted. It reads one file and prints.
It never goes onlineNo network call of any kind. It works with Wi-Fi off.
It never sees a betting lineThe file it reads has 18 columns, and none of them is one.
It never guessesGive it the same filters and the same file, and it prints the same numbers, every time.

The runner: run-analyst.sh, in six beats

The tool above is what the agent calls. The runner is what you call: a shell script of about 120 lines that hands the agent one claim and makes its work visible. Top to bottom, it does six things.

1. Takes the claimFrom the command line, or from claim.txt when you give none. A claim with a quotation mark inside it goes in the file.
2. Hands over the policyIt reads analyst.md and passes it to claude -p as the system prompt. The policy is the only thing written for this show; the model is unchanged.
3. Sets the allowlistOne line: Bash(node ./query-plays.mjs:*). This exact script, any arguments, and nothing else: no file reads, no writes, no network. The policy says it; this line enforces it.
4. Streams the runIt asks for the event stream instead of one answer at the end, and pipes it through show-run.mjs, which prints each tool call as it lands, with the filters and the result, and saves the ruling under rulings/.
5. Opens the run as a pageWhen the run ends, render-run.mjs writes the whole run as one page, the ruling first and every call down a clock, and the runner opens it. A terminal is a hard place to read five tool calls.
6. Holds the word limitThe policy says under 350 words, and on its own the model runs over about half the time. The runner counts the words above the commands. Over the limit, the ruling goes back to the model once, with "shorten it" and no tools, and the page shows both rulings. Still over, the run fails with an OVER LIMIT line. Beat 3 is the same idea: the policy asks, the runner enforces.

Nothing in the runner computes a number. It moves the claim, the policy, and the tool's printout between the pieces, and writes down what happened.

See it for yourself

# the example on this page
node ./query-plays.mjs --seasons 2021-2025 --half 1

# every filter it accepts
node ./query-plays.mjs --help

# a filter it does not know: it stops and says so
node ./query-plays.mjs --seasons 2026 --spread 3
Read the codeAll 224 lines, with the line numbers this page refers to. The runner is run-analyst.sh, beside it.
Change a filterThe same output for 378 combinations of filters, with nothing to install.
How the agent is set upWhere this tool sits: under a written policy, behind a one-line allowlist.