All case names, parties, doctors, carriers, employers, case file IDs, and dates in this post are fictitious demonstration data shown to illustrate skill behavior. No real client information is shown.

The user asked a four-word question: "can we improve it so it works better."

The morning brief skill is the most-used skill in the system — attorneys run it first thing every day. It fetches events, tasks, mail, and case data; classifies a few subsets; presents them in sections. It had been working for months. But "working" and "useful" are different things, and the user's question was about the second.

What I did first: ran it against live data and read every line of output

Six problems became visible only after running the actual block end-to-end and inspecting what it produced. Code review of the skill source had missed all of them.

1. The prep-task check was meaningless

For each upcoming hearing/QME, the brief looked for "prep tasks" by counting any task on the case with date_due before the event. The intent was to flag cases without preparation. The implementation marked every case as 'prepared.'

Why: tasks have due dates whether they're related to the event or not. A case with 30 open routine tasks (file lien, send NOR, follow up on records) all due before next Wednesday counts as 'prepared' for Wednesday's hearing — even though none of those tasks are hearing prep.

The fix is explicit keyword matching on attorney-typed task descriptions. Tokens: 'prep client,' 'call client,' 'case summary,' 'witness prep,' 'hearing prep,' 'MSC prep,' 'mediation prep,' 'pretrial,' 'prep for QME/AME/depo/trial.' These are the words attorneys actually type when creating prep tasks. The token list is tight enough to NOT match 'review medical records' (which earlier loose versions matched on 'review') or 'send brief to opposing' (which matched on 'brief').

This is administrative content the attorney typed — explicit token checking is the right tool. No regex on document content.

2. Two important event types were missing

dangerTypes was [HEARING, QME, DEPO]. But the firm also has AME APPT (event type 15405) and DR. DEPOSITION (event type 28328). Both require prep. The old brief skipped them.

Live data on the day of the fix: 7 AME appointments and 2 Dr. depositions this week — events the old brief would have ignored entirely. After the fix, dangerTypes = [HEARING, QME, AME, DEPO, DR-DEPO]. Deadlines section matches.

3. Overdue tasks weren't ranked

The brief showed the top 5 overdue tasks. The slice came from whatever order the API returned — usually the oldest first. The top 5 displayed were petitions for 5710 fees from 2019 and 2020 on cases long since stipulated. Not actionable. Not the real work.

The fix sorts overdue tasks by priority (HIGH=1 wins), then by days-overdue. Now the top 5 is HIGH-priority tasks that are actually current. Also: the priority field is integer-coded in Merus (1/2/3). The brief now decodes to HIGH/MED/NORM labels so the reader doesn't have to translate.

4. No client-contact signal

"How long since you talked to the client?" is the single most useful danger signal for a hearing or deposition. If your client is going on the record in 3 days and you haven't spoken in 6 months, that's a very different situation from one where you spoke yesterday.

The old brief didn't fetch activities at all, so this was uncomputable. The new brief adds a phase 3 to the bash block: for up to 8 unique danger-case ids, fetch /activities/index/{cid} in parallel. Compute lastContactDate by scanning the activities for tags 111 (telephone call), 32263 (email from client), 32265 (email to client). Inline daysSinceContact into the danger output.

This is the change that produces the most dramatic improvement in the brief's usefulness. The top of the new output, illustrated with a demonstration case: "Doe v. Acme Logistics — DR-DEPO in 3 days. No prep task. 287 days since last client contact." That's a sentence an attorney reads once and immediately knows what to do.

5. The LLM was doing the LLM's least-favorite job: arithmetic

The pre-fix brief printed raw data — "prepTasks=true openTasks=14 contact=75d" — and the prompt instructed the LLM to rank by danger and present situations. The LLM had to compute danger every time. Sometimes well, sometimes inconsistently.

The fix moves the scoring into the bash block. Each danger case gets a numeric score (0=fine, 100=urgent):

The output is sorted by score descending. The LLM's job becomes presentation: take the top-scored cases and write a one-sentence situation summary per case. The danger calculation isn't re-done every time — it's deterministic from the data.

Bonus from this fix: deduplication by case_file_id. A demonstration case (Roe v. Pacific Freight) might have a DEPO and a QME on the same day. Pre-fix, the brief listed two danger entries for the same case. Post-fix, one entry that says "DEPO+QME" because it's one situation, not two.

6. Date comparisons were string-vs-number

Merus returns date fields as quoted strings on some endpoints, numbers on others. The old brief compared e['3']>=startOfToday where the left side might be "1775113200" and the right side is 1775000000. JavaScript coerces silently and the comparison works most of the time. But the semantics depend on what the API returns, and if Merus shifts the response shape, the brief would silently start producing wrong results.

The fix: num = v => Number(v) || 0 helper used at every date comparison. Explicit numeric coercion. Robust against future API shape shifts.

A bonus discovery during testing

When the contact-age lookup wasn't returning data, debugging exposed a schema fact: Merus activity records use integer-keyed fields. The tag array is at a['0'], the date is at a['4']. Not a.tags and a.date — those fields don't exist.

This is the same schema gotcha that bit us in v4.5.120 (the broken staff lookup). Different endpoint, same root cause: Merus returns CakePHP-style records keyed by integer indices when you query the index endpoint. Code that assumes named fields silently gets undefined.

Going forward: when introducing new code that reads activity records, the right move is to fetch one record live, log its keys, and code against those — not against assumptions from documentation or model intuition.

What the new brief looks like

Top of the output (demonstration data shaped like a real firm-caseload run):

DANGER [CASE-ID-1] Doe, Jane v. Acme Logistics
  DR-DEPO 5/28/2026 in 3d
  score=85 prep=false contact=287d openTasks=1
  reasons: no prep task | 287d no contact

DANGER [CASE-ID-2] Doe, Patricia v. Coastal Health Systems
  DEPO 6/2/2026 in 7d
  score=50 prep=false contact=? openTasks=3
  reasons: no prep task | contact unknown

DANGER [CASE-ID-3] Roe, Sam v. Pacific Freight Lines
  DEPO+QME 5/26/2026 in 0d
  score=45 prep=true contact=75d openTasks=3
  reasons: 75d no contact

Three sentences, three decisions. The LLM doesn't have to recompute anything — it just presents what the bash block already classified.

Wall time end-to-end: 3 seconds. Including the 4 firm-wide indexes (parallel) and up to 8 activity fetches (also parallel). Under 12 API calls total. The brief used to take ~30 seconds of model time to write because the model was doing arithmetic. Now it takes ~10 because the model is just describing.

The meta-lesson

Every prior round of fixes in this codebase has tightened the same general rule. v4.5.121 adds another tightening:

v4.5.121 ships the six fixes. The brief now produces a triage of situations ranked by score, not a list of facts requiring interpretation. The most-used skill in the system just got materially better.