SLOPSHOPPER

rd-ledger

Contemporaneous R&D time and AI-usage ledger: engagement hours by business component, list-price usage estimates, a weekly review with buttons, a CPA export…

newpanespinnerguardcommandtoast
v0.2.0MITupdated 2026-10-12Joseph-Ayoub/claude-mods/rd-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rd-ledger
│ ┃ R&D ledger ✕ › fix the failing auth test and add an audit log call │ ┃ [ R&D ] [ Review ] [ Spend ] [ Session ] │ ┃ [ Refresh ] [ Export ] [ Close ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ R&D ledger · week of Oct 6 – Oct 12 · UTC · ⏺ Update(src/auth.ts) │ ┃ union-v2/36910bab ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Hours in range ⎿ 3 pass, 1 fail │ ┃ Net hours 0.00 │ ┃ engaged time, gaps removed; the figure … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ built from │ ┃ Gross span 0.00 ✻ Worked for 42s · done 4:20 PM │ ┃ first to last prompt each day, gaps inc… │ ┃ (context, not a claim basis) › /rd │ ┃ Today 0.00 ⎿ rd-ledger: R&D ledger pane opened. │ ┃ no prior period │ ┃ │ ┃ Weekly trend │ ┃ ISO week Net hours │ ┃ 2025-W41 0.00 │ ┃ 2025-W40 0.00 │ ┃ 2025-W39 0.00 │ ┃ 2025-W38 0.00 │ ┃ 2025-W37 0.00 │ ┃ 2025-W36 0.00 │ ┃ Total 0.00 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ $0.42 ⧗0.0h

Draws

Pane · R&D ledger
[ R&D ] [ Review ] [ Spend ] [ Session ] [ Sessions ] [ Refresh ] [ Export ] [ Close ] R&D ledger · week of Oct 6 – Oct 12 · UTC · union-v2/36910bab Hours in range Net hours 0.00 engaged time, gaps removed; the figure a claim is built from Gross span 0.00 first to last prompt each day, gaps included (context, not a claim basis) Today 0.00 no prior period Weekly trend ISO week Net hours 2025-W41 0.00 2025-W40 0.00 2025-W39 0.00 2025-W38 0.00 2025-W37 0.00 2025-W36 0.00 Total 0.00 Hours by entity Entity Net hours non-R&D 0.00 unmapped 0.00 ignored 0.00 Total 0.00 Hours by component Component Net hours non-R&D 0.00 unmapped 0.00 ignored 0.00 Total 0.00 Activity mix Activity type Net hours Total 0.00 Process-of-experimentation share: 0.00 of 0.00 qualified-services hours (n/a) Review status 0.00 hours in the review queue · 0 unmapped repo(s), 0 session(s) Unconfirmed weeks Every week with recorded time is confirmed. Longest days (no flags) no time recorded this week Confirm weeks in Review (v) — hours are proposals until confirmed. Spend this month ~$0.00 est. list price · Spend (s) Pipeline last refresh at 08:53 on 2025-10-09 never exported bridge standalone (rd-tracker not found) 0 sessions · 0 segments in scope · 0 unreadable denied events: none 0 redactions · 0 dropped rows prices as of 2026-10-06 zone UTC Deadlines first mandatory tax year: TY2026 Form 6765 Section G becomes mandatory — no calendar date — see due 2026-07-06 §70302(f)(1) retroactive §174A election window closes — 270 day(s) remaining 2026-07-06 §280C(c)(2) late election / revocation window closes — 270 day(s) remaining original timely filed return, including extensions §280C reduced-credit election (Form 6765 Item A) — no calendar date — see due original timely filed return, including extensions §41(h) qualified small business payroll-tax election (Section D) — no calendar date — see due whenever an amended return is filed Section G small-filer exemption is lost on an amended return — no calendar date — see due 2025-10-13 Next weekly review (Monday) — 4 day(s) remaining Section G exemption · default — unknown Calendar tax year; dates are not moved for weekends or holidays; verify with your CPA.
Pane · Usage
This session Cost $0.42 Session 30m 0s Active 42s API 0s Turns 1 Requests 0 Context ████████████████ Window 97k of 200k (49%) Rate limits 5-hour 31% Models No requests counted yet. What's contributing Nothing counted yet. Code changes +0 −0 lines · 0 edits Per-model, per-agent and code figures count from when rd-ledger loaded in this session; the cost is the engine's own, from the session's start. [ Refresh ] [ R&D ledger ] [ Close ]
Prompt hint
⟨Claude Code's own drawing⟩ $0.42 ⧗0.0h
README

rd-ledger

A contemporaneous ledger of R&D engagement time and AI usage, kept inside every Claude Code session it runs in. It records when you prompted (never what you typed), which repository you were in, and what each model turn cost at list price; shows this week's hours by business component and this month's usage estimate in a pane; lets you map repositories to components and confirm each week with buttons; and exports a CPA-ready bundle. With Joseph's private rd-tracker checkout configured it can compare against, and hand confirmed weeks into, that tool.

Nothing in this mod is tax advice. What is claimed on a return is your CPA's decision. The ledger is a record of time and usage with the methodology written down; it computes no credit.

What you see

  • Footer chip (desktop prompt footer; the terminal's hint line), the one chip this mod draws: $12.34 ⧗2.4h, this session's cost (the engine's own figure: cents under $100, whole dollars from there) and then an hourglass mark with the R&D hours recorded today (the R&D view's "Today" figure; ⧗-- until the first ledger read). The footerChip option shows both (default), the cost alone or the hours alone; chipContext adds · ctx 12%. The label is never longer than 16 characters, so the strip never cuts it to …. Press it: the Usage pane opens, with R&D ledger as the tab beside it. With the footerButton option off the figure is plain text and /rd usage opens the panes.
  • The Usage pane (/rd usage, or /spend, usage-gauge's old command): this session's Cost (the engine's figure; no ledger here on a host that keeps none), Session length, Active time (main-loop turns), API time (every model request), Turns, Requests and Agent turns; Context (a text bar, the window as 147k of 1.0M (15%), tokens until auto-compaction, and up to eight categories after a Refresh); Rate limits (each window's percent and its local reset time, or none reported); Models (requests, input, output, cache read and write, cache hit, list-price estimate and share per model, with a Total row; the narrower the pane, the fewer columns); What's contributing (main loop 40% · subagents 60%, then the top five subagent types); Code changes (+4,321 −321 lines · 42 edits from your Edit, Write and NotebookEdit calls); the price footnote; then Refresh r, R&D ledger d (opens the ledger tab) and Close c. The estimates use this mod's price table with the cache-write price of the cache TTL in force when each request ran (1.25× input for 5 minutes, 2× for 1 hour). Per-model, per-agent and code figures count from when the mod loaded in this session; a reload keeps them.
  • The R&D ledger pane: five views sharing one chrome of two button rows: the views (R&D o, Review v, Spend s, Session i, Sessions a), then the actions (Refresh r, Export e, Clear stale lock l when one is held, Close c). Every view is drawn to the pane's width: wide (84 columns or more), medium (60 to 83) and narrow (under 60). Tables drop their least important columns as the pane narrows and long text wraps onto the next line, so nothing runs off the edge. The view you were on, and the session you picked, are still there after a reload.
  • Session (/rd session, or /rd session <first 8 characters of a session id>): engaged hours, prompts, main and agent turns, the four token counts and the cache-hit ratio, the engine's cost beside the list-price estimate, a by-model table, an agents table (the main loop and each subagent with its turns, model, tokens, estimate and share), and recommendations for spending less where the numbers support one: a longer or shorter cache TTL, a cheaper model for a short-answer subagent, a large context worth compacting, turns that ended in errors, or an hour that cost twice your usual. Every figure in a recommendation is an estimate.
  • Sessions (/rd sessions): every session this and last month, newest first, with its date, id, repository, engaged hours, turns, subagents, cost and recommendation count, and your median cost per engaged hour. It lists 40 at first; More (m) adds 40 at a time up to the newest 300, and remembers how many. Pick one with Open session… (or /rd session <id8>), and Back returns to the list. Recommendations about the cache TTL say what they assume and stay quiet when the TTL changed during the session.
  • R&D (/rd): laid out like rd-tracker's dashboard. Hours in range (this week's net hours, the figure a claim is built from, and the gross span, first to last prompt each day, for context; today; the change against last week), Weekly trend (each week this and last month; the oldest marked (partial) when it starts before the first of last month), Hours by entity and Hours by component (the unmapped, ignored and non-R&D buckets always shown, so the totals add up), Activity mix (research, support, non-qualified, unclassified, and the process-of-experimentation share), Review status (the review queue, every week this and last month with time that is not yet confirmed, days a flag says to look at, or the longest days), Spend this month, Pipeline (last refresh and export, the rd-tracker bridge, sessions read, prompts not counted, redactions, the zone, the price-table date, warnings) and Deadlines (rd-tracker's six R&D-credit deadlines, your entities' filing dates and the next weekly review, the Section G exemption per entity; calendar tax year, verify with your CPA).
  • Review (/rd review): unmapped repositories with a picker (a component, Ignore, or Non-R&D for an entity); one two-line block per component and day (the component and entity, then the date, hours, status, activity and marks) with Research / Support / Non-R&D / POE buttons; a note field per component and week (what was done, what was uncertain, what was tried; required before a process-of-experimentation flag); other-hours fields per entity; a Compare button when rd-tracker is configured; Confirm week and Reopen.
  • Spend (/rd spend): this month and last by model and by component, the token counts, the estimate, the engine's own /cost figure beside it, and the cache-write assumption in force. A narrower pane drops the cache-write and input columns first, then cache read.
  • /rd export [YYYY | YYYY-MM] writes a bundle under ~/.claude/rd-ledger/export/<stamp>/ and tells you the path.
  • /rd import (rd-tracker configured) reads the entity and component ids from rd-tracker's registries into your mapping, never overwriting your own entries.
  • Where no pane can be drawn (claude -p), /rd answers a short text table of counts and hours, and /rd usage and /spend one line (Usage: $12.34 · ctx 42% · 2 requests).

Install and load

Replacing usage-gauge: this mod now carries usage-gauge's Usage pane and /spend. Disable usage-gauge (/plugin disable usage-gauge) when you enable this one, or the footer shows two cost chips and both register /spend.

While developing, load it from this folder:

claude --plugin-dir /absolute/path/to/mods/rd-ledger

or add that absolute path to CLAUDE_CODE_PLUGIN_DIRS (in the shell, or in the env block of ~/.claude/settings.json) so sessions the desktop app starts load it too. Every session that should be counted needs to load it; a session without the mod records nothing (rd-tracker's nightly transcript parse, where present, still covers it).

Marketplace install: see the distribution decision in docs/ADR-006-distribution.md; nothing is published yet.

Options

Set in the config menu, or in settings under pluginConfigs → rd-ledger:

OptionDefaultWhat it does
footerButtontrueThe footer figure is a pressable chip that opens Usage with the R&D ledger beside it; off, it is plain text.
footerChipbothWhat the chip shows: both ($123 ⧗2.4h), cost ($123) or time (⧗2.4h). Where the host keeps no cost ledger the hours stand in.
chipContextfalseAdds the context fill ( · ctx 12%). Within the 16-character bound the hours give way to it first, then it gives way to the hours.
refreshMinutes5How often the week and month figures are recomputed (1 to 60).
idleGapMinutes30Two prompts further apart than this start a new engagement block (5 to 120). The canonical rd-tracker methodology is 30; changing it changes the method hash printed on every export.
cacheTtl5mThe prompt-cache write assumption when the engine does not report a TTL: 5m prices cache writes at 1.25× input, 1h at 2×.
rdtRepo(empty)Absolute path of an rd-tracker checkout. Empty runs standalone.
bridgeModecorroboratecorroborate: nothing is written into rd-tracker, Compare shows both. source-of-truth: confirming a week records its hours in rd-tracker through rdt add (read the costs below). off: no rd-tracker calls at all.
personLabel(empty)Printed in the export README's header only (Person: …); blank prints "not recorded — add on cover sheet".

Out-of-range values fall back to the default.

How it works

HookWhy
session.startregisters /rd and /spend, freezes the timezone on first run, opens or continues this process's segment file, arms the refresh timer, reads the usage figures
classic.SessionStart (startup, resume, clear, fork)opens a segment for the session id the engine names; /clear closes the old one
prompt.submitone engagement event per human submission, recorded after the prompt entered and only from origin.kind and whether a turn was running; the text is never read
turn.completeone usage row per turn (main loop and subagents): model and the four token counts; the Usage pane's turn count and active time
turn.stepthe Usage pane's per-request counts (model, four token counts, wall time); the model's stream passes through untouched
tool.call (Edit, Write, NotebookEdit)the lines each change added and removed, counted from its result; no path or text is kept
session.measurethe engine's cumulative cost figure, kept as a corroboration number; the context window and rate limits for the Usage pane
$.agent.list() (a call, not a hook)a subagent's type for What's contributing, looked up once per agent; only the id and type are read, never the task description
classic.PostModelSwitcha dated cache-TTL change point
session.endcloses the segment
command.run /rd …the commands; views open only for your own origins (typed, Remote Control, the desktop app); a peer session, a schedule or another plugin gets one line of counts
ui.render Pane (rd-ledger, rd-usage)the two panes, the ledger drawn from the last rollup, Usage from the usage figures in the session state
ui.render SessionMode / PromptHintthe one footer chip: the session cost and today's R&D hours

Engagement hours follow rd-tracker's union-v2 method exactly: human prompts from every session are merged into blocks while the gap is at most 30 minutes; a chain longer than 4 hours is split at its largest gap; a lone prompt counts 60 seconds; blocks are cut at local midnight with the seconds shared by prompt count; each block's seconds are shared equally over its prompts and land on the prompt's repository, then on the component your mapping names. Prompts that are not yours (task notifications, scheduled triggers, peer messages, plugins, claude -p and SDK runs) are recorded with the reason they were not counted and never form blocks. The method string union-v2/<hash> is rd-tracker's own and is printed on every export.

Usage estimates price each turn's tokens at the published list rates bundled in hooks/prices.ts (dated in the Spend view and the export). They are list-price estimates, not invoices: a subscription bills differently, cache writes are priced on an assumption, and the engine's own side calls (compaction, titles) are not in the per-turn counts. The raw counts are kept, so any later price table can be applied.

Files it writes, all under ~/.claude/rd-ledger/:

  • timezone: one IANA zone name, frozen the first time the mod runs (rd-tracker's store/timezone is adopted when the bridge is configured). Never rewritten; a different detected zone is only reported.
  • salt: 32 hex characters, written once; salts the repository-root hash.
  • components.json: your mapping (entities, components, repository rules, pre-tag rules). Edit by hand or through Review; the file carries a gen counter and unknown keys survive a save. mapping-history.ndjson records every save.
  • sessions/YYYY/MM/<sessionId>.<seg>.json: one segment per session per process open (and per local month, and again when a segment passes 3 MiB). It holds the session id, when it opened, how (startup, resume, clear, fork), the entrypoint word, the surface, the repository's basename and a salted hash of its root, the cwd's basename, the branch and its changes, the model, the cache-TTL change points, the events (time, origin kind, admitted or not, reason, queued) and the turns (times, model, four token counts, outcome), and counters. Never prompt text, answers, tool inputs, titles, full paths, your name, an email, or stderr. Every string from outside the mod is scanned for credential and email shapes first (a hit is stored as [redacted:<kind>] and counted), then cut to one line.
  • reviews/YYYY-Www.json: the weekly review, append-only: a revision is written on Confirm and on Reopen, never on a click. A confirmed revision freezes each row's seconds, entity, activity and POE flag, your notes and other hours, the method and parameters, the zone, and digests of the mapping and of the week's events, so a later remap or setting change shows as changed since confirmation rather than silently moving confirmed hours.
  • bridge-YYYY.ndjson: every hand-off to rd-tracker (an intent line before each call, the outcome after), the record that prevents a second send.
  • .lock/: a mkdir lock taken for shared-file saves and hand-offs. The code never takes a lock over (two takeovers could both succeed): a lock older than 15 minutes, left by a crashed session, shows in the R&D view's Pipeline section and the pane offers Clear stale lock (l), which asks first and removes it. Until then saves and hand-offs wait.
  • export/<stamp>/: the CPA bundle (below).

Writes go to a temp file beside the target and are renamed over it, so a crash never leaves a torn record; a file the mod cannot read is counted as unreadable and never rewritten. Nothing is deleted except temp files older than an hour. The store is the record; the export is its hardcopy. Back the store up off this Mac.

The export bundle (/rd export, Rev. Proc. 98-25 in spirit): README.md (the caveat, the person line, a data dictionary for every field and column, the method and admission rules verbatim, the mod version and the sha256 of the union and price code, the retention note, redaction counts, a PROVISIONAL stamp when any week in scope is unconfirmed), method.json (parameters, zone, method, price table and date, assumptions, scope and rounding rules), events.csv, blocks.csv, allocations.csv, hours_by_component.csv (frozen and live seconds, status, confirmation lag), activity_notes.csv, turns.csv, spend_by_month.csv (labelled estimates), monthly_log.txt (page-numbered), reviews.json, components.json (component entries only; ignored and non-R&D directory names stay local), bridge.ndjson, sessions.ndjson (the raw segments in scope, re-encoded), and MANIFEST.sha256. Rows that are not claimable (unmapped, ignored, non-R&D, non-qualified) show a bucket name instead of a repository or branch name. Every cell is scanned and redacted in place; the README counts the redactions; an export is never refused for a hit. CSV cells that start with = + - @ get a leading apostrophe so a spreadsheet reads text.

The rd-tracker bridge. With rdtRepo set to a checkout that has its .venv, every call is <repo>/.venv/bin/python -I -m rdt … by argv (no shell, every value one --flag=value token, ids validated, numbers formatted by the mod), run in the repo, with a minute's timeout; stdout is kept as one cleaned line and stderr never. Only rdt add (hand-off), rdt review --json (Compare) and the registry import run; never publish, ingest, spend, review --answers, git, or any file under store/. Compare runs rd-tracker's review derivation, which rewrites that tool's local proposals for the open week; the button says so and asks first. Under source-of-truth, Confirm asks once more, then records each (date, entity, component) of the frozen rows once, keyed by the bridge log (at most 12 per Confirm and 10 minutes of sending, each re-checked against the log as it stands right before its call); a changed amount for a destination already sent is recorded as needs-manual-adjust even when nothing new is sent; a changed amount is logged as needs-manual-adjust and never sent again (rd-tracker has no update command); a failed call is retried on the next Confirm; a call whose outcome is unknown blocks that destination until you clear it in the log. A POE flag is sent only with your typed justification. The costs of source-of-truth are in docs/ADR-003-bridge-semantics.md: rd-tracker's nightly parse still counts the same Claude Code time unless its ingest excludes that source, and the entries lose rd-tracker's event-level audit.

Tests

claude plugin validate mods/rd-ledger, claude plugin test mods/rd-ledger, and sh mods/rd-ledger/scripts/check.sh (the validate call-list and env-read allowlist, the source bans, then the tests). The tests cover:

  • the ground-truth fixtures from the tester seat (F1: 19 events over 3 sessions and 2 days with a midnight crossing, a queued prompt, a lone prompt, an over-4-hour chain split at its largest gap and two overlapping sessions; F2 the gap-tie rule; F3 and F4 the two 2026 daylight-saving days), asserted to the second;
  • admission for every origin kind, entrypoint and surface; the gap boundary in milliseconds; the method hash golden a5db146c; sha256 against the FIPS vectors; every price row; zone math including ISO week-year edges and a sweep over every 2026 day;
  • the mapping and review files: validation, null-prototype tables, unknown keys preserved, gen conflicts refused, confirm and reopen revisions, POE refused without a note, drift after a remap;
  • the export: every file and column, redaction in place, the bucket rule, the manifest, the data dictionary covering every field;
  • the bridge: exact argv, refusals, the hand-off matrix, the log round trip;
  • the session views: a two-segment session worked by hand (engaged seconds, tokens, both costs, model and agent shares), each recommendation rule with a case that fires and one that does not, /rd session, /rd sessions and /rd session <id8> with one, none and several matches, the picker and Back, and the view surviving a reload;
  • the register on the shared fake engine: open-or-continue, /clear, resume, month rollover, events written at once, flushes never torn, the sentinel prompt absent from every file, argv, toast, log, state value, drawn pane and export, the views and buttons on terminal, desktop and mobile, options round-trips, and every argv executable on the absolute allowlist;
  • the Usage pane and chip (usage.test.ts, usage-pane.test.ts): usage-gauge's figure formats, patch counting, model lines and shares, rate-limit text and the context line; the estimate at 1.25× and 2× cache writes and a TTL change point mid-session; the chip per footerChip and chipContext within 16 characters and without emoji; the press opening the ledger then Usage focused; /rd usage and /spend per origin and headless; the R&D ledger button; the model stream passed through unchanged; an edit counted with no path kept; a sentinel agent description never reaching state, files, the pane or an answer; the pane at 40, 60, 84 and 120 columns on every surface.

What to test

  1. Look at the prompt footer: one chip from this mod, reading the session cost and then ⧗ with today's R&D hours to one decimal ($1.25 ⧗0.0h; ⧗-- until the first ledger read). It never shows a cut-off …. With usage-gauge disabled there is no second cost chip.
  2. Press it: the Usage pane opens and is the one shown, with a tab R&D ledger beside it. Check Cost against /cost, the Context bar and window line, Rate limits, the Models table (estimates and shares), What's contributing after a subagent ran, and Code changes after an edit. Press Refresh (r) for the context categories. Press R&D ledger (d) or the tab: the ledger shows on the R&D view.
  3. Type /spend and /rd usage: each opens the same two panes, Usage in front. Set footerChip to cost, then time, and turn on chipContext: the chip changes after the next reload and stays within 16 characters.
  4. Narrow the pane with Usage shown: the Models table drops Req, Input and Cache wr, then Cache rd and Hit; nothing runs off the edge.
  5. On the ledger tab, the R&D view: the header names the week, then Hours in range, Weekly trend, Hours by entity, Hours by component, Activity mix, Review status, Spend this month, Pipeline and Deadlines, each with a bold title. Check the Total rows match Net hours. Press Session for this session's hours, tokens, costs, models and agents, and either recommendations or "No recommendations for this session".
  6. Press Sessions (or type /rd sessions): sessions this and last month are listed newest first; press More if there are over 40, pick an older one with Open session… (it may read "Reading the ledger…" for a moment), check its figures, press Back.
  7. Type /rd session and the first eight characters of a session id from the list: that session opens. Eight characters that match nothing, or two sessions, answer one line and open nothing.
  8. Reload the mod with the pane on a picked session: the same session is still shown.
  9. Make the pane narrow (drag it, or open it beside the transcript), then wide, on each of the five views: no line runs off the edge or ends cut short in a sentence; tables lose columns (Weekly trend loses Gross span, Hours by component loses Activity then Entity, Spend loses cache write and input then cache read, Sessions loses Recs then Agents and Repo) and long lines (captions, warnings, recommendations) continue on the next line.
  10. The buttons sit in two rows: R&D, Review, Spend, Session, Sessions on top; Refresh, Export, Close below (and Clear stale lock when a crashed session left a lock). Each hotkey still works.
  11. In Review, each day row reads as two lines (the component and entity, then the date, hours, status and activity) with its buttons under it.

Limits

  • macOS only for the subprocess paths (/bin/mv, /bin/rm, /bin/mkdir, /bin/rmdir, /usr/bin/git).
  • A session without the mod records nothing. Load it in every session (see Install).
  • Verified on the Claude desktop app 2.31226 driving CLI 2.1.296 (2026-10-12): a typed prompt arrives as origin composer with entrypoint claude-desktop; session.start reports no surface and not interactive on the desktop, so the surface is learnt from the first draw or prompt and kept on the record. Slash commands: whether a typed slash command raises an engagement event is still unverified; rd-tracker denies them.
  • sdk prompts are counted only on the desktop app (entrypoint claude-desktop with a desktop surface); a claude -p run started from a desktop session inherits the entrypoint variable
Source 15 files
hooks/register.tsx 2376 lines
1// rd-ledger: a contemporaneous, metadata-only ledger of R&D engagement time
2// and AI usage (SPEC v1.1). Every human prompt submission becomes one event
3// (its origin and verdict, never its text), every model turn one usage row,
4// each written into this process's own segment file under
5// ~/.claude/rd-ledger/sessions/YYYY/MM/. A timer folds every segment of this
6// and last month into a rollup in $.state; the pane (/rd, /rd session,
7// /rd sessions, /rd review, /rd spend) and the footer chip draw that rollup
8// and nothing else.
9//
10// The engine requires `$` to be handed only to functions declared at the top
11// of this file, so every function that touches `$` lives here. Everything that
12// decides anything (admission, union-v2, pricing, the review, the export, the
13// bridge plan) is pure and lives in the sibling modules; this file reads and
14// writes files, runs argv, and draws.
15//
16// Writes: a file is written whole to a temp beside it, both paths are stat'ed
17// (a symbolic link at either refuses the write), then `/bin/mv -f -h` swaps it
18// in. Shared files (components.json, reviews, the bridge log) are rewritten
19// only under the `mkdir` lock. Nothing here reads the prompt, the answer or a
20// transcript: `promptMeta(e)` is prompt.submit's only reading of its event.
21
22import { atom, read, update } from 'claude-code'
23import type { EngineInterface, Register, SessionUsageArgs, TurnStepResult } from 'claude-code'
24
25import type { Activity, BridgeInfo, BridgeLogEntry, BridgeLogKind, HandoffEntry, Mapping, QueueDecision, RecordSource, ReviewRevision, Rollup, SessionRecord, SessionSummary, UnionEvent, UnionParams, View, WeekRow } from '../types'
26import { addArgv, argvForLog, bridgeLine, compareArgv, detectBridge, importArgv, parseAddLine, parseBridgeLog, parseCompareJson, parseImportJson, planHandoff } from './bridge'
27import { buildExport, EXPORT_FILES, manifestText } from './exporter'
28import { sha256 } from './hash'
29import { applyQueueDecision, deadlines, historyLine, loadMapping, mappingDigest, mergeImport, saveMapping } from './mapping'
30import { dayNumber, fit, fitTable, overviewText, rdLines, SESSIONS_SHOWN, SESSIONS_SHOWN_MAX, sessionOptions, sessionsText, sessionText, shownRows, spendText, summaryLine, wrapText } from './report'
31import { buildConfirm, buildReopen, encodeReview, inputsDigest, latestConfirmed, latestRevision, loadReview, parseOtherHours } from './review'
32import { computeRollup, scopeWeeks } from './rollup'
33import {
34  CAP,
35  DEFAULT_PARAMS,
36  IGNORED,
37  NON_RD,
38  UNMAPPED,
39  bridgeLogPath,
40  decodeSession,
41  encodeSession,
42  exportDir,
43  isPlainAbsolute,
44  isSalt,
45  isSegmentName,
46  isTmpName,
47  lockDir,
48  makeSalt,
49  mappingHistoryPath,
50  mappingPath,
51  monthDir,
52  newSegment,
53  reviewPath,
54  reviewsDir,
55  rootHashOf,
56  saltPath,
57  segOf,
58  segmentPath,
59  sessionsDir,
60  storeDir,
61  timezonePath,
62  tmpPath,
63  withBranch,
64  withCacheTtl,
65  withClose,
66  withCost,
67  withEvent,
68  withTurn,
69 isOversize, ttlAt, withSurface } from './store'
70import type { TurnInput } from './store'
71import { cleanBranch, cleanField, hasOwn, hoursText, isMonth, isSessionId, isWeek, oneLine, repoKeyOf } from './text'
72import { addDays, detectZone, isoWeek, isoWithOffset, isZone, localDate, monthOfTs, previousMonth, weekDates, weekOfTs, zoneFromLocaltimeTarget } from './tz'
73import { allocate, buildBlocks, methodString, segmentKey } from './union'
74import type { ChipMode, UsageView } from './usage'
75import {
76  MAX_AGENT_IDS,
77  MAX_AGENT_TYPES,
78  MAX_MODELS,
79  UCAP,
80  addRequest,
81  boundedKey,
82  breakdownFrom,
83  chipLabel,
84  countPatch,
85  emptyCounts,
86  emptyTotals,
87  estimateUsd,
88  keepLatest,
89  ownGet,
90  unknownUsage,
91  usageFrom,
92  usageLines,
93  usageSummaryLine,
94} from './usage'
95
96type Dollar = EngineInterface
97type BridgeMode = 'corroborate' | 'source-of-truth' | 'off'
98type Stat = { kind: string; size: number; mtimeMs: number; isLink: boolean; realPath?: string }
99
100// $.state values, one literal plugin/key reference each, so `claude plugin
101// validate` holds them to types/index.d.ts. Written from handlers, timers and
102// events, never while drawing.
103// Shape-tagged: a reload of code whose rollup lacks the session fields reads the old value as absent (verifier V2).
104const rollupRef = atom({ plugin: 'rd-ledger', key: 'rollup' } as const, null as Rollup | null, { shape: 'rollup-rd-v3' })
105const viewRef = atom({ plugin: 'rd-ledger', key: 'view' } as const, 'overview' as View)
106const selectedSessionRef = atom({ plugin: 'rd-ledger', key: 'selectedSession' } as const, null as string | null)
107const sessionsShownRef = atom({ plugin: 'rd-ledger', key: 'sessionsShown' } as const, SESSIONS_SHOWN)
108const busyRef = atom({ plugin: 'rd-ledger', key: 'busy' } as const, null as string | null)
109const lastErrorRef = atom({ plugin: 'rd-ledger', key: 'lastError' } as const, null as string | null)
110const pendingRowsRef = atom({ plugin: 'rd-ledger', key: 'pendingRows' } as const, {} as Record<string, { activity: Activity; poe: boolean }>)
111const draftNotesRef = atom({ plugin: 'rd-ledger', key: 'draftNotes' } as const, {} as Record<string, string>)
112const draftOtherHoursRef = atom({ plugin: 'rd-ledger', key: 'draftOtherHours' } as const, {} as Record<string, string>)
113const recordPathRef = atom({ plugin: 'rd-ledger', key: 'recordPath' } as const, null as string | null)
114const armTokenRef = atom({ plugin: 'rd-ledger', key: 'armToken' } as const, null as string | null)
115const compareRef = atom({ plugin: 'rd-ledger', key: 'compare' } as const, null)
116// The Usage pane's figures (SPEC §6.6), shape-tagged so a reload of another
117// build's value reads as the initial one rather than a value missing fields.
118const usageRef = atom({ plugin: 'rd-ledger', key: 'usage' } as const, null, { shape: 'usage-v1' })
119const breakdownRef = atom({ plugin: 'rd-ledger', key: 'breakdown' } as const, null, { shape: 'breakdown-v1' })
120const usageTotalsRef = atom({ plugin: 'rd-ledger', key: 'usageTotals' } as const, emptyTotals(), { shape: 'usage-totals-v1' })
121const modelsRef = atom({ plugin: 'rd-ledger', key: 'models' } as const, {}, { shape: 'usage-models-v1' })
122const loopsRef = atom({ plugin: 'rd-ledger', key: 'loops' } as const, { main: emptyCounts(), agents: {} }, { shape: 'usage-loops-v1' })
123const agentTypesRef = atom({ plugin: 'rd-ledger', key: 'agentTypes' } as const, {} as Record<string, string>)
124const usageRefreshingRef = atom({ plugin: 'rd-ledger', key: 'usageRefreshing' } as const, false)
125
126const PANE = 'rd-ledger'
127const PANE_TITLE = 'R&D ledger'
128/** The Usage pane: a second pane of this mod, so the engine draws it and the ledger as tabs of one pane area. */
129const USAGE_PANE = 'rd-usage'
130const USAGE_TITLE = 'Usage'
131/** The version printed in the export; kept equal to .claude-plugin/plugin.json. */
132const MOD_VERSION = '0.2.0'
133const ADMISSION_VERSION = 'admission-v1'
134/** Turns, branch changes and cost figures coalesce into one flush this long after the first mark. */
135const FLUSH_MS = 500
136const MAX_BACKOFF_MS = 60_000
137const TIMEOUT_MS = 5000
138/** Every rdt call: rd-tracker's review derives the week, which can take a while. */
139const RDT_TIMEOUT_MS = 60_000
140/** A refresh after the person's own turn waits for the turn burst to settle. */
141const TURN_REFRESH_MS = 5000
142/** The pane re-reads on open when its rollup is older than this. */
143const STALE_MS = 60_000
144/** A lock older than this counts as stale: it is reported in the R&D view's Pipeline section and the person clears it; the code never takes a lock over (verifier V10). */
145const LOCK_STALE_MS = 15 * 60_000
146/** Destinations sent per Confirm; the rest go on the next Confirm. */
147const MAX_HANDOFF_PER_CONFIRM = 12
148/** A hand-off stops sending past this hold time (the rest waits for the next Confirm), well inside LOCK_STALE_MS. */
149const HANDOFF_TIME_GUARD_MS = 10 * 60_000
150
151/** One line about a lock that could not be taken: another session's, or a stale one the person must clear. */
152const lockHeldNote = (staleMs: number | null): string =>
153  staleMs === null ? 'another session holds the store lock' : `a store lock left by a crashed session ${Math.round(staleMs / 60_000)} minutes ago blocks it; clear it in the pane`
154const TMP_TTL_MS = 60 * 60_000
155const MAX_SWEEP = 10
156/** session.end shares a short budget with core's own end: a temp written later than this after the end began is left for the sweep. */
157const END_MV_BUDGET_MS = 1000
158/** A Compare row is marked when the two figures are further apart than this share. */
159const COMPARE_MARK = 0.1
160const WRITE = 'Write'
161const COMPARE = 'Compare'
162const HAND_OFF = 'Hand off'
163const CLEAR_LOCK = 'Clear lock'
164const CANCEL = 'Cancel'
165const USAGE = 'Usage: /rd [usage | session [<first 8 of a session id>] | sessions | review | spend | export [YYYY | YYYY-MM] | import]'
166const BUCKETS: ReadonlySet<string> = new Set([UNMAPPED, IGNORED, NON_RD])
167const STORE_LABEL = '~/.claude/rd-ledger'
168const ACTIVITY_LABEL: Record<Activity, string> = { direct_research: 'Research', direct_support: 'Support', non_qualified: 'Non-R&D' }
169
170type Config = { refreshMs: number; params: UnionParams; cacheTtl: '5m' | '1h'; rdtRepo: string; bridgeMode: BridgeMode; personLabel: string; footerButton: boolean; footerChip: ChipMode; chipContext: boolean }
171type Boot = { home: string | null; zone: string; salt: string | null; storeIsLink: boolean; surface: string | null; interactive: boolean }
172type Known = { mapping: Mapping; mappingText: string | null; problems: string[]; mappingReadOnly: boolean; segments: SessionRecord[]; bridge: BridgeInfo }
173
174// Per-load plumbing, not state: register() resets it, and a hot reload starts
175// it over together with the timers it cancels; session.start arms again and
176// continues the open segment through $.state.recordPath.
177let config: Config = {
178  refreshMs: 5 * 60_000,
179  params: DEFAULT_PARAMS,
180  cacheTtl: '5m',
181  rdtRepo: '',
182  bridgeMode: 'corroborate',
183  personLabel: '',
184  footerButton: true,
185  footerChip: 'both',
186  chipContext: false,
187}
188let boot: Boot | null = null
189let homeCache: string | null | undefined
190let timer: { cancel: () => void } | null = null
191let loadToken = ''
192let turnRefresh: { cancel: () => void } | null = null
193let refreshing: Promise<void> | null = null
194let flushTimer: { cancel: () => void } | null = null
195let flushing: Promise<void> | null = null
196let flushAgain = false
197let dirty = false
198let backoffMs = 0
199let writeSerial = 0
200/** The repository root the open segment was keyed from; the root hash is re-derived from it when the salt arrives late. */
201let lastRoot: string | null = null
202let current: SessionRecord | null = null
203let currentPath: string | null = null
204// Every change to which segment is open, and every row appended, runs in turn:
205// two prompts submitted together must both land, and two hooks must never
206// open two segments for one session.
207let mutating: Promise<unknown> = Promise.resolve()
208let fileCache = new Map<string, { record: SessionRecord | null; mtimeMs: number; size: number; readAt: number }>()
209// What the last refresh read beside the rollup: the Review view's Select
210// options and the mapping problems, and what Confirm digests. Replaced whole
211// by each refresh, read by drawings only after the rollup write that follows it.
212let known: Known | null = null
213
214const clamp = (value: unknown, fallback: number, min: number, max: number): number =>
215  typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
216
217/** An error's kind only: a message could carry a path or command output. */
218const kindOf = (err: unknown): string => (err instanceof Error && err.name ? oneLine(err.name, 40) : 'error')
219
220const isBucket = (component: string): boolean => BUCKETS.has(component)
221
222/** A problem loadMapping reports that a save would make permanent (an entry dropped, a field not kept). */
223const isBlockingProblem = (p: string): boolean => / dropped: /.test(p) || /not kept on save/.test(p) || /will not be rewritten/.test(p)
224
225/** The person's own origins (SPEC §6): their Enter, the Remote Control bridge, and the SDK host when this record's surface is the desktop. */
226const isPersonOrigin = (kind: string, surface: string | null): boolean => kind === 'composer' || kind === 'bridge' || (kind === 'sdk' && surface === 'desktop')
227
228
229/** The bridge status the pane and Health show (SPEC §7). */
230const bridgeStatus = (info: BridgeInfo): Rollup['health']['bridge'] => {
231  if (config.bridgeMode === 'off') return 'off'
232  if (config.rdtRepo === '') return 'standalone'
233  return info.present ? config.bridgeMode : 'unreachable'
234}
235
236/** `YYYY-MM-DDTHHMM` local: the export folder's name. */
237const stampOf = (now: number, zone: string): string => {
238  const iso = isoWithOffset(now, zone)
239  const stamp = `${iso.slice(0, 10)}T${iso.slice(11, 13)}${iso.slice(14, 16)}`
240  return /^\d{4}-\d{2}-\d{2}T\d{4}$/.test(stamp) ? stamp : `${localDate(now, zone)}T0000`
241}
242
243// --- small $ helpers -------------------------------------------------------------
244
245/** `HOME` as a plain absolute path, or null (the mod then runs read-only in memory). */
246async function homeOf($: Dollar): Promise<string | null> {
247  if (homeCache !== undefined) return homeCache
248  try {
249    const home = await $.env.get('HOME')
250    homeCache = isPlainAbsolute(home) ? home.replace(/\/+$/, '') || null : null
251  } catch {
252    homeCache = null
253  }
254  return homeCache
255}
256
257async function setError($: Dollar, text: string | null): Promise<void> {
258  await update($, lastErrorRef, () => (text === null ? null : oneLine(text, 200))).catch(() => undefined)
259}
260
261async function readText($: Dollar, path: string): Promise<string | null> {
262  try {
263    const text = await $.fs.read(path)
264    return typeof text === 'string' ? text : null
265  } catch {
266    return null
267  }
268}
269
270async function statOf($: Dollar, path: string, resolve = false): Promise<Stat | null> {
271  try {
272    return (resolve ? await $.fs.stat(path, { resolve: true }) : await $.fs.stat(path)) as Stat
273  } catch {
274    return null
275  }
276}
277
278async function existsQuiet($: Dollar, path: string): Promise<boolean> {
279  try {
280    return (await $.fs.exists(path)) === true
281  } catch {
282    return false
283  }
284}
285
286async function listQuiet($: Dollar, path: string): Promise<{ name: string; kind: string; size: number; mtimeMs: number; isLink: boolean }[]> {
287  try {
288    return (await $.fs.list(path)) as { name: string; kind: string; size: number; mtimeMs: number; isLink: boolean }[]
289  } catch {
290    return []
291  }
292}
293
294/** One argv; null when it could not start or was refused. */
295async function runProc($: Dollar, argv: string[], init: { cwd?: string; timeoutMs?: number } = {}): Promise<{ exitCode: number; stdout: string } | null> {
296  try {
297    const r = await $.process.run(argv, { timeoutMs: init.timeoutMs ?? TIMEOUT_MS, ...(init.cwd ? { cwd: init.cwd } : {}) })
298    return { exitCode: r.exitCode, stdout: r.stdout }
299  } catch {
300    return null
301  }
302}
303
304async function removeQuiet($: Dollar, path: string): Promise<void> {
305  await runProc($, ['/bin/rm', '-f', path])
306}
307
308/**
309 * Writes `text` to `finalPath` whole: a temp beside it, both paths stat'ed (a
310 * link at either refuses), then `mv -f -h`. Returns null on success, else one
311 * line saying what failed (never a path). `skipMv` leaves the temp for the
312 * sweep, when session.end's budget is spent.
313 */
314async function writeAtomic($: Dollar, finalPath: string, text: string, tag: string, skipMv = false): Promise<string | null> {
315  if (boot?.storeIsLink) return 'the store folder is a symbolic link, so nothing is written through it'
316  // The target's folder must be a real folder under the real store: a linked month folder is never written through (verifier V4).
317  if (boot?.home) {
318    // The nearest folder that exists decides (a missing month or year folder is created by the write itself): it must be a real folder under the real store (verifier N5).
319    const store = storeDir(boot.home)
320    let folder = finalPath.slice(0, finalPath.lastIndexOf('/'))
321    let ps: Stat | null = null
322    for (let i = 0; i < 6 && folder.length >= store.length; i += 1) {
323      ps = await statOf($, folder, true)
324      if (ps) break
325      folder = folder.slice(0, folder.lastIndexOf('/'))
326    }
327    const ss = await statOf($, store, true)
328    // Nothing of the store exists yet (first run): the write creates real folders, so there is nothing to lead elsewhere.
329    if (ps && ss) {
330      if (ps.isLink) return 'a store folder is a symbolic link; the write was refused'
331      const folderReal = (ps as { realPath?: string }).realPath ?? ''
332      const storeReal = (ss as { realPath?: string }).realPath ?? ''
333      if (folderReal !== '' && storeReal !== '' && folderReal !== storeReal && !folderReal.startsWith(`${storeReal}/`)) return 'a store folder leads outside the store; the write was refused'
334    } else if (ps?.isLink) return 'a store folder is a symbolic link; the write was refused'
335  }
336  writeSerial += 1
337  const tmp = tmpPath(finalPath, tag, writeSerial)
338  if ((await statOf($, tmp))?.isLink) return 'a temp file is a symbolic link; the write was refused'
339  try {
340    await $.fs.write(tmp, text)
341  } catch {
342    return 'a store file could not be written'
343  }
344  const [a, b] = await Promise.all([statOf($, tmp), statOf($, finalPath)])
345  if (a?.isLink || b?.isLink) {
346    await removeQuiet($, tmp)
347    return 'a store file is a symbolic link; the write was refused'
348  }
349  if (skipMv) return null
350  const r = await runProc($, ['/bin/mv', '-f', '-h', tmp, finalPath])
351  if (!r || r.exitCode !== 0) {
352    await removeQuiet($, tmp)
353    return 'a store file could not be replaced'
354  }
355  return null
356}
357
358/**
359 * Runs `fn` under the store's `mkdir` lock. A lock is never taken over by the
360 * code: two sessions taking over the same stale lock could both end up holding
361 * it (verifier V10), so a lock older than LOCK_STALE_MS is reported in Data
362 * health and in the toast, and the person clears it with a confirmed button.
363 */
364async function withLock<T>($: Dollar, home: string, fn: () => Promise<T>): Promise<{ ok: true; value: T } | { ok: false; staleMs: number | null }> {
365  const dir = lockDir(home)
366  const held = (await runProc($, ['/bin/mkdir', dir]))?.exitCode === 0
367  if (!held) return { ok: false, staleMs: await lockStaleMs($, home) }
368  try {
369    return { ok: true, value: await fn() }
370  } finally {
371    await runProc($, ['/bin/rmdir', dir])
372  }
373}
374
375/** The age of a held lock past LOCK_STALE_MS, or null (no lock, a young one, or one whose age is unknown). */
376async function lockStaleMs($: Dollar, home: string): Promise<number | null> {
377  const s = await statOf($, lockDir(home))
378  if (!s || s.isLink || s.mtimeMs <= 0) return null
379  const age = (await $.clock.now()) - s.mtimeMs
380  return age > LOCK_STALE_MS ? age : null
381}
382
383/** The person clears a stale lock (a crashed session's): confirmed, one rmdir, then a refresh. */
384async function clearStaleLock($: Dollar): Promise<void> {
385  const home = boot?.home
386  if (!home) return
387  const age = await lockStaleMs($, home)
388  if (age === null) {
389    $.ui.toast('R&D ledger: no stale lock to clear.')
390    return
391  }
392  if (!(await ask($, `Clear the store lock left by another session ${Math.round(age / 60_000)} minutes ago? Only do this when no other rd-ledger session is handing off or saving right now.`, CLEAR_LOCK))) return
393  // Re-checked after the answer: a lock cleared and re-taken meanwhile is a live one, never removed (verifier V14).
394  const ageNow = await lockStaleMs($, home)
395  if (ageNow === null || ageNow < age) {
396    $.ui.toast('R&D ledger: the lock changed while the question was open; nothing was removed.')
397    return
398  }
399  const r = await runProc($, ['/bin/rmdir', lockDir(home)])
400  $.ui.toast(r && r.exitCode === 0 ? 'R&D ledger: the stale lock is cleared.' : 'R&D ledger: the lock could not be removed.')
401  void refresh($)
402}
403
404/** A press that must not run twice at once: claims `busy`, or toasts and declines. */
405async function claimBusy($: Dollar, what: string): Promise<boolean> {
406  let claimed = false
407  await update($, busyRef, held => {
408    if (held !== null) return held
409    claimed = true
410    return what
411  })
412  if (!claimed) $.ui.toast('R&D ledger: another action is still running.')
413  return claimed
414}
415
416async function releaseBusy($: Dollar, what: string): Promise<void> {
417  await update($, busyRef, held => (held === what ? null : held)).catch(() => undefined)
418}
419
420// --- boot: the store, the zone, the salt ----------------------------------------------
421
422/**
423 * The zone, frozen at first run (ADR-005): the store's file; else rd-tracker's
424 * `store/timezone` when a repository is configured; else the runtime's Intl;
425 * else TZ; else the /etc/localtime link target; else UTC with an error (and
426 * then nothing is frozen, so the next start tries again).
427 */
428async function freezeZone($: Dollar, home: string | null, writable: boolean): Promise<{ zone: string; error: string | null }> {
429  if (home) {
430    const saved = (await readText($, timezonePath(home)))?.trim() ?? ''
431    if (saved !== '' && isZone(saved)) return { zone: saved, error: null }
432  }
433  let zone: string | null = null
434  if (config.rdtRepo !== '' && config.bridgeMode !== 'off') {
435    const theirs = (await readText($, `${config.rdtRepo}/store/timezone`))?.trim() ?? ''
436    if (theirs !== '' && isZone(theirs)) zone = theirs
437  }
438  zone ??= detectZone()
439  if (zone === null) {
440    const tz = await $.env.get('TZ').catch(() => undefined)
441    if (typeof tz === 'string' && isZone(tz)) zone = tz
442  }
443  if (zone === null) {
444    const target = (await statOf($, '/etc/localtime', true))?.realPath
445    if (typeof target === 'string') zone = zoneFromLocaltimeTarget(target)
446  }
447  if (zone === null) return { zone: 'UTC', error: 'the time zone could not be read, so UTC is used until it can be' }
448  if (home && writable) await writeAtomic($, timezonePath(home), `${zone}\n`, 'tz')
449  return { zone, error: null }
450}
451
452/** The install's salt: read, or written once from material only this install saw. Null when it cannot be kept. */
453async function ensureSalt($: Dollar, home: string | null, writable: boolean, now: number): Promise<string | null> {
454  if (!home) return null
455  const saved = (await readText($, saltPath(home)))?.trim() ?? ''
456  if (isSalt(saved)) return saved
457  if (!writable) return null
458  const sid = await $.session.id().catch(() => '')
459  const salt = makeSalt(`${sid}|${now}`)
460  // Under the store lock: two first-run processes must not hash the same repository differently (verifier V10).
461  const held = await withLock($, home, async () => {
462    const again = (await readText($, saltPath(home)))?.trim() ?? ''
463    if (isSalt(again)) return again
464    return (await writeAtomic($, saltPath(home), `${salt}\n`, 'salt')) === null ? salt : null
465  })
466  if (held.ok) return held.value
467  // The lock was busy: the other session may be writing the salt this moment; read it once more, and the next refresh tries again (verifier N6).
468  const later = (await readText($, saltPath(home)))?.trim() ?? ''
469  return isSalt(later) ? later : null
470}
471
472async function bootStore($: Dollar, start: { surface: string | null; interactive: boolean } | null): Promise<Boot> {
473  const now = await $.clock.now()
474  const home = await homeOf($)
475  const storeIsLink = home ? (await statOf($, storeDir(home), true))?.isLink === true : false
476  // The R&D view's Pipeline section reports a linked store; the boot writes nothing through it.
477  boot = { home, zone: boot?.zone ?? 'UTC', salt: null, storeIsLink, surface: start?.surface ?? boot?.surface ?? null, interactive: start?.interactive ?? boot?.interactive ?? false }
478  // Temp names carry a serial; seeding it from the clock keeps them unique across a hot reload (verifier V8).
479  if (writeSerial === 0) writeSerial = now % 1_000_000_000
480  const writable = home !== null && !storeIsLink
481  const frozen = await freezeZone($, home, writable)
482  const salt = await ensureSalt($, home, writable, now)
483  boot = { ...boot, zone: frozen.zone, salt }
484  const errors = [home ? null : 'HOME is not a plain absolute path, so the ledger runs read-only in memory', storeIsLink ? 'the store folder is a symbolic link, so nothing is written' : null, frozen.error].filter(Boolean)
485  if (errors.length > 0) await setError($, `rd-ledger: ${errors.join('; ')}`)
486  return boot
487}
488
489async function ensureBoot($: Dollar): Promise<Boot> {
490  return boot ?? (await bootStore($, null))
491}
492
493// --- the segment: open, continue, record, flush ---------------------------------------
494
495function markDirty($: Dollar): void {
496  dirty = true
497  if (flushTimer) return
498  flushTimer = $.clock.after(FLUSH_MS, () => {
499    flushTimer = null
500    void flush($)
501  })
502}
503
504async function flushOnce($: Dollar, skipMv: boolean): Promise<void> {
505  const record = current
506  const path = currentPath
507  if (!record || !path || !boot?.home) return
508  dirty = false
509  const err = await writeAtomic($, path, encodeSession(record), record.seg, skipMv)
510  if (err === null) {
511    if (backoffMs > 0) await setError($, null)
512    backoffMs = 0
513    return
514  }
515  dirty = true
516  backoffMs = Math.min(MAX_BACKOFF_MS, backoffMs === 0 ? 1000 : backoffMs * 2)
517  await setError($, `rd-ledger: ${err}; retrying in ${Math.round(backoffMs / 1000)} s`)
518  if (!flushTimer) {
519    flushTimer = $.clock.after(backoffMs, () => {
520      flushTimer = null
521      if (dirty) void flush($)
522    })
523  }
524}
525
526/** Runs `fn` after every earlier segment change has settled (one at a time, in order). */
527function serial<T>(fn: () => Promise<T>): Promise<T> {
528  const run = mutating.then(fn, fn)
529  mutating = run.catch(() => undefined)
530  return run
531}
532
533/** One flush of the open segment at a time; a mark during one re-flushes after it, and every caller waits for the last. */
534function flush($: Dollar, skipMv = false): Promise<void> {
535  if (flushing) {
536    flushAgain = true
537    return flushing
538  }
539  let mine: Promise<void> | null = null
540  mine = (async () => {
541    try {
542      do {
543        flushAgain = false
544        await flushOnce($, skipMv)
545      } while (flushAgain)
546    } finally {
547      if (flushing === mine) flushing = null
548    }
549  })()
550  flushing = mine
551  return mine
552}
553
554/** Opens a new segment for `sessionId` at `at` and writes it at once. */
555async function openSegment($: Dollar, sessionId: string, source: RecordSource, at: number): Promise<void> {
556  if (!isSessionId(sessionId)) return
557  const b = await ensureBoot($)
558  const repo = await $.session.repo().catch(() => null)
559  const cwd = await $.session.cwd().catch(() => '')
560  const root = repo?.root ?? cwd
561  lastRoot = root || null
562  const entrypoint = (await $.env.get('CLAUDE_CODE_ENTRYPOINT').catch(() => undefined)) ?? null
563  const model = await $.session.model().catch(() => null)
564  const usage = await $.session.usage().catch(() => null)
565  let openedAt = at
566  const pathAt = (t: number): string | null => (b.home ? segmentPath(b.home, monthOfTs(t, b.zone), sessionId, segOf(t)) : null)
567  let path = pathAt(openedAt)
568  // Two opens in one millisecond never share a file: a writer rewrites only the segment it opened.
569  for (let i = 0; path !== null && i < 5 && (await existsQuiet($, path)); i += 1) {
570    openedAt += 1
571    path = pathAt(openedAt)
572  }
573  current = newSegment({
574    sessionId,
575    openedAt,
576    source,
577    entrypoint,
578    interactive: b.interactive,
579    surface: b.surface,
580    repoKey: root ? repoKeyOf(root, b.home) : null,
581    repoKind: repo ? 'repo' : 'dir',
582    rootHash: b.salt && b.home && root ? rootHashOf(b.salt, root, b.home) : null,
583    cwdBase: cwd ? repoKeyOf(cwd, b.home) : 'unknown',
584    branch: null,
585    model: typeof model === 'string' ? model : null,
586    engineCostAtOpen: typeof usage?.cost?.usd === 'number' ? usage.cost.usd : null,
587  })
588  currentPath = path
589  await update($, recordPathRef, () => path)
590  if (path) await flush($)
591}
592
593/**
594 * session.start's open-or-continue (SPEC §3.1): the segment named in
595 * $.state.recordPath continues when it decodes, belongs to this session and is
596 * open (a hot reload or a worker respawn); anything else opens a new one. An
597 * undecodable file there is never rewritten.
598 */
599async function openOrContinue($: Dollar, at?: number): Promise<void> {
600  const b = await ensureBoot($)
601  const sid = await $.session.id().catch(() => '')
602  const saved = await read($, recordPathRef).catch(() => null)
603  if (saved && b.home && saved.startsWith(`${sessionsDir(b.home)}/`)) {
604    const text = await readText($, saved)
605    const onDisk = text === null ? null : decodeSession(text)
606    if (onDisk && onDisk.sessionId === sid && onDisk.closedAt === null) {
607      // The copy in memory is newer when this load wrote it (turns not yet flushed).
608      if (!(current && currentPath === saved && current.nextSeq >= onDisk.nextSeq)) current = onDisk
609      currentPath = saved
610      return
611    }
612  }
613  await openSegment($, sid, 'unknown', at ?? (await $.clock.now()))
614}
615
616/** The open segment for a row at `ts`: a new one when the session changed or the local month rolled over. */
617async function segmentFor($: Dollar, ts: number): Promise<SessionRecord | null> {
618  const b = await ensureBoot($)
619  // A hot reload forgets the open segment; a row that beats session.start continues it from recordPath.
620  if (!current) await openOrContinue($, ts)
621  const sid = await $.session.id().catch(() => '')
622  if (current && current.closedAt === null && current.sessionId === sid) {
623    const rollover = monthOfTs(ts, b.zone) !== monthOfTs(current.openedAt, b.zone) ? 'month' : isOversize(current) ? 'size' : null
624    if (rollover === null) return current
625    const prev = current
626    current = withClose(prev, rollover, ts)
627    await flush($)
628    await openSegment($, sid, prev.source, ts)
629    // The month's new segment carries the engine's figure on, so own cost is never counted twice.
630    if (current && current !== prev) current = { ...current, engineCostAtOpen: prev.engineCostUsd ?? prev.engineCostAtOpen, engineCostUsd: prev.engineCostUsd }
631    return current
632  }
633  if (current && current.closedAt === null) {
634    current = withClose(current, 'session-changed', ts)
635    await flush($)
636  }
637  if (!isSessionId(sid)) return null
638  await openSegment($, sid, 'unknown', ts)
639  return current
640}
641
642/** The hook's only reading of a submission: its origin's kind and whether a turn was running (SPEC §2). */
643function promptMeta(e: { origin?: { kind?: unknown }; turnId?: unknown }): { originKind: string; isQueued: boolean } {
644  const kind = e.origin?.kind
645  return { originKind: typeof kind === 'string' ? kind : 'unknown', isQueued: e.turnId !== undefined }
646}
647
648async function recordEvent($: Dollar, meta: { originKind: string; isQueued: boolean }): Promise<void> {
649  const ts = await $.clock.now()
650  const recorded = await serial(async () => {
651    const r = await segmentFor($, ts)
652    if (!r) return false
653    current = withEvent(r, { ts, origin: meta.originKind, queued: meta.isQueued }, ts)
654    return true
655  })
656  // Events are written at once: human pace, and none may be lost to a reload.
657  if (recorded) await flush($)
658}
659
660async function recordTurn($: Dollar, input: TurnInput): Promise<void> {
661  await serial(async () => {
662    const r = await segmentFor($, input.endTs)
663    if (!r) return
664    const next = withTurn(r, input, input.endTs)
665    if (next === r) return
666    current = next
667    markDirty($)
668  })
669}
670
671/** The branch of the session's working folder, read off the turn's path; a change marks the segment. */
672async function detectBranch($: Dollar): Promise<void> {
673  if (!current || current.repoKind !== 'repo' || current.closedAt !== null) return
674  const cwd = await $.session.cwd().catch(() => '')
675  if (!cwd) return
676  const r = await runProc($, ['/usr/bin/git', '-c', 'core.fsmonitor=false', '--no-optional-locks', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
677  if (!r || r.exitCode !== 0 || !current) return
678  const branch = cleanBranch(r.stdout)
679  const now = await $.clock.now()
680  await serial(async () => {
681    if (!current || current.closedAt !== null) return
682    const next = withBranch(current, branch, now)
683    if (next === current) return
684    current = next
685    markDirty($)
686  })
687}
688
689// --- refresh: segments → rollup ------------------------------------------------------------
690
691async function bridgeInfoOf($: Dollar): Promise<BridgeInfo> {
692  if (config.bridgeMode === 'off') return { present: false, reason: 'off' }
693  return detectBridge(config.rdtRepo, path => existsQuiet($, path))
694}
695
696async function daysSinceExport($: Dollar, home: string | null, today: string): Promise<number | null> {
697  if (!home) return null
698  const names = (await listQuiet($, `${storeDir(home)}/export`)).map(e => e.name).filter(n => /^\d{4}-\d{2}-\d{2}T\d{4}$/.test(n)).sort()
699  const newest = names[names.length - 1]
700  return newest ? Math.max(0, dayNumber(today) - dayNumber(newest.slice(0, 10))) : null
701}
702
703/**
704 * Every segment in the months' folders (changed files only re-read). With
705 * `fromMemory` the open segment is taken from memory (it may hold rows not yet
706 * flushed); without it, as the export needs, every segment comes from its file.
707 */
708async function readSegments($: Dollar, b: Boot, months: readonly string[], now: number, fromMemory: boolean): Promise<{ segments: SessionRecord[]; unreadable: number }> {
709  const segments: SessionRecord[] = []
710  let unreadable = 0
711  let swept = 0
712  if (b.home) {
713    for (const month of months) {
714      const dir = monthDir(b.home, month)
715      for (const entry of await listQuiet($, dir)) {
716        const path = `${dir}/${entry.name}`
717        if (isTmpName(entry.name)) {
718          if (fromMemory && swept < MAX_SWEEP && !entry.isLink && entry.mtimeMs > 0 && now - entry.mtimeMs > TMP_TTL_MS) {
719            swept += 1
720            await removeQuiet($, path)
721          }
722          continue
723        }
724        if (!isSegmentName(entry.name) || entry.isLink) continue
725        if (fromMemory && current && path === currentPath) continue
726        const cached = fileCache.get(path)
727        let record: SessionRecord | null
728        // The racy rule: a file written in the same millisecond it was read is read again.
729        if (cached && cached.mtimeMs === entry.mtimeMs && cached.size === entry.size && entry.mtimeMs < cached.readAt - 1) record = cached.record
730        else {
731          const text = await readText($, path)
732          record = text === null ? null : decodeSession(text)
733          fileCache.set(path, { record, mtimeMs: entry.mtimeMs, size: entry.size, readAt: now })
734        }
735        if (record) segments.push(record)
736        else unreadable += 1
737      }
738    }
739  }
740  if (fromMemory && current && months.includes(monthOfTs(current.openedAt, b.zone))) segments.push(current)
741  return { segments, unreadable }
742}
743
744async function refreshOnce($: Dollar): Promise<void> {
745  const b = await ensureBoot($)
746  if (!isZone(b.zone)) {
747    await setError($, 'rd-ledger: the frozen time zone is not one this runtime knows')
748    return
749  }
750  const now = await $.clock.now()
751  const month = monthOfTs(now, b.zone)
752  const { segments, unreadable } = await readSegments($, b, [month, previousMonth(month)], now, true)
753  const mappingText = b.home ? await readText($, mappingPath(b.home)) : null
754  const loaded = loadMapping(mappingText)
755  const today = localDate(now, b.zone)
756  // Every week the R&D view's trend lists (this and last week among them), so each one's confirmation is known.
757  const weeks = scopeWeeks(today)
758  const reviews = new Map(await Promise.all(weeks.map(async w => [w, loadReview(b.home ? await readText($, reviewPath(b.home, w)) : null, w).review] as const)))
759  const bridge = await bridgeInfoOf($)
760  if (boot && boot.salt === null && b.home && !b.storeIsLink) {
761    const salt = await ensureSalt($, b.home, true, now)
762    if (salt) {
763      boot = { ...boot, salt }
764      // The segment opened without a salt gets its root hash now, so its hours resolve through key#hash (verifier N6).
765      if (current && current.rootHash === null && lastRoot) {
766        current = { ...current, rootHash: rootHashOf(salt, lastRoot, b.home), updatedAt: now }
767        markDirty($)
768      }
769    }
770  }
771  const year = Number(today.slice(0, 4))
772  // The returns still ahead: last tax year's until its extended date passes, then this year's.
773  const seen = new Set<string>()
774  const ahead = [...deadlines(loaded.mapping, year - 1, today), ...deadlines(loaded.mapping, year, today)].filter(d => {
775    const key = `${d.date}|${d.label}|${d.entity ?? ''}`
776    if (d.date < today || seen.has(key)) return false
777    seen.add(key)
778    return true
779  })
780  const rollup = computeRollup({
781    now,
782    zone: b.zone,
783    params: config.params,
784    defaultTtl: config.cacheTtl,
785    segments,
786    mapping: loaded.mapping,
787    reviews,
788    health: {
789      zoneDetectedNow: detectZone(),
790      unreadable,
791      daysSinceExport: await daysSinceExport($, b.home, today),
792      storeIsLink: b.storeIsLink,
793      lockStaleMs: b.home ? await lockStaleMs($, b.home) : null,
794      bridge: bridgeStatus(bridge),
795      readOnly: b.home === null || b.storeIsLink,
796    },
797    deadlines: ahead,
798    current: current ? { sessionId: current.sessionId, seg: current.seg } : null,
799    selected: await read($, selectedSessionRef).catch(() => null),
800  })
801  known = { mapping: loaded.mapping, mappingText, problems: loaded.problems, mappingReadOnly: loaded.readOnly, segments, bridge }
802  await update($, rollupRef, () => rollup)
803}
804
805/** One refresh at a time: the timer, a turn, a press or a command during one joins it. */
806function refresh($: Dollar): Promise<void> {
807  if (refreshing) return refreshing
808  let mine: Promise<void> | null = null
809  mine = (async () => {
810    try {
811      await refreshOnce($)
812    } catch (err) {
813      await setError($, `rd-ledger: refresh failed (${kindOf(err)})`)
814    } finally {
815      if (refreshing === mine) refreshing = null
816    }
817  })()
818  refreshing = mine
819  return mine
820}
821
822/** The refresh timer, armed once per load; the token in state lets only the newest load's timer refresh. */
823async function arm($: Dollar): Promise<void> {
824  if (timer) return
825  loadToken ||= `load-${(await $.clock.now()).toString(36)}`
826  const token = loadToken
827  await update($, armTokenRef, () => token)
828  timer = $.clock.every(config.refreshMs, () => {
829    void (async () => {
830      if ((await read($, armTokenRef).catch(() => null)) !== token) return
831      await refresh($)
832    })()
833  })
834}
835
836function refreshAfterTurn($: Dollar): void {
837  turnRefresh?.cancel()
838  turnRefresh = $.clock.after(TURN_REFRESH_MS, () => {
839    turnRefresh = null
840    void refresh($)
841  })
842}
843
844async function freshRollup($: Dollar, maxAgeMs = STALE_MS): Promise<Rollup | null> {
845  const r = await read($, rollupRef).catch(() => null)
846  if (r && (await $.clock.now()) - r.at <= maxAgeMs) return r
847  await refresh($)
848  return read($, rollupRef).catch(() => null)
849}
850
851// --- the pane ----------------------------------------------------------------------------
852
853/** A rollup that carries the session fields (an older one, kept across a reload, does not). */
854const hasSessions = (r: Rollup | null): r is Rollup => r !== null && Array.isArray(r.sessions) && typeof r.details === 'object' && r.details !== null
855/** The full summary the rollup carries for `id`, or null. */
856const detailOf = (r: Rollup, id: string | null): SessionSummary | null => (id !== null && hasOwn(r.details, id) ? (r.details[id] ?? null) : null)
857
858/**
859 * Makes the rollup carry the full summary of a session picked past the newest
860 * rows: a refresh reads the selection from state, and a second one runs when
861 * the first had already started before the selection landed (joined refresh).
862 */
863async function ensureDetails($: Dollar, id: string): Promise<void> {
864  for (let i = 0; i < 2; i += 1) {
865    const r = await read($, rollupRef).catch(() => null)
866    if (!hasSessions(r) || !r.sessions.some(x => x.sessionId === id) || detailOf(r, id)) return
867    await refresh($)
868  }
869}
870
871/**
872 * Today's recorded seconds for the footer chip, from the cached rollup only;
873 * null before one exists (or when an older build's rollup lacks the figure),
874 * which the label draws as `--`. A declaration, so the allowlist check can
875 * follow `$` into it.
876 */
877async function todayFigure($: Dollar): Promise<number | null> {
878  const rollup = await read($, rollupRef)
879  return rollup && typeof rollup.todayS === 'number' ? rollup.todayS : null
880}
881
882/**
883 * Opens the pane; `selected` (when given) is the session the Session view
884 * shows, null for this process's own. `focus: false` opens it without asking
885 * for the keyboard (the engine's `focus` is a request that is only ever
886 * `true`, so the key is left out), as the chip does when Usage follows it.
887 */
888async function openPane($: Dollar, view: View | null, selected?: string | null, opts: { focus: boolean } = { focus: true }) {
889  if (selected !== undefined) await update($, selectedSessionRef, () => selected)
890  if (view) await update($, viewRef, () => view)
891  const opened = await $.ui.open(opts.focus ? { id: PANE, title: PANE_TITLE, focus: true, closeOnEscape: true } : { id: PANE, title: PANE_TITLE, closeOnEscape: true })
892  const r = await read($, rollupRef).catch(() => null)
893  if (!r || (await $.clock.now()) - r.at > STALE_MS) void refresh($)
894  return opened
895}
896
897// --- the Usage pane (SPEC §6.6) ----------------------------------------------------------
898
899/**
900 * Reads the session's usage figures (and, with `breakdown`, the window by
901 * category) into $.state. A refused read leaves every figure unknown rather
902 * than absent, so the pane draws `--` instead of waiting forever.
903 */
904async function readUsage($: Dollar, args?: SessionUsageArgs): Promise<void> {
905  const now = await $.clock.now()
906  let reading
907  try {
908    reading = await $.session.usage(args)
909  } catch {
910    await update($, usageRef, prev => prev ?? unknownUsage(now))
911    return
912  }
913  await update($, usageRef, prev => usageFrom(reading, prev, now, true))
914  if (args?.breakdown) await update($, breakdownRef, () => breakdownFrom(reading.context.breakdown))
915}
916
917/** Usage open and its Refresh: the figures and the summary breakdown (estimated locally, nothing is sent). */
918async function refreshUsage($: Dollar, columns?: number): Promise<void> {
919  if (await read($, usageRefreshingRef)) return
920  await update($, usageRefreshingRef, () => true)
921  try {
922    await readUsage($, columns === undefined ? { breakdown: 'summary' } : { breakdown: 'summary', columns })
923  } finally {
924    await update($, usageRefreshingRef, () => false).catch(() => undefined)
925  }
926}
927
928/** Opens the Usage pane, focused, and reads the breakdown behind it. */
929async function openUsagePane($: Dollar) {
930  const opened = await $.ui.open({ id: USAGE_PANE, title: USAGE_TITLE, focus: true, closeOnEscape: true })
931  if (opened.isPlaced) void refreshUsage($).catch(() => undefined)
932  return opened
933}
934
935/**
936 * The chip's press, `/rd usage` and `/spend`: the R&D ledger first, without
937 * the keyboard, then Usage, focused, so Usage is the pane shown and "R&D
938 * ledger" is the tab beside it (the engine draws one pane per id, one shown,
939 * the rest tabs). Answers whether Usage was placed.
940 */
941async function openUsageAndLedger($: Dollar): Promise<boolean> {
942  // An open ledger keeps its view (a Review or Session the person is in): only Usage opens or takes focus.
943  const panes = await $.ui.panes().catch(() => [] as readonly { id: string }[])
944  if (!panes.some(p => p.id === PANE)) await openPane($, 'overview', undefined, { focus: false }).catch(() => undefined)
945  return (await openUsagePane($)).isPlaced
946}
947
948/**
949 * The type an agent id was spawned as, looked up once and remembered; `agent`
950 * when it is not listed. `$.agent.list()` is read for each entry's `id` and
951 * `type` only: the task description an entry carries is never read (a source
952 * ban holds that), so no task text reaches the state.
953 */
954async function agentTypeOf($: Dollar, agentId: string): Promise<string> {
955  const id = agentId.slice(0, 128)
956  const known = ownGet(await read($, agentTypesRef), id)
957  if (known !== undefined) return known
958  let type = 'agent'
959  try {
960    for (const entry of await $.agent.list()) {
961      if (entry.id !== agentId) continue
962      if (typeof entry.type === 'string') type = oneLine(entry.type, UCAP.agentType) || 'agent'
963      break
964    }
965  } catch {
966    // Unknown stays `agent`; it is remembered so the list is not asked again for it.
967  }
968  await update($, agentTypesRef, m => keepLatest({ ...m, [id]: type }, MAX_AGENT_IDS))
969  return type
970}
971
972/** One model request, counted under its model and its loop, priced at the cache TTL in force now. */
973async function recordStep($: Dollar, agentId: string | undefined, model: string, r: TurnStepResult | undefined, t0: number | null): Promise<void> {
974  const now = await $.clock.now()
975  const apiMs = t0 === null ? 0 : Math.max(0, now - t0)
976  const usage = r?.usage ?? null
977  const rawModel = typeof usage?.model === 'string' && usage.model ? usage.model : typeof model === 'string' ? model : ''
978  const counts = usage
979    ? { input: usage.input_tokens, output: usage.output_tokens, cacheRead: usage.cache_read_input_tokens, cacheWrite: usage.cache_creation_input_tokens }
980    : null
981  const ttl = (current ? ttlAt(current, now) : null) ?? config.cacheTtl
982  // A request with no usage (failed, interrupted) has no tokens to price.
983  const est = counts ? estimateUsd(rawModel, counts, ttl) : 0
984  const modelKey = oneLine(rawModel, UCAP.model) || 'unknown'
985  const type = agentId === undefined ? null : await agentTypeOf($, agentId)
986  await update($, modelsRef, m => {
987    const key = boundedKey(m, modelKey, MAX_MODELS)
988    return { ...m, [key]: addRequest(ownGet(m, key), counts, apiMs, est) }
989  })
990  await update($, loopsRef, l => {
991    if (type === null) return { ...l, main: addRequest(l.main, counts, apiMs, est) }
992    const key = boundedKey(l.agents, type, MAX_AGENT_TYPES)
993    return { ...l, agents: { ...l.agents, [key]: addRequest(ownGet(l.agents, key), counts, apiMs, est) } }
994  })
995  await update($, usageTotalsRef, t => ({ ...t, loadedAt: t.loadedAt || now, updatedAt: now, requests: t.requests + 1, apiMs: t.apiMs + apiMs }))
996}
997
998/** A turn's end: the main loop's count and wall time, or a subagent's count. */
999async function recordUsageTurn($: Dollar, agentId: string | undefined, durationMs: number): Promise<void> {
1000  const now = await $.clock.now()
1001  const ms = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0
1002  await update($, usageTotalsRef, t =>
1003    agentId === undefined
1004      ? { ...t, loadedAt: t.loadedAt || now, updatedAt: now, turns: t.turns + 1, activeMs: t.activeMs + ms }
1005      : { ...t, loadedAt: t.loadedAt || now, updatedAt: now, agentTurns: t.agentTurns + 1 },
1006  )
1007}
1008
1009/** An Edit / Write / NotebookEdit result's line counts; a denied or failed call counts nothing. No path or text is kept. */
1010async function recordEdit($: Dollar, ran: unknown): Promise<void> {
1011  const counted = countPatch(ran)
1012  if (!counted) return
1013  const now = await $.clock.now()
1014  await update($, usageTotalsRef, t => ({
1015    ...t,
1016    loadedAt: t.loadedAt || now,
1017    updatedAt: now,
1018    linesAdded: t.linesAdded + counted.added,
1019    linesRemoved: t.linesRemoved + counted.removed,
1020    edits: t.edits + 1,
1021  }))
1022}
1023
1024/** Everything the Usage pane and its text answer draw. */
1025async function readUsageView($: Dollar): Promise<UsageView> {
1026  return {
1027    usage: await read($, usageRef),
1028    breakdown: await read($, breakdownRef),
1029    totals: await read($, usageTotalsRef),
1030    models: await read($, modelsRef),
1031    loops: await read($, loopsRef),
1032  }
1033}
1034
1035/** The footer chip's label from the state alone: the session cost and today's R&D hours by the footerChip option. */
1036async function chipText($: Dollar): Promise<string> {
1037  const usage = config.footerChip === 'time' && !config.chipContext ? null : await read($, usageRef)
1038  return chipLabel(usage, await todayFigure($), config.footerChip, config.chipContext)
1039}
1040
1041/**
1042 * `/rd usage` and `/spend`: the person's own origins open Usage beside the
1043 * ledger; headless, unplaced, or any other origin gets the one summary line
1044 * (counts only).
1045 */
1046async function runUsageCommand($: Dollar, originKind: string): Promise<string> {
1047  const surfaces = await $.session.surfaces().catch(() => [] as readonly string[])
1048  const surfaceNow = await learnSurface($, surfaces[0] ?? null)
1049  if (surfaces.length > 0 && isPersonOrigin(originKind, surfaceNow)) {
1050    let isPlaced = false
1051    try {
1052      isPlaced = await openUsageAndLedger($)
1053    } catch {
1054      isPlaced = false
1055    }
1056    if (isPlaced) return 'Usage pane opened.'
1057  }
1058  return usageSummaryLine(await readUsageView($))
1059}
1060
1061// --- mapping saves -----------------------------------------------------------------------
1062
1063/**
1064 * Applies `change` to components.json under the lock (SPEC §3.3): re-read,
1065 * refused when the file cannot be rewritten as it stands, when a save would
1066 * erase hand-edited entries the loader dropped, or when its `gen` moved since
1067 * the view was drawn; else written at `gen + 1` with one history line.
1068 */
1069async function changeMapping($: Dollar, change: (m: Mapping) => Mapping | null, describe: string): Promise<string | null> {
1070  const b = await ensureBoot($)
1071  const home = b.home
1072  if (!home || b.storeIsLink) return 'the store is read-only'
1073  const expectedGen = known?.mapping.gen ?? null
1074  const now = await $.clock.now()
1075  const held = await withLock($, home, async (): Promise<string | null> => {
1076    const text = await readText($, mappingPath(home))
1077    const loaded = loadMapping(text)
1078    if (loaded.readOnly) return 'components.json cannot be rewritten as it stands; fix it by hand'
1079    const blocking = loaded.problems.filter(isBlockingProblem)
1080    if (blocking.length > 0) return `components.json has ${blocking.length} ${blocking.length === 1 ? 'problem' : 'problems'} a save would erase; fix ${blocking.length === 1 ? 'it' : 'them'} by hand first (listed in Review)`
1081    if (expectedGen !== null && loaded.mapping.gen !== expectedGen) return 'components.json changed on disk; the view is refreshed, pick again'
1082    const next = change(loaded.mapping)
1083    if (!next) return 'that choice cannot be saved'
1084    const gen = loaded.mapping.gen + 1
1085    const err = await writeAtomic($, mappingPath(home), saveMapping({ ...next, gen }), 'map')
1086    if (err) return err
1087    const history = (await readText($, mappingHistoryPath(home))) ?? ''
1088    const sep = history === '' || history.endsWith('\n') ? '' : '\n'
1089    await writeAtomic($, mappingHistoryPath(home), `${history}${sep}${historyLine(now, gen, describe)}\n`, 'hist')
1090    return null
1091  })
1092  return held.ok ? held.value : `${lockHeldNote(held.staleMs)}; try again in a moment`
1093}
1094
1095/** A queue row's Select pick: `c:<component>`, `ignore`, or `nonrd:<entity>`. */
1096async function pickQueue($: Dollar, key: string, value: string): Promise<void> {
1097  const decision: QueueDecision | null =
1098    value === 'ignore' ? { ignore: true } : value.startsWith('c:') ? { component: value.slice(2) } : value.startsWith('nonrd:') ? { nonRd: true, entity: value.slice(6) } : null
1099  if (!decision) return
1100  const err = await changeMapping($, m => applyQueueDecision(m, key, decision), `map ${key} to ${value}`)
1101  if (err) {
1102    $.ui.toast(`R&D ledger: ${err}.`)
1103    await refresh($)
1104    return
1105  }
1106  $.ui.toast(`R&D ledger: ${oneLine(key, 80)} mapped.`)
1107  await refresh($)
1108}
1109
1110// --- review drafts, Confirm, Reopen ------------------------------------------------------
1111
1112async function setActivity($: Dollar, row: WeekRow, activity: Activity): Promise<void> {
1113  await update($, pendingRowsRef, m => {
1114    const prior = hasOwn(m, row.key) ? m[row.key] : undefined
1115    return { ...m, [row.key]: { activity, poe: activity === 'non_qualified' ? false : (prior?.poe ?? row.poe) } }
1116  })
1117}
1118
1119async function togglePoe($: Dollar, row: WeekRow, week: string): Promise<void> {
1120  const pending = await read($, pendingRowsRef)
1121  const prior = hasOwn(pending, row.key) ? pending[row.key] : undefined
1122  const poe = prior?.poe ?? row.poe
1123  if (!poe) {
1124    const notes = await read($, draftNotesRef)
1125    const note = notes[`${row.component}|${week}`] ?? ''
1126    if (note.trim() === '') {
1127      $.ui.toast(`R&D ledger: add a note for ${oneLine(row.component, 64)} (${week}) before turning POE on.`)
1128      return
1129    }
1130  }
1131  await update($, pendingRowsRef, m => ({ ...m, [row.key]: { activity: prior?.activity ?? row.activity, poe: !poe } }))
1132}
1133
1134/** The admitted events of `week`, as the inputs digest reads them. */
1135const weekEvents = (segments: readonly SessionRecord[], week: string, zone: string): UnionEvent[] =>
1136  segments.flatMap(r => r.events.filter(ev => ev.admit === 1 && weekOfTs(ev.ts, zone) === week).map(ev => ({ ts: ev.ts, sessionId: r.sessionId, seg: r.seg, seq: ev.seq })))
1137
1138/** Confirm week: freeze the rows with the drafts applied as a `confirm` revision; under source-of-truth, hand off. */
1139async function confirmWeek($: Dollar): Promise<void> {
1140  if (!(await claimBusy($, 'confirm'))) return
1141  try {
1142    await refresh($)
1143    const b = await ensureBoot($)
1144    const rollup = await read($, rollupRef)
1145    const home = b.home
1146    if (!rollup || !known || !home || b.storeIsLink) {
1147      $.ui.toast('R&D ledger: nothing to confirm (the store is read-only or not read yet).')
1148      return
1149    }
1150    const week = rollup.week
1151    const [pending, notesDraft, otherDraft] = await Promise.all([read($, pendingRowsRef), read($, draftNotesRef), read($, draftOtherHoursRef)])
1152    const otherHours: Record<string, number | null> = {}
1153    for (const [id, text] of Object.entries(otherDraft)) {
1154      const parsed = parseOtherHours(text)
1155      if (parsed !== null && typeof parsed === 'object') {
1156        $.ui.toast(`R&D ledger: other hours for ${oneLine(id, 40)}: ${parsed.error}.`)
1157        return
1158      }
1159      otherHours[id] = parsed
1160    }
1161    const rows: Record<string, { seconds: number; entity: string; activity: Activity; poe: boolean; source: 'pretag' | 'confirmed' }> = {}
1162    for (const row of rollup.weekRows) {
1163      const draft = hasOwn(pending, row.key) ? pending[row.key] : undefined
1164      rows[row.key] = { seconds: row.seconds, entity: row.entity, activity: draft?.activity ?? row.activity, poe: draft?.poe ?? row.poe, source: draft ? 'confirmed' : 'pretag' }
1165    }
1166    const now = await $.clock.now()
1167    const digestIn = inputsDigest(weekEvents(known.segments, week, b.zone))
1168    const digestMap = mappingDigest(known.mappingText ?? '')
1169    const held = await withLock($, home, async (): Promise<ReviewRevision | string> => {
1170      const loaded = loadReview(await readText($, reviewPath(home, week)), week)
1171      if (loaded.readOnly) return `the review file for ${week} cannot be rewritten as it stands`
1172      const prior = latestRevision(loaded.review)
1173      const notes: Record<string, string> = { ...(prior?.notes ?? {}) }
1174      for (const [key, text] of Object.entries(notesDraft)) if (key.endsWith(`|${week}`)) notes[key] = text
1175      const built = buildConfirm(loaded.review, {
1176        at: now,
1177        method: rollup.method,
1178        params: rollup.params,
1179        zone: rollup.zone,
1180        mappingDigest: digestMap,
1181        inputsDigest: digestIn,
1182        rows,
1183        otherHours: { ...(prior?.otherHours ?? {}), ...otherHours },
1184        notes,
1185        weekEnd: weekDates(week)[6] as string,
1186      })
1187      if ('refused' in built) return built.refused
1188      const err = await writeAtomic($, reviewPath(home, week), encodeReview(built.review), 'rev')
1189      if (err) return err
1190      return latestRevision(built.review) as ReviewRevision
1191    })
1192    if (!held.ok) {
1193      $.ui.toast(`R&D ledger: ${lockHeldNote(held.ok ? null : held.staleMs)}.`)
1194      return
1195    }
1196    if (typeof held.value === 'string') {
1197      $.ui.toast(`R&D ledger: not confirmed: ${oneLine(held.value, 160)}.`)
1198      return
1199    }
1200    const revision = held.value
hooks/bridge.ts 481 lines
1// rd-ledger: the rd-tracker bridge (SPEC §7, ADR-003). Workstream W4. Pure:
2// discovery decisions, argv builders, the hand-off plan, the log and the
3// parsers. The register runs the argv and writes the log.
4//
5// The record this feeds is a tax record, so two failures matter more than any
6// other: an argv that carries something it should not (argument injection, a
7// credential in a note), and an hour counted twice. Every argv token is one
8// `--flag=value` string built from values that passed a shape check here; and
9// the plan never sends a destination whose last call has no known outcome.
10
11import type { Activity, BridgeInfo, BridgeLogEntry, BridgeLogKind, CompareResult, Filing, HandoffEntry, HandoffPlan, ImportedRegistry } from '../types'
12import { CAP, isPlainAbsolute } from './store'
13import { cleanField, isDate, isId, isWeek, oneLine } from './text'
14
15/** The fixed registry-import program: `python -I -c IMPORT_PROGRAM <entities.yaml> <components.yaml>`; it must contain `safe_load` and nothing that reads a path other than its two arguments. */
16export const IMPORT_PROGRAM = [
17  'import json, sys, yaml',
18  'e = yaml.safe_load(open(sys.argv[1], encoding="utf-8")) or {}',
19  'c = yaml.safe_load(open(sys.argv[2], encoding="utf-8")) or {}',
20  'ents = {k: {"name": str((v or {}).get("legal_name") or k)} for k, v in (e.get("entities") or {}).items()}',
21  'comps = {k: {"name": str((v or {}).get("name") or k), "entity": str((((v or {}).get("entity") or [{}])[-1] or {}).get("id") or "")} for k, v in (c.get("components") or {}).items()}',
22  'print(json.dumps({"entities": ents, "components": comps}))',
23].join('\n')
24
25export const PROVENANCE = 'rd-ledger'
26
27/** The most one destination can carry: 24 hours, rd-tracker's own `--hours` ceiling. */
28export const MAX_HUNDREDTHS = 2400
29
30const RDT_INIT = 'rdt/__init__.py'
31const ENTITIES_YAML = 'registries/entities.yaml'
32const COMPONENTS_YAML = 'registries/components.yaml'
33const VENV_PYTHON = '.venv/bin/python'
34/** The interpreter as the log spells it (CYB-10: the absolute path names the person's HOME). */
35const LOG_INTERPRETER = 'python'
36const LOG_REPO = '<repo>'
37const LOG_PROGRAM = '<import-program>'
38const LOG_MAX_TOKENS = 40
39const LOG_MAX_TOKEN_CHARS = 2000
40/** A longer log line is not one the mod wrote (40 tokens of 2000 plus the fixed fields). */
41const LOG_MAX_LINE_CHARS = 128 * 1024
42const LABEL_CAP = 40
43/** `rdt review --json` for one busy week is tens of KB; anything past this is not its answer. */
44const MAX_JSON_CHARS = 4 * 1024 * 1024
45/** `rdt add` prints one line; a longer stdout is scanned only this far. */
46const MAX_ADD_STDOUT_CHARS = 64 * 1024
47const DAY_MS = 86_400_000
48
49const ACTIVITIES: ReadonlySet<string> = new Set<Activity>(['direct_research', 'direct_support', 'non_qualified'])
50const KINDS: ReadonlySet<string> = new Set<BridgeLogKind>(['intent', 'sent', 'uncertain', 'failed', 'needs-manual-adjust'])
51const FILINGS: ReadonlySet<string> = new Set<Filing>(['c-corp', 's-corp', 'schedule-c', 'partnership', 'other'])
52
53const isRecordObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
54
55// --- calendar arithmetic (pure; no clock) ------------------------------------
56
57/** Days since 1970-01-01 for a real calendar date from 1970 on; null for a shape-valid impossible date like 2026-02-31. */
58const dayNumber = (date: string): number | null => {
59  if (typeof date !== 'string' || !isDate(date)) return null
60  const y = Number(date.slice(0, 4))
61  const m = Number(date.slice(5, 7))
62  const d = Number(date.slice(8, 10))
63  if (y < 1970) return null
64  const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0
65  const inMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1] ?? 0
66  return d <= inMonth ? Date.UTC(y, m - 1, d) / DAY_MS : null
67}
68
69/** 0 for Monday through 6 for Sunday (1970-01-01 was a Thursday). */
70const weekdayOf = (day: number): number => (((day + 3) % 7) + 7) % 7
71
72/** The ISO week `YYYY-Www` holding a day: the week belongs to the year of its Thursday. */
73const isoWeekOf = (day: number): string => {
74  const thursday = day - weekdayOf(day) + 3
75  let year = 1970 + Math.floor(thursday / 365.2425)
76  while (Date.UTC(year, 0, 1) / DAY_MS > thursday) year -= 1
77  while (Date.UTC(year + 1, 0, 1) / DAY_MS <= thursday) year += 1
78  const week = Math.floor((thursday - Date.UTC(year, 0, 1) / DAY_MS) / 7) + 1
79  return `${year}-W${String(week).padStart(2, '0')}`
80}
81
82/** An instant as the register formats it (`toISOString`, or with an offset); the calendar must be real. */
83const isIsoInstant = (s: unknown): s is string =>
84  typeof s === 'string' &&
85  /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{1,3})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/.test(s) &&
86  dayNumber(s.slice(0, 10)) !== null &&
87  Number.isFinite(Date.parse(s))
88
89/** Whole hundredths as `H.HH` by integer arithmetic, so no float rounding reaches rd-tracker. */
90const hoursArg = (hundredths: number): string => `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}`
91
92const stripRepo = (path: string): string => String(path).replace(/\/+$/, '')
93
94// --- discovery and argv ------------------------------------------------------
95
96/** CONTRACT (W4). Whether the bridge is present: `rdtRepo` plain absolute and the three files exist (`exists` is injected). No PATH fallback. */
97export const detectBridge = async (rdtRepo: string, exists: (path: string) => Promise<boolean>): Promise<BridgeInfo> => {
98  if (typeof rdtRepo !== 'string' || rdtRepo.trim() === '') return { present: false, reason: 'not configured' }
99  const repo = stripRepo(rdtRepo)
100  // The reason is drawn in the pane, so it names the rule, never the path (the path names HOME).
101  if (repo === '' || !isPlainAbsolute(repo) || /\p{Cc}/u.test(repo)) return { present: false, reason: 'the rd-tracker path is not a plain absolute path' }
102  const probe = async (rel: string): Promise<boolean> => {
103    try {
104      return (await exists(`${repo}/${rel}`)) === true
105    } catch {
106      return false
107    }
108  }
109  const wanted = [RDT_INIT, COMPONENTS_YAML, VENV_PYTHON]
110  const found = await Promise.all(wanted.map(probe))
111  const missing = wanted.filter((_, i) => !found[i])
112  if (missing.length > 0) return { present: false, reason: `not found in the rd-tracker folder: ${missing.join(', ')}` }
113  return { present: true, repo, python: `${repo}/${VENV_PYTHON}` }
114}
115
116/** The fixed provenance at the head of every note: where the hours came from, which week and which revision; no method or path. */
117const provenanceOf = (week: string, rev: number): string => `${PROVENANCE}: week ${week} rev ${rev}`
118
119/**
120 * Why `rdt add` must not be called for this entry, or null when it may. Shared
121 * by addArgv and planHandoff so the plan never offers to send what the argv
122 * builder would refuse. The note is scanned whole: a credential or an email in
123 * it refuses the entry rather than sending a redacted justification.
124 */
125const refusalOf = (entry: HandoffEntry): { reason: string } | { note: string | null } => {
126  const day = dayNumber(entry.date)
127  if (day === null) return { reason: 'the date is not a calendar date' }
128  if (typeof entry.week !== 'string' || !isWeek(entry.week)) return { reason: 'the week is not an ISO week' }
129  if (isoWeekOf(day) !== entry.week) return { reason: 'the date is not in the week' }
130  if (!Number.isSafeInteger(entry.rev) || entry.rev < 1) return { reason: 'the revision is not a positive whole number' }
131  if (!Number.isSafeInteger(entry.hundredths) || entry.hundredths <= 0 || entry.hundredths > MAX_HUNDREDTHS) return { reason: 'the hours must be above 0 and at most 24' }
132  if (typeof entry.rdtEntity !== 'string' || !isId(entry.rdtEntity)) return { reason: 'the rd-tracker entity id is not valid' }
133  if (entry.rdtComponent !== null && (typeof entry.rdtComponent !== 'string' || !isId(entry.rdtComponent))) return { reason: 'the rd-tracker component id is not valid' }
134  if (!ACTIVITIES.has(entry.activity)) return { reason: 'the activity type is not one of the three' }
135  const cleaned = cleanField(typeof entry.note === 'string' ? entry.note : null, CAP.note)
136  if (cleaned.redacted) return { reason: 'the note holds a credential or an email address; edit the note first' }
137  if (entry.poe === true && cleaned.value === null) return { reason: 'a process-of-experimentation flag needs a note' }
138  if (entry.startIso !== null && !isIsoInstant(entry.startIso)) return { reason: 'the start is not an ISO instant' }
139  if (entry.endIso !== null && !isIsoInstant(entry.endIso)) return { reason: 'the end is not an ISO instant' }
140  if (entry.startIso !== null && entry.endIso !== null && Date.parse(entry.startIso) >= Date.parse(entry.endIso)) return { reason: 'the start is not before the end' }
141  return { note: cleaned.value }
142}
143
144/** CONTRACT (W4). `[python, '-I', '-m', 'rdt', 'add', '--date=…', '--hours=H.HH', '--entity=…', '--component=…'?, '--activity-type=…', '--poe=1'?, '--note=…', '--start=…'?, '--end=…'?]`; refused on an invalid id, non-positive hours, or poe without a note. */
145export const addArgv = (info: Extract<BridgeInfo, { present: true }>, entry: HandoffEntry): string[] | { refused: string } => {
146  if (!isPlainAbsolute(info.python)) return { refused: 'the interpreter path is not plain' }
147  const verdict = refusalOf(entry)
148  if ('reason' in verdict) return { refused: verdict.reason }
149  // Every value is one `--flag=value` token: argparse never reads a value as a flag.
150  const argv = [info.python, '-I', '-m', 'rdt', 'add', `--date=${entry.date}`, `--hours=${hoursArg(entry.hundredths)}`, `--entity=${entry.rdtEntity}`]
151  if (entry.rdtComponent !== null) argv.push(`--component=${entry.rdtComponent}`)
152  argv.push(`--activity-type=${entry.activity}`)
153  if (entry.poe === true) argv.push('--poe=1')
154  argv.push(`--note=${provenanceOf(entry.week, entry.rev)}${verdict.note === null ? '' : ` — ${verdict.note}`}`)
155  if (entry.startIso !== null) argv.push(`--start=${entry.startIso}`)
156  if (entry.endIso !== null) argv.push(`--end=${entry.endIso}`)
157  return argv
158}
159
160/**
161 * CONTRACT (W4). `[python, '-I', '-m', 'rdt', 'review', '--json', '--week=<monday>']`.
162 * The contract has no refusal shape, so a value that is not a real Monday throws:
163 * the register's caller derives the Monday itself, and a wrong one is a bug to surface.
164 */
165export const compareArgv = (info: Extract<BridgeInfo, { present: true }>, monday: string): string[] => {
166  if (!isPlainAbsolute(info.python)) throw new Error('compareArgv: the interpreter path is not plain')
167  const day = dayNumber(monday)
168  if (day === null || weekdayOf(day) !== 0) throw new Error('compareArgv: the week must be given by its Monday')
169  return [info.python, '-I', '-m', 'rdt', 'review', '--json', `--week=${monday}`]
170}
171
172/** CONTRACT (W4). `[python, '-I', '-c', IMPORT_PROGRAM, '<repo>/registries/entities.yaml', '<repo>/registries/components.yaml']`. */
173export const importArgv = (info: Extract<BridgeInfo, { present: true }>): string[] => {
174  const repo = stripRepo(info.repo)
175  if (!isPlainAbsolute(info.python) || repo === '' || !isPlainAbsolute(repo)) throw new Error('importArgv: the rd-tracker path is not plain')
176  return [info.python, '-I', '-c', IMPORT_PROGRAM, `${repo}/${ENTITIES_YAML}`, `${repo}/${COMPONENTS_YAML}`]
177}
178
179/** CONTRACT (W4). The argv as the log stores it: the interpreter spelled `python`, `<repo>` for the repo path (CYB-10). */
180export const argvForLog = (argv: readonly string[], repo: string): string[] => {
181  const base = typeof repo === 'string' ? stripRepo(repo) : ''
182  return argv.slice(0, LOG_MAX_TOKENS).map((token, i) => {
183    if (i === 0) return LOG_INTERPRETER
184    if (token === IMPORT_PROGRAM) return LOG_PROGRAM
185    const text = String(token)
186    return oneLine(base === '' ? text : text.split(base).join(LOG_REPO), LOG_MAX_TOKEN_CHARS)
187  })
188}
189
190// --- parsers -------------------------------------------------------------------
191
192/** CSI, OSC and two-character escape sequences, so a coloured line reads as its text. */
193const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
194
195/** rd-tracker's success line, whole: anchored so a note echoed inside another line cannot pass for it. */
196const ADD_LINE = /^rdt add: manual entry (\d{1,12}) (?:—|--?) (\d{1,2}(?:\.\d{1,4})?)h on (\d{4}-\d{2}-\d{2}) \[([a-z][a-z_-]{0,39}), lag (-?\d{1,5})d\]$/
197
198/**
199 * CONTRACT (W4). rd-tracker's `rdt add: manual entry <n> — <h>h on <date> [<label>, lag <d>d]` line → row id, label, lag; null when absent.
200 * Null too when two lines match: one call makes one row, so a second line means
201 * the output is not what it seems, and the caller logs the call `uncertain`.
202 */
203export const parseAddLine = (stdout: string): { rowId: number; label: string; lagDays: number } | null => {
204  if (typeof stdout !== 'string') return null
205  let found: { rowId: number; label: string; lagDays: number } | null = null
206  let matches = 0
207  for (const raw of stdout.slice(0, MAX_ADD_STDOUT_CHARS).split(/\r\n|\r|\n/)) {
208    const m = ADD_LINE.exec(oneLine(raw.replace(ANSI, ''), 400))
209    if (m === null) continue
210    matches += 1
211    const rowId = Number(m[1])
212    const lagDays = Number(m[5]) + 0
213    if (!Number.isSafeInteger(rowId) || rowId < 1 || !Number.isSafeInteger(lagDays)) return null
214    found = { rowId, label: m[4] ?? '', lagDays }
215  }
216  return matches === 1 ? found : null
217}
218
219/** A JSON object from a bounded text; null for anything else. */
220const parseObject = (text: unknown): Record<string, unknown> | null => {
221  if (typeof text !== 'string' || text.length > MAX_JSON_CHARS) return null
222  try {
223    const value: unknown = JSON.parse(text.trim())
224    return isRecordObject(value) ? value : null
225  } catch {
226    return null
227  }
228}
229
230/** CONTRACT (W4). `rdt review --json` output → Claude Code hours per date (sum of `sessions[].seconds` where `harness` is `claude_code`) and the method; null for garbage. */
231export const parseCompareJson = (text: string, at: number): CompareResult | null => {
232  const root = parseObject(text)
233  if (root === null) return null
234  const sessions = root['sessions']
235  if (sessions !== undefined && sessions !== null && !Array.isArray(sessions)) return null
236  const seconds: Record<string, number> = Object.create(null) as Record<string, number>
237  for (const s of Array.isArray(sessions) ? sessions : []) {
238    if (!isRecordObject(s) || s['harness'] !== 'claude_code') continue
239    const date = s['date']
240    const secs = s['seconds']
241    if (typeof date !== 'string' || !isDate(date) || typeof secs !== 'number' || !Number.isFinite(secs) || secs < 0) continue
242    seconds[date] = (seconds[date] ?? 0) + secs
243  }
244  // Seconds are summed first and converted once, so per-session rounding never accumulates.
245  const byDate: Record<string, number> = Object.create(null) as Record<string, number>
246  for (const date of Object.keys(seconds).sort()) byDate[date] = Math.round(((seconds[date] ?? 0) / 3600) * 1000) / 1000
247  const method = typeof root['method'] === 'string' ? cleanField(root['method'], CAP.name).value : null
248  return { at, byDate, method }
249}
250
251const nameOf = (value: unknown, id: string): string => (typeof value === 'string' ? cleanField(value, CAP.name).value : null) ?? id
252
253/** CONTRACT (W4). The import program's JSON → a registry with ids passing `isId`, names cleaned, invalid entries dropped; null for garbage. */
254export const parseImportJson = (text: string): ImportedRegistry | null => {
255  const root = parseObject(text)
256  if (root === null) return null
257  const ents = root['entities'] ?? {}
258  const comps = root['components'] ?? {}
259  if (!isRecordObject(ents) || !isRecordObject(comps)) return null
260  // Null-prototype tables: `constructor` is a valid id and must stay an ordinary key (CYB-8).
261  const entities: ImportedRegistry['entities'] = Object.create(null) as ImportedRegistry['entities']
262  const components: ImportedRegistry['components'] = Object.create(null) as ImportedRegistry['components']
263  for (const id of Object.keys(ents)) {
264    const v = ents[id]
265    if (!isId(id) || !isRecordObject(v)) continue
266    const filing = typeof v['filing'] === 'string' && FILINGS.has(v['filing']) ? (v['filing'] as Filing) : null
267    entities[id] = { name: nameOf(v['name'], id), filing }
268  }
269  for (const id of Object.keys(comps)) {
270    const v = comps[id]
271    if (!isId(id) || !isRecordObject(v)) continue
272    const entity = v['entity']
273    if (typeof entity !== 'string' || !isId(entity)) continue
274    components[id] = { name: nameOf(v['name'], id), entity }
275  }
276  return { entities, components }
277}
278
279// --- the hand-off plan and the log ---------------------------------------------
280
281/** The destination key. Ids are `[a-z0-9-]` and dates digits and dashes, so `|` cannot occur inside a part. */
282const destKey = (date: string | null, entity: string, component: string | null): string => `${date ?? ''}|${entity}|${component ?? ''}`
283
284const OUTCOMES: ReadonlySet<string> = new Set<BridgeLogKind>(['sent', 'failed', 'uncertain'])
285
286type DestState = { open: BridgeLogEntry[]; uncertain: boolean; sentAny: boolean; sentHundredths: number; sentUnknown: boolean }
287
288/**
289 * The log folded per destination, in file order (never by `at`: a clock step
290 * must not reorder a call and its outcome). An outcome closes the most recent
291 * open intent for its key, so `intent, intent, sent` leaves the first intent
292 * open: a crashed call that may have made a row.
293 */
294const foldLog = (log: readonly BridgeLogEntry[]): Map<string, DestState> => {
295  const states = new Map<string, DestState>()
296  for (const e of log) {
297    const key = destKey(e.date, e.rdtEntity, e.rdtComponent)
298    let s = states.get(key)
299    if (s === undefined) {
300      s = { open: [], uncertain: false, sentAny: false, sentHundredths: 0, sentUnknown: false }
301      states.set(key, s)
302    }
303    if (e.kind === 'intent') s.open.push(e)
304    else if (OUTCOMES.has(e.kind)) s.open.pop()
305    if (e.kind === 'uncertain') s.uncertain = true
306    if (e.kind === 'sent') {
307      s.sentAny = true
308      if (typeof e.hundredths === 'number' && Number.isSafeInteger(e.hundredths) && e.hundredths > 0) s.sentHundredths += e.hundredths
309      else s.sentUnknown = true
310    }
311    // `needs-manual-adjust` records a decision, not a call: it opens and closes nothing.
312  }
313  return states
314}
315
316const byDestination = (a: HandoffEntry, b: HandoffEntry): number => {
317  const ka = [a.date, a.rdtEntity, a.rdtComponent ?? '']
318  const kb = [b.date, b.rdtEntity, b.rdtComponent ?? '']
319  for (let i = 0; i < 3; i++) {
320    const x = ka[i] ?? ''
321    const y = kb[i] ?? ''
322    if (x !== y) return x < y ? -1 : 1
323  }
324  return 0
325}
326
327/**
328 * CONTRACT (W4). The hand-off plan for a confirmed revision's rows against the
329 * bridge log: key `(date, rdtEntity, rdtComponent ?? '')`; `send` when no
330 * `sent` line matches at the same hundredths; `adjust` when a `sent` line has
331 * a different amount; `blocked` when an `uncertain` (or an `intent` with no
332 * outcome) exists for that key. Rows with 0 hundredths are skipped.
333 *
334 * Beyond the contract line, each rule errs toward not sending: several `sent`
335 * lines compare by their sum (what rd-tracker holds); a `sent` line with no
336 * amount blocks; two rows for one destination block both (one `rdt add` per
337 * destination); a row the argv builder would refuse blocks; and a 0-hundredths
338 * row is skipped only when nothing was sent, else it is an `adjust`, because
339 * rd-tracker still holds hours the revision no longer claims.
340 */
341export const planHandoff = (rows: readonly HandoffEntry[], log: readonly BridgeLogEntry[]): HandoffPlan => {
342  const states = foldLog(log)
343  const groups = new Map<string, HandoffEntry[]>()
344  for (const row of rows) {
345    const key = destKey(row.date, row.rdtEntity, row.rdtComponent)
346    const group = groups.get(key)
347    if (group === undefined) groups.set(key, [row])
348    else group.push(row)
349  }
350  const plan: HandoffPlan = { send: [], adjust: [], blocked: [] }
351  for (const [key, group] of groups) {
352    const row = group[0]
353    if (row === undefined) continue
354    if (group.length > 1) {
355      plan.blocked.push(...group)
356      continue
357    }
358    const s = states.get(key)
359    if (s !== undefined && (s.uncertain || s.open.length > 0 || s.sentUnknown)) {
360      plan.blocked.push(row)
361      continue
362    }
363    if (row.hundredths === 0) {
364      if (s !== undefined && s.sentAny) plan.adjust.push(row)
365      continue
366    }
367    if (s !== undefined && s.sentAny) {
368      if (s.sentHundredths !== row.hundredths) plan.adjust.push(row)
369      continue
370    }
371    if ('reason' in refusalOf(row)) plan.blocked.push(row)
372    else plan.send.push(row)
373  }
374  plan.send.sort(byDestination)
375  plan.adjust.sort(byDestination)
376  plan.blocked.sort(byDestination)
377  return plan
378}
379
380const nullable = <T>(value: unknown, ok: (x: unknown) => x is T): T | null | undefined => (value === undefined || value === null ? null : ok(value) ? value : undefined)
381const intIn = (min: number, max: number) => (x: unknown): x is number => typeof x === 'number' && Number.isSafeInteger(x) && x >= min && x <= max
382const strWhere = (test: (s: string) => boolean) => (x: unknown): x is string => typeof x === 'string' && test(x)
383const ANY_INT = intIn(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
384const REV = intIn(1, Number.MAX_SAFE_INTEGER)
385const HUNDREDTHS = intIn(0, MAX_HUNDREDTHS)
386const ROW_ID = intIn(1, Number.MAX_SAFE_INTEGER)
387const WEEK = strWhere(isWeek)
388const DATE = strWhere(isDate)
389const ID = strWhere(isId)
390
391/** One log line → an entry with exactly the declared keys, or null when any field is off-shape. A missing nullable field reads null. */
392const decodeLogLine = (line: string): BridgeLogEntry | null => {
393  if (line.length > LOG_MAX_LINE_CHARS) return null
394  let raw: unknown
395  try {
396    raw = JSON.parse(line)
397  } catch {
398    return null
399  }
400  if (!isRecordObject(raw)) return null
401  const at = raw['at']
402  const kind = raw['kind']
403  const argv = raw['argv']
404  const stdoutLine = raw['stdoutLine']
405  if (typeof at !== 'number' || !Number.isFinite(at)) return null
406  if (typeof kind !== 'string' || !KINDS.has(kind)) return null
407  if (!ID(raw['rdtEntity'])) return null
408  if (!Array.isArray(argv) || argv.length > LOG_MAX_TOKENS || !argv.every(t => typeof t === 'string' && t.length <= LOG_MAX_TOKEN_CHARS)) return null
409  if (typeof stdoutLine !== 'string') return null
410  const week = nullable(raw['week'], WEEK)
411  const rev = nullable(raw['rev'], REV)
412  const date = nullable(raw['date'], DATE)
413  const rdtComponent = nullable(raw['rdtComponent'], ID)
414  const hundredths = nullable(raw['hundredths'], HUNDREDTHS)
415  const exitCode = nullable(raw['exitCode'], ANY_INT)
416  const rowId = nullable(raw['rowId'], ROW_ID)
417  const label = nullable(raw['label'], (x: unknown): x is string => typeof x === 'string')
418  const lagDays = nullable(raw['lagDays'], ANY_INT)
419  if ([week, rev, date, rdtComponent, hundredths, exitCode, rowId, label, lagDays].includes(undefined)) return null
420  return {
421    at,
422    kind: kind as BridgeLogKind,
423    week: week ?? null,
424    rev: rev ?? null,
425    date: date ?? null,
426    rdtEntity: raw['rdtEntity'] as string,
427    rdtComponent: rdtComponent ?? null,
428    hundredths: hundredths ?? null,
429    argv: (argv as string[]).map(t => oneLine(t, LOG_MAX_TOKEN_CHARS)),
430    exitCode: exitCode ?? null,
431    rowId: rowId ?? null,
432    label: typeof label === 'string' ? oneLine(label, LABEL_CAP) || null : null,
433    lagDays: lagDays ?? null,
434    stdoutLine: cleanField(stdoutLine, CAP.stdout).value ?? '',
435  }
436}
437
438/** CONTRACT (W4). The log from its NDJSON text: malformed lines skipped and counted; an `intent` with no later outcome for its key is reported in `pending`. */
439export const parseBridgeLog = (text: string | null): { entries: BridgeLogEntry[]; malformed: number; pending: BridgeLogEntry[] } => {
440  const entries: BridgeLogEntry[] = []
441  let malformed = 0
442  if (typeof text !== 'string') return { entries, malformed, pending: [] }
443  for (const line of text.split('\n')) {
444    if (line.trim() === '') continue
445    const entry = decodeLogLine(line)
446    if (entry === null) malformed += 1
447    else entries.push(entry)
448  }
449  const pending: BridgeLogEntry[] = []
450  for (const s of foldLog(entries).values()) pending.push(...s.open)
451  pending.sort((a, b) => entries.indexOf(a) - entries.indexOf(b))
452  return { entries, malformed, pending }
453}
454
455const writeInt = (x: unknown, ok: (v: unknown) => v is number): number | null => (ok(x) ? x : null)
456
457/**
458 * CONTRACT (W4). One log line as JSON (no newline). Keys in the declared order,
459 * and every field shaped so parseBridgeLog reads the line back: a line the
460 * reader dropped as malformed would lose an `intent`, and a lost intent is how
461 * an hour gets sent twice.
462 */
463export const bridgeLine = (entry: BridgeLogEntry): string =>
464  JSON.stringify({
465    at: typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : 0,
466    kind: entry.kind,
467    week: entry.week,
468    rev: writeInt(entry.rev, REV),
469    date: entry.date,
470    rdtEntity: entry.rdtEntity,
471    rdtComponent: entry.rdtComponent,
472    hundredths: writeInt(entry.hundredths, HUNDREDTHS),
473    argv: (Array.isArray(entry.argv) ? entry.argv : []).slice(0, LOG_MAX_TOKENS).map(t => oneLine(String(t), LOG_MAX_TOKEN_CHARS)),
474    exitCode: writeInt(entry.exitCode, ANY_INT),
475    rowId: writeInt(entry.rowId, ROW_ID),
476    label: typeof entry.label === 'string' ? oneLine(entry.label, LABEL_CAP) || null : null,
477    lagDays: writeInt(entry.lagDays, ANY_INT),
478    // rdt's stdout is outside text: scanned and redacted whole, like every stored field (text.ts).
479    stdoutLine: cleanField(typeof entry.stdoutLine === 'string' ? entry.stdoutLine : '', CAP.stdout).value ?? '',
480  })
481
hooks/exporter.ts 718 lines
1// rd-ledger: the export file set (SPEC §6.4). Workstream W3. Pure: builds the
2// texts; the register writes them and verifies the manifest. Every cell goes
3// through cleanField (redacted in place, counted) and csvCell.
4//
5// The export is the CPA's hardcopy of the store, so three rules hold for every
6// file. (1) Redaction never aborts: a credential, an email address or a path
7// under a home folder becomes `[redacted:<kind>]` or `~` in place and is
8// counted by file and kind in the README. (2) Non-claimable rows (`__unmapped__`,
9// `__ignored__`, `__non_rd__`, or a component pre-tagged or confirmed
10// `non_qualified`) show the bucket name in place of the repo key, root hash and
11// branch (PRV-4), so a personal repository never reaches the CPA by name. (3)
12// Scope is by local date in the frozen zone: a row is in when its date falls in
13// the period, so a segment crossing the boundary contributes only its rows
14// inside it. Prices come through a `Pricing` seam (prices.ts by default) so the
15// file shapes can be tested apart from the price table.
16
17import type { Activity, Allocation, Block, BridgeLogEntry, Mapping, ReviewFile, ReviewRevision, SessionRecord, SpendRow, TurnRecord, UnionParams } from '../types'
18import { pretag, resolveRepo } from './mapping'
19import { CACHE_WRITE_MULTIPLIER, PRICES, PRICES_AS_OF, priceTurn, spendByMonth } from './prices'
20import { BUCKETS, ESTIMATE_LABEL, isoWeekOfDate, localClock, monthlyLogPages, utcIso, weekDateList } from './report'
21import { latestConfirmed, latestRevision, rowStatus } from './review'
22import { CAP, IGNORED, NON_RD, UNMAPPED, decodeSession, encodeSession, ttlAt } from './store'
23import { cleanField, csvLine, findCredentials, isDate, isMonth, isRedacted, isWeek, oneLine, scanJoined } from './text'
24
25const ENGAGEMENT_MODEL_TEXT = `## 4. Engagement model — union-v2 parity
26
27**Admission (stored per event):**
28
29| Condition | admit | reason |
30|---|---|---|
31| origin \`composer\` or \`bridge\`, entrypoint ∈ {\`claude-desktop\`, \`cli\`} | 1 | \`human\` |
32| origin \`sdk\`, entrypoint \`claude-desktop\`, record surface \`desktop\` | 1 | \`human-desktop-host\` |
33| origin \`sdk\`, entrypoint \`claude-desktop\`, surface not \`desktop\` (a \`claude -p\` child of a desktop session inherits the variable) | 0 | \`sdk-nested\` |
34| origin \`sdk\` otherwise | 0 | \`automation-entrypoint\` |
35| entrypoint unset | 0 | \`entrypoint-unknown\` |
36| any other origin kind (the \`PromptOrigin\` union, typed) | 0 | \`origin:<kind>\` |
37
38Recorded after \`await next(e)\` only when the result has no \`drop\`. Slash commands typed by the
39person: whether \`prompt.submit\` fires for them is a live-check item (§12); the README states
40the parity gap either way (rd-tracker denies \`<command-name\` lines).
41
42### 4.1 Determinism rules (rd-tracker parity, by line)
43
441. Instants are integer seconds: \`Math.floor(ts / 1000)\`.
452. Order admitted events by \`(instant, sessionId, seg, seq)\`; an event with the same
46   \`(instant, sessionId)\` as the previous one is a duplicate and collapses (rd-tracker's
47   dedupe key).
483. Merge while \`gap ≤ gapS\` (1800 merges, 1801 splits).
494. A chain whose span \`> maxBlockS\` splits at its largest gap; ties go to the gap nearest the
50   chain's temporal midpoint, then the earlier gap (\`timeline.py:451-469\`); recurse.
515. Block seconds = last − first; a single-event block gets \`floorS\` (60) once, before the
52   midnight split.
536. Midnight split: segments \`[lo, hi)\` half-open, the last closed on its end
54   (\`timeline.py:522-527\`); seconds apportioned by event count with largest remainder, ties to
55   the earlier segment (\`timeline.py:369-394\`).
567. Allocation: block seconds apportioned over its events with the same \`apportion\`; each
57   event's seconds go to its session's \`repoKey#rootHash\` → component → entity, or to
58   \`__unmapped__\` / \`__ignored__\` / the entity's \`non_qualified\` bucket for \`nonRd\`.
598. Flags, never auto-claimed: \`block-over-4h\` dated by the chain's first event
60   (\`timeline.py:585\`); \`day-over-cap\` when a local day exceeds \`dayFlagH\`; \`automation-heavy\`
61   when denied > 10 × admitted on a day (strictly greater); \`rows-dropped\` when any segment
62   in scope has \`eventsDropped > 0\`.
639. Method string: \`union-v2/\` + first 8 hex of sha256 over
64   \`{"automation_ratio":10,"day_flag_hours":14,"idle_gap_seconds":G,"max_block_seconds":14400,"single_event_floor_seconds":60,"timezone":"<zone>"}\`
65   with sorted keys and no spaces (\`timeline.py:98-113\`). Golden: \`a5db146c\` for G = 1800 and
66   \`America/New_York\`.
6710. Integer seconds throughout; rounding once at display; every table's rows sum to its total
68    in seconds.`
69
70const RETENTION_TEXT = `### 3.6 Retention
71
72The store **is** the record; the export is its hardcopy. Nothing is deleted except temp files
73matching \`isTmpName\`, older than one hour, one \`rm -f <absolute path>\` each, never recursive.
74Growth is bounded by the row caps (counters record drops). Back the store up off this Mac;
75The R&D view's Pipeline section shows days since the last export and prompts after each closed month.
76§1.6001-1(e) materiality and the 20-year §39 carryforward are the reasons.`
77
78/** The header row of every CSV, in order (SPEC §6.4). */
79export const CSV_COLUMNS = {
80  'events.csv': ['date', 'ts', 'sessionId', 'seg', 'seq', 'repoKey', 'rootHash', 'branch', 'origin', 'admit', 'reason', 'queued'],
81  'blocks.csv': ['date', 'start', 'end', 'seconds', 'eventCount', 'method', 'flags'],
82  'allocations.csv': ['date', 'blockStart', 'sessionId', 'component', 'entity', 'seconds', 'activity', 'poe', 'status', 'rev'],
83  'hours_by_component.csv': ['week', 'component', 'entity', 'confirmedSeconds', 'liveSeconds', 'activity', 'poe', 'status', 'rev', 'confirmedLagDays'],
84  'activity_notes.csv': ['week', 'component', 'note'],
85  'turns.csv': ['ts', 'endTs', 'sessionId', 'seg', 'seq', 'agent', 'model', 'input', 'output', 'cacheRead', 'cacheWrite', 'ttl', 'estUsd', 'priced'],
86  'spend_by_month.csv': ['month', 'model', 'component', 'entity', 'turns', 'tokens', 'estUsd', 'label'],
87} as const
88
89/** The export's file names in the order they are built; MANIFEST.sha256 is the register's. */
90export const EXPORT_FILES = ['README.md', 'method.json', 'events.csv', 'blocks.csv', 'allocations.csv', 'hours_by_component.csv', 'activity_notes.csv', 'turns.csv', 'spend_by_month.csv', 'monthly_log.txt', 'reviews.json', 'components.json', 'bridge.ndjson', 'sessions.ndjson'] as const
91
92export const CAVEAT = "Nothing in this export is tax advice; what is claimed is the CPA's decision."
93export const SPEND_LABEL = 'ESTIMATE list price, not an invoice'
94const BUCKET_MEANING = 'or the bucket name (__unmapped__, __ignored__, __non_rd__) when the row is not claimable'
95const ROUNDING_RULE = 'Integer seconds throughout; hours are seconds / 3600, rounded once at display to two decimals; every table sums to its total in seconds. Dollar estimates are unrounded sums, printed to six decimals per turn and four per month.'
96
97/** CONTRACT (W3). Every field of the record types with its meaning; a test asserts every key of SessionRecord, EngagementEvent and TurnRecord appears. */
98export const DICTIONARY: readonly { file: string; field: string; meaning: string }[] = [
99  // sessions.ndjson: one session segment per line, as the store holds it (SPEC §3.1)
100  ...([
101    ['v', 'Store format version (1).'],
102    ['sessionId', 'The Claude Code session id (a UUID).'],
103    ['seg', 'Segment id: the open time in base-36 milliseconds; a new segment per process open and per local month. The row key of an event or turn is sessionId, seg and seq together.'],
104    ['openedAt', 'When this process opened the segment, ms since the epoch (UTC).'],
105    ['closedAt', 'When the segment was closed, ms since the epoch; null while open.'],
106    ['closeReason', 'Why it closed (clear, logout, exit and similar); null while open.'],
107    ['source', 'How the session started: startup, resume, clear, fork or unknown.'],
108    ['entrypoint', 'The launcher word the engine reported (cli, claude-desktop, sdk-*); null when unset.'],
109    ['interactive', 'True when the session had a person at a terminal or the desktop app.'],
110    ['surface', 'The surface the session ran on (terminal, desktop, mobile and similar); null when unknown.'],
111    ['repoKey', 'The basename of the repository or folder the session worked in (~ for the home folder, root for /).'],
112    ['repoKind', 'repo when the folder is a git repository, dir otherwise.'],
113    ['rootHash', 'First 12 hex of a salted sha256 of the root path, telling apart folders with the same basename; null when unknown.'],
114    ['cwdBase', 'Basename of the working folder at open.'],
115    ['branch', 'The git branch at the latest change; null when detached or not a repository.'],
116    ['branchChanges', 'Each branch change: ts (ms) and branch.'],
117    ['model', 'The model id at the latest change.'],
118    ['cacheTtlChanges', 'Each prompt-cache TTL change: ts (ms) and ttl (5m or 1h); prices cache writes for later turns.'],
119    ['nextSeq', 'The next row sequence number the writer would use.'],
120    ['events', 'Prompt submissions with their admission verdicts (fields below).'],
121    ['turns', 'Model turns with their token counts (fields below).'],
122    ['eventsDropped', 'Events not stored because the row cap was reached.'],
123    ['turnsDropped', 'Turns not stored because the row cap was reached.'],
124    ['malformedRows', 'Rows a decode dropped because their shape was wrong.'],
125    ['redactions', 'Fields the boundary scan redacted while this segment was written.'],
126    ['engineCostAtOpen', "The engine's cumulative cost figure (USD) when the segment opened; null when unknown."],
127    ['engineCostUsd', "The engine's latest cumulative cost figure (USD); this segment's own cost is the difference."],
128    ['extra', 'Not a key in the file: top-level keys this build does not know, kept as written (credentials and home paths redacted).'],
129    ['updatedAt', 'When the segment file was last written, ms since the epoch.'],
130    ['branchChanges[].ts', 'When the branch changed, ms since the epoch.'],
131    ['branchChanges[].branch', 'The branch from then on; null when detached.'],
132    ['cacheTtlChanges[].ts', 'When the cache TTL changed, ms since the epoch.'],
133    ['cacheTtlChanges[].ttl', 'The cache TTL from then on: 5m or 1h.'],
134    ['events[].seq', 'Sequence number, unique within the segment.'],
135    ['events[].ts', 'When the prompt was submitted (Enter), ms since the epoch.'],
136    ['events[].origin', 'Where the prompt came from as the engine stamped it (composer, bridge, sdk, task-notification and others).'],
137    ['events[].admit', '1 when counted as human engagement (SPEC §4 admission table), 0 when not.'],
138    ['events[].reason', 'Why: human, human-desktop-host, sdk-nested, automation-entrypoint, entrypoint-unknown or origin:<kind>.'],
139    ['events[].queued', '1 when typed while a turn was running (recorded at the moment it was queued).'],
140    ['turns[].seq', 'Sequence number, unique within the segment.'],
141    ['turns[].ts', 'When the turn started, ms since the epoch.'],
142    ['turns[].endTs', 'When the turn finished, ms since the epoch.'],
143    ['turns[].turnId', 'The engine turn id.'],
144    ['turns[].agentKey', 'An 8-hex fingerprint of the subagent id; empty on the main loop.'],
145    ['turns[].agent', '1 for a subagent turn, 0 for the main loop.'],
146    ['turns[].model', 'The model id that answered; null when unknown.'],
147    ['turns[].in', 'Input tokens.'],
148    ['turns[].out', 'Output tokens.'],
149    ['turns[].cr', 'Cache-read input tokens.'],
150    ['turns[].cw', 'Cache-creation (cache write) input tokens.'],
151    ['turns[].reason', 'How the turn ended: answer, aborted, refusal or error.'],
152  ] as const).map(([field, meaning]) => ({ file: 'sessions.ndjson', field, meaning })),
153  ...([
154    ['date', 'Local calendar date of the event in the export zone.'],
155    ['ts', 'When the prompt was submitted, ISO 8601 with the zone offset.'],
156    ['sessionId', 'The session id.'],
157    ['seg', 'The segment id.'],
158    ['seq', 'Sequence number within the segment.'],
159    ['repoKey', `The repository basename, ${BUCKET_MEANING}.`],
160    ['rootHash', `The salted root hash, ${BUCKET_MEANING}.`],
161    ['branch', `The git branch at that moment (empty when not recorded), ${BUCKET_MEANING}.`],
162    ['origin', 'Where the prompt came from as the engine stamped it.'],
163    ['admit', '1 when counted as human engagement, 0 when not.'],
164    ['reason', 'The admission reason (SPEC §4).'],
165    ['queued', '1 when typed while a turn was running.'],
166  ] as const).map(([field, meaning]) => ({ file: 'events.csv', field, meaning })),
167  ...([
168    ['date', 'Local calendar date of the block (blocks never cross local midnight).'],
169    ['start', 'First admitted event of the block, ISO 8601 with the zone offset.'],
170    ['end', 'Last admitted event, or local midnight for a block split at midnight.'],
171    ['seconds', 'Engagement seconds credited to the block (a single-event block gets the floor once).'],
172    ['eventCount', 'Admitted events in the block.'],
173    ['method', 'The method string the blocks were built with (union-v2/<hash of the parameters and zone>).'],
174    ['flags', 'over-4h when the chain before the split exceeded the 4-hour cap; flags are never claimed automatically.'],
175  ] as const).map(([field, meaning]) => ({ file: 'blocks.csv', field, meaning })),
176  ...([
177    ['date', 'Local calendar date.'],
178    ['blockStart', 'Start of the block the seconds came from, ISO 8601 with the zone offset.'],
179    ['sessionId', 'The session whose events earned the seconds.'],
180    ['component', `The R&D component id, ${BUCKET_MEANING}.`],
181    ['entity', 'The entity id the component belongs to.'],
182    ['seconds', 'Seconds of the block apportioned to this session by event count.'],
183    ['activity', 'direct_research, direct_support or non_qualified: the confirmed value, else the pre-tag.'],
184    ['poe', 'true when the hours are marked process-of-experimentation, else false.'],
185    ['status', 'confirmed (matches the latest confirmed review), provisional (the week has no confirmed review) or changed (differs from the confirmed review).'],
186    ['rev', 'The confirmed review revision the status compares against; empty when none.'],
187  ] as const).map(([field, meaning]) => ({ file: 'allocations.csv', field, meaning })),
188  ...([
189    ['week', 'ISO week (YYYY-Www) of the dates summed; only dates inside the export scope are summed.'],
190    ['component', `The R&D component id, ${BUCKET_MEANING}.`],
191    ['entity', 'The entity id.'],
192    ['confirmedSeconds', 'Seconds frozen in the latest confirmed review for these rows; empty when the week is not confirmed.'],
193    ['liveSeconds', 'Seconds as derived from the store today.'],
194    ['activity', 'The activity of these rows (confirmed, else pre-tag).'],
195    ['poe', 'true when marked process-of-experimentation.'],
196    ['status', 'confirmed, provisional (no confirmed review for the week) or changed (live differs from frozen; frozen rows missing live are listed with liveSeconds 0).'],
197    ['rev', 'The confirmed review revision; empty when none.'],
198    ['confirmedLagDays', 'Days between the end of the week and its confirmation; empty when none.'],
199  ] as const).map(([field, meaning]) => ({ file: 'hours_by_component.csv', field, meaning })),
200  ...([
201    ['week', 'ISO week of the note.'],
202    ['component', 'The component the note is about.'],
203    ['note', 'What was done, what was uncertain, what was tried, as the person typed it (at most 300 characters; credentials redacted).'],
204  ] as const).map(([field, meaning]) => ({ file: 'activity_notes.csv', field, meaning })),
205  ...([
206    ['ts', 'When the turn started, ISO 8601 with the zone offset.'],
207    ['endTs', 'When the turn finished, ISO 8601 with the zone offset.'],
208    ['sessionId', 'The session id.'],
209    ['seg', 'The segment id.'],
210    ['seq', 'Sequence number within the segment.'],
211    ['agent', '1 for a subagent turn, 0 for the main loop.'],
212    ['model', 'The model id that answered.'],
213    ['input', 'Input tokens (turns[].in).'],
214    ['output', 'Output tokens (turns[].out).'],
215    ['cacheRead', 'Cache-read input tokens (turns[].cr).'],
216    ['cacheWrite', 'Cache-write input tokens (turns[].cw).'],
217    ['ttl', 'The cache TTL assumed for the cache writes: the latest change point at or before the turn, else the cacheTtl option.'],
218    ['estUsd', `The ${ESTIMATE_LABEL}, from the dated price table in method.json; empty when unpriced.`],
219    ['priced', 'false when the model is not in the price table (tokens kept, no estimate).'],
220  ] as const).map(([field, meaning]) => ({ file: 'turns.csv', field, meaning })),
221  ...([
222    ['month', 'Local month (YYYY-MM).'],
223    ['model', 'The model id.'],
224    ['component', `The R&D component id, ${BUCKET_MEANING}.`],
225    ['entity', 'The entity id.'],
226    ['turns', 'Turns counted.'],
227    ['tokens', 'Input, output, cache-read and cache-write tokens summed.'],
228    ['estUsd', `The ${ESTIMATE_LABEL}; empty when no turn of the row was priced.`],
229    ['label', `Always "${SPEND_LABEL}": consumption at list prices, never invoiced spend.`],
230  ] as const).map(([field, meaning]) => ({ file: 'spend_by_month.csv', field, meaning })),
231  ...([
232    ['version', 'The rd-ledger mod version that built the export.'],
233    ['generatedAt', 'When the export was built, ISO 8601 UTC.'],
234    ['scope', 'The period exported: YYYY or YYYY-MM.'],
235    ['zone', 'The frozen time zone every local date and time uses.'],
236    ['method', 'The method string (union-v2/<hash>).'],
237    ['params', 'gapS (largest gap that merges), floorS (single-event floor), maxBlockS (block cap), dayFlagH (day flag hours).'],
238    ['admissionVersion', 'The version of the admission table in force.'],
239    ['prices', 'The price table: [model prefix, input, output, cache read] in USD per million tokens; longer prefixes first, first match wins.'],
240    ['pricesAsOf', 'The date the price table was last checked.'],
241    ['cacheWriteMultiplier', 'Cache writes cost input price times 1.25 under 5m and 2.0 under 1h.'],
242    ['defaultTtl', 'The cacheTtl option: the TTL assumed where a session recorded none.'],
243    ['rounding', 'The rounding rule.'],
244    ['scopeRule', 'Which rows the export includes.'],
245    ['unreadable', 'Store files that could not be read and are not in this export.'],
246  ] as const).map(([field, meaning]) => ({ file: 'method.json', field, meaning })),
247]
248
249export type ExportInput = {
250  scope: { year: string; month: string | null }
251  segments: readonly SessionRecord[]
252  blocks: readonly Block[]
253  allocations: readonly Allocation[]
254  reviews: readonly ReviewFile[]
255  mapping: Mapping
256  mappingText: string
257  bridgeLog: readonly BridgeLogEntry[]
258  zone: string
259  params: UnionParams
260  method: string
261  defaultTtl: '5m' | '1h'
262  version: string
263  now: number
264  personLabel: string
265  /** sha256 of the mod's own union.ts and prices.ts texts, read by the register */
266  sources: { unionSha256: string; pricesSha256: string }
267  unreadable: number
268  /** The admission table version string printed in method.json */
269  admissionVersion: string
270}
271
272export type ExportFile = { name: string; text: string }
273
274/** The two prices.ts functions the export uses; a seam so the file shapes are testable apart from the table. */
275export type Pricing = {
276  priceTurn: (t: TurnRecord, ttl: '5m' | '1h') => { usd: number | null; priced: boolean }
277  spendByMonth: (segments: readonly SessionRecord[], mapping: Mapping, defaultTtl: '5m' | '1h', zone: string) => SpendRow[]
278}
279
280const PRICING: Pricing = { priceTurn, spendByMonth }
281
282/** CONTRACT (W3). Every file of §6.4 except MANIFEST.sha256 (the register adds it from `sha256` over each text), plus the redaction counts. */
283export const buildExport = (input: ExportInput): { files: ExportFile[]; redactions: Record<string, number> } => buildExportWith(input, PRICING)
284
285/** A path under a home folder, at the start of a value or after a separator; it reads `~` in the export. */
286const hasHomePath = (s: string): boolean => /(?:^|[\s"'=:,(])\/(?:Users|home)\/[^/\s"',;]+/.test(s)
287const scrubHomePaths = (s: string): string => s.replace(/(^|[\s"'=:,(])\/(?:Users|home)\/[^/\s"',;]+/g, '$1~')
288const cmp = (a: string | number, b: string | number): number => (a < b ? -1 : a > b ? 1 : 0)
289const finiteCount = (n: number): number => (Number.isFinite(n) && n > 0 ? Math.floor(n) : 0)
290const kindOf = (redacted: string): string => redacted.slice('[redacted:'.length, -1)
291
292/** `buildExport` with an explicit pricing seam (the tests pass a synthetic one). */
293export const buildExportWith = (input: ExportInput, pricing: Pricing): { files: ExportFile[]; redactions: Record<string, number> } => {
294  const { scope, mapping } = input
295  if (!/^\d{4}$/.test(scope.year) || (scope.month !== null && !(isMonth(scope.month) && scope.month.startsWith(`${scope.year}-`))))
296    throw new Error('buildExport: the scope is YYYY, or YYYY-MM inside that year')
297  const scopeLabel = scope.month ?? scope.year
298  const prefix = `${scopeLabel}-`
299  const inScope = (date: string): boolean => date.startsWith(prefix)
300  const clock = localClock(input.zone)
301  const tsInScope = (ts: number): boolean => Number.isFinite(ts) && inScope(clock.date(ts))
302
303  // --- redaction, counted by file and kind -------------------------------------------
304  const tally = new Map<string, Map<string, number>>()
305  const count = (fileName: string, kind: string): void => {
306    const byKind = tally.get(fileName) ?? new Map<string, number>()
307    byKind.set(kind, (byKind.get(kind) ?? 0) + 1)
308    tally.set(fileName, byKind)
309  }
310  /** One value as it may leave the Mac: scanned (whole value redacted on a hit), one line, home paths read `~`. */
311  const clean = (fileName: string, value: string | number | boolean | null | undefined, cap = 200): string => {
312    if (value === null || value === undefined) return ''
313    const out = cleanField(String(value), cap)
314    if (out.value === null) return ''
315    if (out.redacted) {
316      count(fileName, kindOf(out.value))
317      return out.value
318    }
319    if (!hasHomePath(out.value)) return out.value
320    count(fileName, 'home-path')
321    return scrubHomePaths(out.value)
322  }
323  /** The same rule over every string (and key) of a JSON value; never truncates and keeps line breaks out. */
324  const deep = (fileName: string, value: unknown): unknown => {
325    if (typeof value === 'string') {
326      // Scan the joined form: an invisible character inside a token must not hide it (verifier V2).
327      const reason = scanJoined(value)
328      if (reason !== null) {
329        count(fileName, reason)
330        return `[redacted:${reason}]`
331      }
332      if (!hasHomePath(value)) return value
333      count(fileName, 'home-path')
334      return scrubHomePaths(value)
335    }
336    if (Array.isArray(value)) return value.map(v => deep(fileName, v))
337    if (value !== null && typeof value === 'object') {
338      // fromEntries defines own properties, so a `__proto__` key stays an ordinary key.
339      return Object.fromEntries(Object.keys(value).map((k, i) => {
340        const key = deep(fileName, k) as string
341        return [isRedacted(key) ? `${key}#${i}` : key, deep(fileName, (value as Record<string, unknown>)[k])]
342      }))
343    }
344    return value
345  }
346  const csv = (fileName: keyof typeof CSV_COLUMNS, rows: readonly (readonly (string | number | boolean | null | undefined)[])[], caps: Partial<Record<string, number>> = {}): ExportFile => {
347    const header = CSV_COLUMNS[fileName]
348    const lines = [csvLine(header)]
349    for (const row of rows) lines.push(csvLine(row.map((v, i) => clean(fileName, v, caps[header[i] as string] ?? 200))))
350    return { name: fileName, text: `${lines.join('\n')}\n` }
351  }
352
353  // --- lookups ----------------------------------------------------------------------------
354  const segKey = (sessionId: string, seg: string): string => `${sessionId}|${seg}`
355  const segments = new Map<string, SessionRecord>()
356  for (const s of input.segments) segments.set(segKey(s.sessionId, s.seg), s)
357  const reviewsByWeek = new Map<string, ReviewFile>()
358  for (const r of input.reviews) if (isWeek(r.week)) reviewsByWeek.set(r.week, r)
359  const confirmedOf = (week: string): ReviewRevision | null => {
360    const r = reviewsByWeek.get(week)
361    return r ? latestConfirmed(r) : null
362  }
363  const liveByKey = new Map<string, number>()
364  for (const a of input.allocations) liveByKey.set(`${a.component}|${a.date}`, (liveByKey.get(`${a.component}|${a.date}`) ?? 0) + finiteCount(a.seconds))
365  const allocsBySeg = new Map<string, Allocation[]>()
366  for (const a of input.allocations) {
367    const list = allocsBySeg.get(segKey(a.sessionId, a.seg)) ?? []
368    list.push(a)
369    allocsBySeg.set(segKey(a.sessionId, a.seg), list)
370  }
371
372  /**
373   * The branch at `ts`: the last change at or before it; the segment's branch
374   * when it never changed; empty (null) before the first change, because the
375   * store records the branch at open only until the first change replaces it.
376   */
377  const branchAt = (s: SessionRecord, ts: number): string | null => {
378    if (s.branchChanges.length === 0) return s.branch
379    let found: string | null | undefined
380    for (const c of s.branchChanges) if (c.ts <= ts) found = c.branch
381    return found === undefined ? null : found
382  }
383  /** Branches seen on `date` by the segment's admitted events (the pre-tag's input), one pass per segment. */
384  const branchMemo = new Map<string, Map<string, Set<string>>>()
385  const branchesOn = (s: SessionRecord, date: string): string[] => {
386    const key = segKey(s.sessionId, s.seg)
387    let byDate = branchMemo.get(key)
388    if (!byDate) {
389      byDate = new Map<string, Set<string>>()
390      for (const ev of s.events) {
391        if (ev.admit !== 1) continue
392        const b = branchAt(s, ev.ts)
393        if (b === null) continue
394        const d = clock.date(ev.ts)
395        byDate.set(d, (byDate.get(d) ?? new Set<string>()).add(b))
396      }
397      branchMemo.set(key, byDate)
398    }
399    const seen = byDate.get(date)
400    return seen ? [...seen] : s.branch === null ? [] : [s.branch]
401  }
402  /** Activity and POE of an allocation row: the confirmed review's, else the pre-tag (buckets are never claimable). */
403  const tagOf = (a: Allocation): { activity: Activity; poe: boolean } => {
404    const confirmed = confirmedOf(isoWeekOfDate(a.date))
405    const key = `${a.component}|${a.date}`
406    if (confirmed && Object.prototype.hasOwnProperty.call(confirmed.rows, key)) {
407      const frozen = confirmed.rows[key] as ReviewRevision['rows'][string]
408      return { activity: frozen.activity, poe: frozen.poe }
409    }
410    if (BUCKETS.has(a.component)) return { activity: 'non_qualified', poe: false }
411    const s = segments.get(segKey(a.sessionId, a.seg))
412    return pretag(mapping, a.component, s?.repoKey ?? '', s ? branchesOn(s, a.date) : [])
413  }
414  const claimable = (a: Allocation): boolean => !BUCKETS.has(a.component) && tagOf(a).activity !== 'non_qualified'
415  /** null when the segment's rows may name its repository; otherwise the bucket they show instead (PRV-4). */
416  const bucketOf = (s: SessionRecord): string | null => {
417    const allocs = allocsBySeg.get(segKey(s.sessionId, s.seg)) ?? []
418    if (allocs.length > 0) {
419      if (allocs.some(claimable)) return null
420      return allocs.find(a => BUCKETS.has(a.component))?.component ?? NON_RD
421    }
422    // No admitted seconds: resolve the repository the way allocation would.
423    if (s.repoKey === null) return UNMAPPED
424    const res = resolveRepo(mapping, s.repoKey, s.rootHash)
425    if (res.kind === 'unmapped') return UNMAPPED
426    if (res.kind === 'ignored') return IGNORED
427    if (res.kind === 'nonRd') return NON_RD
428    return pretag(mapping, res.component, s.repoKey, s.branch === null ? [] : [s.branch]).activity === 'non_qualified' ? NON_RD : null
429  }
430  const statusText = (status: string): string => (status === 'pretag' ? 'provisional' : status)
431
432  // --- events.csv ---------------------------------------------------------------------------
433  const eventRows: { key: [number, string, string, number]; cells: (string | number)[] }[] = []
434  for (const s of input.segments) {
435    const bucket = bucketOf(s)
436    for (const ev of s.events) {
437      if (!tsInScope(ev.ts)) continue
438      const where = bucket === null ? [s.repoKey ?? '', s.rootHash ?? '', branchAt(s, ev.ts) ?? ''] : [bucket, bucket, bucket]
439      eventRows.push({ key: [ev.ts, s.sessionId, s.seg, ev.seq], cells: [clock.date(ev.ts), clock.iso(ev.ts), s.sessionId, s.seg, ev.seq, ...where, ev.origin, ev.admit, ev.reason, ev.queued] })
440    }
441  }
442  eventRows.sort((a, b) => cmp(a.key[0], b.key[0]) || cmp(a.key[1], b.key[1]) || cmp(a.key[2], b.key[2]) || cmp(a.key[3], b.key[3]))
443  const eventsFile = csv('events.csv', eventRows.map(r => r.cells), { repoKey: CAP.repoKey, branch: CAP.branch, origin: CAP.origin, reason: CAP.reason })
444
445  // --- blocks.csv ----------------------------------------------------------------------------
446  const blocks = input.blocks.filter(b => isDate(b.date) && inScope(b.date)).slice().sort((a, b) => a.startTs - b.startTs || a.endTs - b.endTs)
447  const blocksFile = csv('blocks.csv', blocks.map(b => [b.date, clock.iso(b.startTs), clock.iso(b.endTs), finiteCount(b.seconds), b.events.length, oneLine(input.method, 40), b.flags.map(f => oneLine(f, 20)).join(';')]))
448
449  // --- allocations.csv ---------------------------------------------------------------------
450  const allocs = input.allocations.filter(a => isDate(a.date) && inScope(a.date)).slice().sort((a, b) => cmp(a.date, b.date) || a.blockStartTs - b.blockStartTs || cmp(a.sessionId, b.sessionId) || cmp(a.seg, b.seg) || cmp(a.component, b.component))
451  const allocRows: (string | number | boolean)[][] = []
452  for (const a of allocs) {
453    const confirmed = confirmedOf(isoWeekOfDate(a.date))
454    const st = rowStatus(confirmed, `${a.component}|${a.date}`, liveByKey.get(`${a.component}|${a.date}`) ?? 0, a.entity)
455    const tag = tagOf(a)
456    allocRows.push([a.date, clock.iso(a.blockStartTs), a.sessionId, a.component, a.entity, finiteCount(a.seconds), tag.activity, tag.poe, statusText(st.status), confirmed ? confirmed.rev : ''])
457  }
458  const allocationsFile = csv('allocations.csv', allocRows)
459
460  // --- hours_by_component.csv: per (week, component, entity, activity, poe) -------------------
461  type HoursRow = { week: string; component: string; entity: string; activity: Activity; poe: boolean; live: number; frozen: number; frozenKeys: Set<string>; changed: boolean }
462  const hours = new Map<string, HoursRow>()
463  const hoursRow = (week: string, component: string, entity: string, activity: Activity, poe: boolean): HoursRow => {
464    const key = [week, component, entity, activity, String(poe)].join('|')
465    const row = hours.get(key) ?? { week, component, entity, activity, poe, live: 0, frozen: 0, frozenKeys: new Set<string>(), changed: false }
466    hours.set(key, row)
467    return row
468  }
469  const weeksWithRows = new Set<string>()
470  for (const a of allocs) {
471    const week = isoWeekOfDate(a.date)
472    weeksWithRows.add(week)
473    const tag = tagOf(a)
474    const row = hoursRow(week, a.component, a.entity, tag.activity, tag.poe)
475    row.live += finiteCount(a.seconds)
476    const confirmed = confirmedOf(week)
477    const rowKey = `${a.component}|${a.date}`
478    const st = rowStatus(confirmed, rowKey, liveByKey.get(rowKey) ?? 0, a.entity)
479    if (st.status === 'changed') row.changed = true
480    if (st.confirmedSeconds !== null && !row.frozenKeys.has(rowKey)) {
481      row.frozenKeys.add(rowKey)
482      row.frozen += st.confirmedSeconds
483    }
484  }
485  // Frozen rows with no live seconds any more are shown, never dropped (DBM-1).
486  for (const week of [...reviewsByWeek.keys()].sort()) {
487    const confirmed = confirmedOf(week)
488    if (!confirmed) continue
489    for (const rowKey of Object.keys(confirmed.rows).sort()) {
490      const [component, date] = rowKey.split('|') as [string, string]
491      if (!isDate(date) || !inScope(date) || liveByKey.has(rowKey)) continue
492      weeksWithRows.add(week)
493      const frozen = confirmed.rows[rowKey] as ReviewRevision['rows'][string]
494      const row = hoursRow(week, component, frozen.entity, frozen.activity, frozen.poe)
495      row.changed = true
496      if (!row.frozenKeys.has(rowKey)) {
497        row.frozenKeys.add(rowKey)
498        row.frozen += finiteCount(frozen.seconds)
499      }
500    }
501  }
502  const hoursRows = [...hours.values()].sort((a, b) => cmp(a.week, b.week) || cmp(a.component, b.component) || cmp(a.entity, b.entity) || cmp(a.activity, b.activity) || cmp(String(a.poe), String(b.poe)))
503  const hoursFile = csv('hours_by_component.csv', hoursRows.map(r => {
504    const confirmed = confirmedOf(r.week)
505    const status = confirmed === null ? 'provisional' : r.changed ? 'changed' : 'confirmed'
506    return [r.week, r.component, r.entity, confirmed === null ? '' : r.frozen, r.live, r.activity, r.poe, status, confirmed ? confirmed.rev : '', confirmed?.confirmedLagDays ?? '']
507  }))
508  const provisionalWeeks = [...weeksWithRows].filter(w => confirmedOf(w) === null).sort()
509  const changedRows = hoursRows.filter(r => confirmedOf(r.week) !== null && r.changed).length
510
511  // --- activity_notes.csv: the confirmed revision's notes, else the latest draft's ----------
512  const noteRows: [string, string, string][] = []
513  for (const [week, review] of [...reviewsByWeek.entries()].sort((a, b) => cmp(a[0], b[0]))) {
514    if (!weekDateList(week).some(inScope)) continue
515    const rev = latestConfirmed(review) ?? latestRevision(review)
516    if (!rev) continue
517    for (const key of Object.keys(rev.notes).sort()) {
518      const [component, noteWeek] = key.split('|') as [string, string | undefined]
519      if (noteWeek !== week) continue
520      noteRows.push([week, component, rev.notes[key] as string])
521    }
522  }
523  const notesFile = csv('activity_notes.csv', noteRows, { note: CAP.note })
524
525  // --- turns.csv -----------------------------------------------------------------------------
526  const turnRows: { key: [number, string, string, number]; cells: (string | number | boolean)[] }[] = []
527  for (const s of input.segments) {
528    for (const t of s.turns) {
529      if (!tsInScope(t.ts)) continue
530      const ttl = ttlAt(s, t.ts) ?? input.defaultTtl
531      const price = pricing.priceTurn(t, ttl)
532      const usd = price.priced && price.usd !== null && Number.isFinite(price.usd) ? price.usd.toFixed(6) : ''
533      turnRows.push({ key: [t.ts, s.sessionId, s.seg, t.seq], cells: [clock.iso(t.ts), clock.iso(t.endTs), s.sessionId, s.seg, t.seq, t.agent, t.model ?? '', t.in, t.out, t.cr, t.cw, ttl, usd, price.priced] })
534    }
535  }
536  turnRows.sort((a, b) => cmp(a.key[0], b.key[0]) || cmp(a.key[1], b.key[1]) || cmp(a.key[2], b.key[2]) || cmp(a.key[3], b.key[3]))
537  const turnsFile = csv('turns.csv', turnRows.map(r => r.cells), { model: CAP.model })
538
539  // --- spend_by_month.csv --------------------------------------------------------------------
540  const spend = pricing
541    .spendByMonth(input.segments, mapping, input.defaultTtl, input.zone)
542    .filter(r => isMonth(r.month) && (scope.month === null ? r.month.startsWith(`${scope.year}-`) : r.month === scope.month))
543    .slice()
544    .sort((a, b) => cmp(a.month, b.month) || cmp(a.model, b.model) || cmp(a.component, b.component) || cmp(a.entity, b.entity))
545  const spendFile = csv('spend_by_month.csv', spend.map(r => [r.month, r.model, r.component, r.entity, r.turns, finiteCount(r.in) + finiteCount(r.out) + finiteCount(r.cr) + finiteCount(r.cw), r.usd !== null && Number.isFinite(r.usd) ? r.usd.toFixed(4) : '', SPEND_LABEL]), { model: CAP.model })
546
547  // --- monthly_log.txt -------------------------------------------------------------------------
548  const months = [...new Set([...blocks.map(b => b.date.slice(0, 7)), ...allocs.map(a => a.date.slice(0, 7))])].sort()
549  const logText = (months.length > 0 ? months : [scope.month ?? `${scope.year}-01`]).map(m => monthlyLogPages(m, blocks, allocs, input.zone, input.method)).join('')
550  const logFile: ExportFile = { name: 'monthly_log.txt', text: logText }
551
552  // --- JSON copies ------------------------------------------------------------------------------
553  const reviewsFile: ExportFile = { name: 'reviews.json', text: `${JSON.stringify(deep('reviews.json', input.reviews), null, 2)}\n` }
554  const componentsFile: ExportFile = { name: 'components.json', text: componentsText(input.mappingText, v => deep('components.json', v), r => count('components.json', r)) }
555  const bridgeFile: ExportFile = { name: 'bridge.ndjson', text: input.bridgeLog.map(entry => `${JSON.stringify(deep('bridge.ndjson', entry))}\n`).join('') }
556
557  // --- sessions.ndjson: decoded, re-encoded, rows outside the scope left out ---------------------
558  // A non-claimable segment carries its bucket in place of the repository's
559  // name, root hash, folder and branches, as its CSV rows do (PRV-4): the
560  // segment copy must not undo the masking.
561  let unencodable = 0
562  const sessionLines: { id: string; seg: string; line: string }[] = []
563  for (const s of input.segments) {
564    const opensIn = tsInScope(s.openedAt)
565    const events = s.events.filter(ev => tsInScope(ev.ts))
566    const turns = s.turns.filter(t => tsInScope(t.ts))
567    if (!opensIn && events.length === 0 && turns.length === 0) continue
568    const decoded = decodeSession(encodeSession({ ...s, events, turns }))
569    if (!decoded) {
570      unencodable += 1
571      continue
572    }
573    const bucket = bucketOf(s)
574    const kept = bucket === null ? (['closeReason', 'entrypoint', 'surface', 'repoKey', 'cwdBase', 'branch', 'model'] as const) : (['closeReason', 'entrypoint', 'surface', 'model'] as const)
575    for (const field of kept) {
576      const after = decoded[field]
577      if (typeof after === 'string' && isRedacted(after) && after !== s[field]) count('sessions.ndjson', kindOf(after))
578    }
579    if (bucket === null)
580      decoded.branchChanges.forEach((c, i) => {
581        if (c.branch !== null && isRedacted(c.branch) && c.branch !== s.branchChanges[i]?.branch) count('sessions.ndjson', kindOf(c.branch))
582      })
583    decoded.turns.forEach((t, i) => {
584      if (t.model !== null && isRedacted(t.model) && t.model !== turns[i]?.model) count('sessions.ndjson', kindOf(t.model))
585    })
586    const extra = deep('sessions.ndjson', decoded.extra) as Record<string, unknown>
587    const masked: SessionRecord =
588      bucket === null ? { ...decoded, extra } : { ...decoded, extra, repoKey: bucket, rootHash: null, cwdBase: bucket, branch: bucket, branchChanges: decoded.branchChanges.map(c => ({ ts: c.ts, branch: bucket })) }
589    sessionLines.push({ id: decoded.sessionId, seg: decoded.seg, line: encodeSession(masked) })
590  }
591  sessionLines.sort((a, b) => cmp(a.id, b.id) || cmp(a.seg, b.seg))
592  const sessionsFile: ExportFile = { name: 'sessions.ndjson', text: sessionLines.map(l => `${l.line}\n`).join('') }
593
594  // --- method.json --------------------------------------------------------------------------------
595  const scopeRule = `A row is in the export when its local date in ${oneLine(input.zone, 64)} falls in ${scopeLabel}; a segment crossing the boundary contributes only its rows inside the period; the bridge log is the scope year's.`
596  const methodFile: ExportFile = {
597    name: 'method.json',
598    text: `${JSON.stringify(
599      {
600        version: clean('method.json', input.version, 40),
601        generatedAt: utcIso(input.now),
602        scope: scopeLabel,
603        zone: oneLine(input.zone, 64),
604        method: oneLine(input.method, 40),
605        params: { gapS: input.params.gapS, floorS: input.params.floorS, maxBlockS: input.params.maxBlockS, dayFlagH: input.params.dayFlagH },
606        admissionVersion: clean('method.json', input.admissionVersion, 40),
607        prices: PRICES,
608        pricesAsOf: PRICES_AS_OF,
609        cacheWriteMultiplier: CACHE_WRITE_MULTIPLIER,
610        defaultTtl: input.defaultTtl,
611        rounding: ROUNDING_RULE,
612        scopeRule,
613        unreadable: finiteCount(input.unreadable),
614      },
615      null,
616      2,
617    )}\n`,
618  }
619
620  // --- README.md (last, so it can carry every other file's redaction counts) ------------------------
621  const person = clean('README.md', input.personLabel, CAP.name)
622  const sha = (s: string): string => (/^[0-9a-f]{64}$/.test(s) ? s : 'unknown')
623  const md = (s: string): string => s.replace(/\|/g, '\\|')
624  const readme: string[] = [CAVEAT, '']
625  if (provisionalWeeks.length > 0)
626    readme.push(`PROVISIONAL: ${provisionalWeeks.length === 1 ? '1 week' : `${provisionalWeeks.length} weeks`} in scope ${provisionalWeeks.length === 1 ? 'has' : 'have'} no confirmed review (${provisionalWeeks.join(', ')}); those rows read \`provisional\` and may change.`, '')
627  readme.push(
628    `# rd-ledger export ${scopeLabel}`,
629    '',
630    `Person: ${person === '' ? 'not recorded — add on cover sheet' : person}`,
631    `Scope: ${scopeLabel}, by local date in ${oneLine(input.zone, 64)}`,
632    `Generated: ${utcIso(input.now)}`,
633    `Mod version: ${clean('README.md', input.version, 40)}`,
634    `union.ts sha256: ${sha(input.sources.unionSha256)}`,
635    `prices.ts sha256: ${sha(input.sources.pricesSha256)}`,
636    `Method: ${oneLine(input.method, 40)}`,
637    `Admission version: ${clean('README.md', input.admissionVersion, 40)}`,
638    `Every dollar figure is a ${ESTIMATE_LABEL} (price table as of ${PRICES_AS_OF}); it excludes the engine's side calls (compaction, titles).`,
639  )
640  if (changedRows > 0) readme.push(`${changedRows} hours_by_component rows changed since their week was confirmed; the frozen seconds are in confirmedSeconds.`)
641  if (finiteCount(input.unreadable) > 0) readme.push(`${finiteCount(input.unreadable)} store files could not be read and are not in this export.`)
642  if (unencodable > 0) readme.push(`${unencodable} segments could not be re-encoded and are not in sessions.ndjson.`)
643  readme.push('', '## Files', '', ...EXPORT_FILES.map(n => `- ${n}`), '- MANIFEST.sha256 (sha256 of every file above; check with `shasum -a 256 -c MANIFEST.sha256`)')
644  readme.push('', '## Data dictionary', '', '| File | Field | Meaning |', '|---|---|---|', ...DICTIONARY.map(d => `| ${d.file} | ${md(d.field)} | ${md(d.meaning)} |`))
645  readme.push('', '## Engagement model and admission (SPEC §4 and §4.1, verbatim)', '', ENGAGEMENT_MODEL_TEXT)
646  readme.push('', '## Retention (SPEC §3.6, verbatim)', '', RETENTION_TEXT)
647  // The README's own redactions (the person label) are counted before the table is drawn.
648  const redactionLines = [...tally.entries()].sort((a, b) => cmp(a[0], b[0])).flatMap(([name, byKind]) => [...byKind.entries()].sort((a, b) => cmp(a[0], b[0])).map(([kind, n]) => `| ${name} | ${kind} | ${n} |`))
649  readme.push('', '## Redactions', '')
650  if (redactionLines.length === 0) readme.push('No value was redacted.')
651  else readme.push('Values that matched a credential, an email address or a home-folder path were replaced in place:', '', '| File | Kind | Count |', '|---|---|---|', ...redactionLines)
652  const readmeFile: ExportFile = { name: 'README.md', text: `${readme.join('\n')}\n` }
653
654  const files = [readmeFile, methodFile, eventsFile, blocksFile, allocationsFile, hoursFile, notesFile, turnsFile, spendFile, logFile, reviewsFile, componentsFile, bridgeFile, sessionsFile]
655  const redactions: Record<string, number> = {}
656  for (const name of EXPORT_FILES) redactions[name] = [...(tally.get(name)?.values() ?? [])].reduce((s, n) => s + n, 0)
657  return { files, redactions }
658}
659
660/**
661 * components.json: the mapping file as written when nothing in it needs
662 * redacting; otherwise the parsed mapping re-serialized with the redactions in
663 * place. Text that does not parse is copied only when it scans clean.
664 */
665const componentsText = (mappingText: string, redact: (v: unknown) => unknown, onRaw: (reason: string) => void): string => {
666  let parsed: unknown
667  try {
668    parsed = JSON.parse(mappingText)
669  } catch {
670    // Not JSON: copied only when the joined form scans clean (verifier N2); otherwise one redaction marker stands for the file.
671    const reason = scanJoined(mappingText)
672    if (reason === null && !hasHomePath(mappingText)) return mappingText
673    onRaw(reason ?? 'home-path')
674    return `${JSON.stringify(`[redacted:${reason ?? 'home-path'}]`)}\n`
675  }
676  const redacted = withoutNonClaimableRepos(redact(parsed))
677  return JSON.stringify(redacted) === JSON.stringify(parsed) ? mappingText : `${JSON.stringify(redacted, null, 2)}\n`
678}
679
680/**
681 * PRV-4 (lead ruling 2026-10-12): the CPA copy of the mapping keeps the repo
682 * keys that map to a component and drops the `ignore` and `nonRd` entries
683 * (personal and non-R&D directory names have no place in the bundle), leaving
684 * their counts under `_omitted`. Pre-tag rules that name an omitted repo go too.
685 */
686const withoutNonClaimableRepos = (parsed: unknown): unknown => {
687  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return parsed
688  const m = parsed as Record<string, unknown>
689  const repos = m['repos']
690  if (typeof repos !== 'object' || repos === null || Array.isArray(repos)) return parsed
691  const kept: Record<string, unknown> = {}
692  const omittedKeys = new Set<string>()
693  let ignored = 0
694  let nonRd = 0
695  for (const key of Object.keys(repos as Record<string, unknown>)) {
696    const rule = (repos as Record<string, unknown>)[key]
697    const isObj = typeof rule === 'object' && rule !== null
698    if (isObj && (rule as Record<string, unknown>)['ignore'] === true) {
699      ignored += 1
700      omittedKeys.add(key.split('#')[0] as string)
701    } else if (isObj && (rule as Record<string, unknown>)['nonRd'] === true) {
702      nonRd += 1
703      omittedKeys.add(key.split('#')[0] as string)
704    } else kept[key] = rule
705  }
706  if (ignored + nonRd === 0) return parsed
707  const pretag = Array.isArray(m['pretag']) ? m['pretag'].filter(r => !(typeof r === 'object' && r !== null && omittedKeys.has(String((r as Record<string, unknown>)['repo']?.toString().split('#')[0])))) : m['pretag']
708  return { ...m, repos: kept, pretag, _omitted: { ignored, nonRd, note: 'ignored and non-R&D repository entries are kept in the local store only' } }
709}
710
711/** CONTRACT (W3). `<sha256>  <name>` lines, sorted by name. */
712export const manifestText = (files: readonly { name: string; sha256: string }[]): string =>
713  files
714    .slice()
715    .sort((a, b) => cmp(a.name, b.name))
716    .map(f => `${f.sha256}  ${f.name}\n`)
717    .join('')
718
hooks/hash.ts 71 lines
1// rd-ledger: SHA-256 in plain TypeScript (FIPS 180-4), because the plugin runtime
2// promises no WebCrypto and the method hash must equal rd-tracker's exactly.
3// Pure. Used for the method string, the salted root hash, the digests and the
4// export manifest; never for secrets.
5
6const K = new Uint32Array([
7  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
8  0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
9  0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
10  0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
11])
12
13const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n))
14
15/** SHA-256 of `bytes`, as 32 bytes. */
16export const sha256Bytes = (bytes: Uint8Array): Uint8Array => {
17  const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
18  const bitLen = bytes.length * 8
19  const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
20  padded.set(bytes)
21  padded[bytes.length] = 0x80
22  const view = new DataView(padded.buffer)
23  view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false)
24  view.setUint32(padded.length - 4, bitLen >>> 0, false)
25  const w = new Uint32Array(64)
26  for (let off = 0; off < padded.length; off += 64) {
27    for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(off + i * 4, false)
28    for (let i = 16; i < 64; i += 1) {
29      const w15 = w[i - 15] as number
30      const w2 = w[i - 2] as number
31      const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3)
32      const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10)
33      w[i] = ((w[i - 16] as number) + s0 + (w[i - 7] as number) + s1) >>> 0
34    }
35    let [a, b, c, d, e, f, g, hh] = h as unknown as [number, number, number, number, number, number, number, number]
36    for (let i = 0; i < 64; i += 1) {
37      const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
38      const ch = (e & f) ^ (~e & g)
39      const t1 = (hh + S1 + ch + (K[i] as number) + (w[i] as number)) >>> 0
40      const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
41      const maj = (a & b) ^ (a & c) ^ (b & c)
42      const t2 = (S0 + maj) >>> 0
43      hh = g
44      g = f
45      f = e
46      e = (d + t1) >>> 0
47      d = c
48      c = b
49      b = a
50      a = (t1 + t2) >>> 0
51    }
52    h[0] = ((h[0] as number) + a) >>> 0
53    h[1] = ((h[1] as number) + b) >>> 0
54    h[2] = ((h[2] as number) + c) >>> 0
55    h[3] = ((h[3] as number) + d) >>> 0
56    h[4] = ((h[4] as number) + e) >>> 0
57    h[5] = ((h[5] as number) + f) >>> 0
58    h[6] = ((h[6] as number) + g) >>> 0
59    h[7] = ((h[7] as number) + hh) >>> 0
60  }
61  const out = new Uint8Array(32)
62  const ov = new DataView(out.buffer)
63  for (let i = 0; i < 8; i += 1) ov.setUint32(i * 4, h[i] as number, false)
64  return out
65}
66
67export const toHex = (bytes: Uint8Array): string => [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
68
69/** SHA-256 of the UTF-8 encoding of `text`, as 64 lowercase hex characters. */
70export const sha256 = (text: string): string => toHex(sha256Bytes(new TextEncoder().encode(text)))
71
hooks/mapping.ts 450 lines
1// rd-ledger: the mapping (components.json, SPEC §3.3). The READ side is
2// lead-owned and complete: an empty mapping, resolution, pre-tag, digests. The
3// WRITE side (load with validation, save, queue decisions, import merge,
4// deadlines) is workstream W2's; its signatures below are the contract.
5// Pure: no `$`, no I/O. Tables are null-prototype objects (CYB-8).
6
7import type { Activity, Component, Deadline, Entity, Filing, ImportedRegistry, Mapping, PretagRule, QueueDecision, RepoRule, Resolution } from '../types'
8import { sha256 } from './hash'
9import { CAP } from './store'
10import { bareObject, cleanField, globMatch, hasOwn, isDate, isId, isRedacted, oneLine } from './text'
11
12export const ACTIVITIES: readonly Activity[] = ['direct_research', 'direct_support', 'non_qualified']
13export const FILINGS = ['c-corp', 's-corp', 'schedule-c', 'partnership', 'other'] as const
14export const isActivity = (x: unknown): x is Activity => typeof x === 'string' && (ACTIVITIES as readonly string[]).includes(x)
15
16/** An empty, valid mapping (gen 0). */
17export const emptyMapping = (): Mapping => ({ v: 1, gen: 0, entities: bareObject({}), components: bareObject({}), repos: bareObject({}), pretag: [], extra: {} })
18
19/** The seed written on first run: one `default` entity and the help text (SPEC §3.3). */
20export const seedMapping = (): Mapping => {
21  const m = emptyMapping()
22  m.entities['default'] = { name: 'My business', filing: null }
23  m.extra['_help'] = 'Edit by hand or through /rd review. Top-level keys starting with _ are ignored. Ids are kebab-case; activity is direct_research, direct_support or non_qualified; filing is c-corp, s-corp, schedule-c, partnership or other.'
24  return m
25}
26
27const ruleToResolution = (rule: RepoRule, m: Mapping, via: 'hash' | 'bare'): Resolution | null => {
28  if ('component' in rule) {
29    if (!hasOwn(m.components, rule.component)) return null
30    return { kind: 'component', component: rule.component, entity: (m.components[rule.component] as { entity: string }).entity, via }
31  }
32  if ('ignore' in rule) return { kind: 'ignored', via }
33  if ('nonRd' in rule) return hasOwn(m.entities, rule.entity) ? { kind: 'nonRd', entity: rule.entity, via } : null
34  return null
35}
36
37/**
38 * Where a repository lands: the `key#hash` entry outranks the bare key; a
39 * rule naming an unknown component or entity resolves unmapped (never a guess).
40 */
41export const resolveRepo = (m: Mapping, repoKey: string, rootHash: string | null): Resolution => {
42  if (rootHash && hasOwn(m.repos, `${repoKey}#${rootHash}`)) {
43    const r = ruleToResolution(m.repos[`${repoKey}#${rootHash}`] as RepoRule, m, 'hash')
44    if (r) return r
45  }
46  if (hasOwn(m.repos, repoKey)) {
47    const r = ruleToResolution(m.repos[repoKey] as RepoRule, m, 'bare')
48    if (r) return r
49  }
50  return { kind: 'unmapped' }
51}
52
53/** True when the bare key is mapped but this hash has no entry of its own (DBM-8: a `new location?` row). */
54export const isNewLocation = (m: Mapping, repoKey: string, rootHash: string | null): boolean =>
55  rootHash !== null && hasOwn(m.repos, repoKey) && !hasOwn(m.repos, `${repoKey}#${rootHash}`)
56
57/**
58 * The pre-tag for a (component, repo, day): the first rule whose `repo` equals
59 * the row's repoKey and whose `branch` glob matches any branch seen that day
60 * for that repo (null matches every branch); else the component's defaults.
61 */
62export const pretag = (m: Mapping, component: string, repoKey: string, branches: readonly string[]): { activity: Activity; poe: boolean } => {
63  for (const rule of m.pretag) {
64    if (rule.repo !== repoKey) continue
65    if (rule.branch === null || branches.some(b => globMatch(rule.branch as string, b))) return { activity: rule.activity, poe: rule.poe }
66  }
67  const c = hasOwn(m.components, component) ? m.components[component] : undefined
68  return c ? { activity: c.activity, poe: c.poe } : { activity: 'non_qualified', poe: false }
69}
70
71/** The entity of a component, or null. */
72export const entityOf = (m: Mapping, component: string): string | null => (hasOwn(m.components, component) ? (m.components[component] as { entity: string }).entity : null)
73
74/** sha256 of the mapping file's text as loaded; what a confirmed revision records. */
75export const mappingDigest = (text: string): string => sha256(text)
76
77// ---------------------------------------------------------------------------------
78// W2 — the write side. Implement exactly these exported names and signatures.
79// ---------------------------------------------------------------------------------
80
81const KNOWN_KEYS: readonly string[] = ['v', 'gen', 'entities', 'components', 'repos', 'pretag']
82const DAY_MS = 86_400_000
83/** The longest change description a history line keeps. */
84const CHANGE_CAP = 200
85
86const isRecordObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
87const isFiling = (x: unknown): x is Filing => typeof x === 'string' && (FILINGS as readonly string[]).includes(x)
88/** An own field only: a hand-edited file must not reach `Object.prototype` through a missing key. */
89const own = (x: Record<string, unknown>, key: string): unknown => (hasOwn(x, key) ? x[key] : undefined)
90/** An outside string as a problem line may show it: one clean short line, a token redacted, never raw. */
91const shown = (s: string): string => cleanField(s, 40).value ?? '(empty)'
92
93/** A bare repo key as repoKeyOf and the store mint it: one clean line of at most 100 code points, without `#` or `|`. */
94const isBareKey = (s: string): boolean => s !== '' && !/[#|]/.test(s) && cleanField(s, CAP.repoKey).value === s
95
96/** A `repos` key: a bare key, or `key#<12 hex>` (one location of that repository). */
97const isRepoRuleKey = (s: string): boolean => {
98  const hashed = /^(.+)#[0-9a-f]{12}$/.exec(s)
99  return isBareKey(hashed ? (hashed[1] as string) : s)
100}
101
102/** A pre-tag branch glob: one clean line of at most 100 characters, `*` allowed only as the last one (globMatch's shape). */
103const isBranchGlob = (s: string): boolean => s.length >= 1 && s.length <= 100 && oneLine(s, 100) === s && !s.slice(0, -1).includes('*')
104
105/** Names the fields of a kept entry that a save will not write back, so a hand-added field is never lost silently. */
106const noteUnknownFields = (problems: string[], what: string, x: Record<string, unknown>, allowed: readonly string[]): void => {
107  const unknown = Object.keys(x).filter(k => !allowed.includes(k))
108  if (unknown.length > 0) problems.push(`${what}: field ${unknown.map(shown).join(', ')} is not kept on save`)
109}
110
111/** A cleaned display name; a redaction is kept (the record must not hold the token) but said out loud. */
112const takeName = (problems: string[], what: string, x: unknown): string | null => {
113  const name = cleanField(typeof x === 'string' ? x : null, CAP.name).value
114  if (name !== null && isRedacted(name)) problems.push(`${what}: the name held a credential or an address and was replaced with ${name}`)
115  return name
116}
117
118const decodeEntity = (problems: string[], id: string, x: unknown): Entity | null => {
119  const what = `entity ${shown(id)}`
120  const drop = (why: string): null => (problems.push(`${what} dropped: ${why}`), null)
121  if (!isId(id)) return drop('the id is not lower-case kebab-case')
122  if (!isRecordObject(x)) return drop('not an object')
123  const name = takeName(problems, what, own(x, 'name'))
124  if (name === null) return drop('no name')
125  const filing = own(x, 'filing')
126  if (filing !== undefined && filing !== null && !isFiling(filing)) return drop(`filing must be one of ${FILINGS.join(', ')}`)
127  noteUnknownFields(problems, what, x, ['name', 'filing'])
128  return { name, filing: isFiling(filing) ? filing : null }
129}
130
131const decodeComponent = (problems: string[], id: string, x: unknown): Component | null => {
132  const what = `component ${shown(id)}`
133  const drop = (why: string): null => (problems.push(`${what} dropped: ${why}`), null)
134  if (!isId(id)) return drop('the id is not lower-case kebab-case')
135  if (!isRecordObject(x)) return drop('not an object')
136  const name = takeName(problems, what, own(x, 'name'))
137  if (name === null) return drop('no name')
138  const entity = own(x, 'entity')
139  if (typeof entity !== 'string' || !isId(entity)) return drop('entity is not an id')
140  const activity = own(x, 'activity')
141  if (!isActivity(activity)) return drop(`activity must be one of ${ACTIVITIES.join(', ')}`)
142  const poe = own(x, 'poe')
143  if (typeof poe !== 'boolean') return drop('poe must be true or false')
144  const rdt = own(x, 'rdt')
145  if (rdt !== undefined && rdt !== null && (typeof rdt !== 'string' || !isId(rdt))) return drop('rdt is not an id')
146  noteUnknownFields(problems, what, x, ['name', 'entity', 'activity', 'poe', 'rdt'])
147  return { name, entity, activity, poe, rdt: typeof rdt === 'string' ? rdt : null }
148}
149
150const decodeRule = (problems: string[], key: string, x: unknown): RepoRule | null => {
151  const what = `repos ${shown(key)}`
152  const drop = (why: string): null => (problems.push(`${what} dropped: ${why}`), null)
153  if (!isRepoRuleKey(key)) return drop('the key must be a repository name, or name#<12 hex digits>')
154  if (!isRecordObject(x)) return drop('not an object')
155  const component = own(x, 'component')
156  const ignore = own(x, 'ignore')
157  const nonRd = own(x, 'nonRd')
158  // Exactly one shape: a rule that says two things is a guess, so it is dropped rather than read one way.
159  if (component !== undefined && ignore === undefined && nonRd === undefined) {
160    if (typeof component !== 'string' || !isId(component)) return drop('component is not an id')
161    noteUnknownFields(problems, what, x, ['component'])
162    return { component }
163  }
164  if (ignore === true && component === undefined && nonRd === undefined) {
165    noteUnknownFields(problems, what, x, ['ignore'])
166    return { ignore: true }
167  }
168  if (nonRd === true && component === undefined && ignore === undefined) {
169    const entity = own(x, 'entity')
170    if (typeof entity !== 'string' || !isId(entity)) return drop('entity is not an id')
171    noteUnknownFields(problems, what, x, ['nonRd', 'entity'])
172    return { nonRd: true, entity }
173  }
174  return drop('must be { component }, { ignore: true } or { nonRd: true, entity }')
175}
176
177const decodePretag = (problems: string[], index: number, x: unknown): PretagRule | null => {
178  const what = `pretag rule ${index + 1}`
179  const drop = (why: string): null => (problems.push(`${what} dropped: ${why}`), null)
180  if (!isRecordObject(x)) return drop('not an object')
181  const repo = own(x, 'repo')
182  if (typeof repo !== 'string' || !isBareKey(repo)) return drop('repo must be a repository name')
183  const branch = own(x, 'branch')
184  if (branch !== null && (typeof branch !== 'string' || !isBranchGlob(branch))) return drop('branch must be null, a name, or a prefix ending in one * (at most 100 characters)')
185  const activity = own(x, 'activity')
186  if (!isActivity(activity)) return drop(`activity must be one of ${ACTIVITIES.join(', ')}`)
187  const poe = own(x, 'poe')
188  if (typeof poe !== 'boolean') return drop('poe must be true or false')
189  noteUnknownFields(problems, what, x, ['repo', 'branch', 'activity', 'poe'])
190  return { repo, branch, activity, poe }
191}
192
193/** A JSON value copied deeply (extra values came from JSON.parse or the seed, so the round trip is exact). */
194const jsonCopy = (x: unknown): unknown => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)))
195
196const mapTable = <A, B>(table: Record<string, A>, f: (a: A) => B): Record<string, B> => {
197  const out = bareObject<B>({})
198  for (const key of Object.keys(table)) out[key] = f(table[key] as A)
199  return out
200}
201
202const canonicalRule = (r: RepoRule): RepoRule => ('component' in r ? { component: r.component } : 'ignore' in r ? { ignore: true } : { nonRd: true, entity: r.entity })
203
204/** A deep copy with null-prototype tables: the write side never shares structure with its input. */
205const cloneMapping = (m: Mapping): Mapping => {
206  const extra = bareObject<unknown>({})
207  for (const key of Object.keys(m.extra)) extra[key] = jsonCopy(m.extra[key])
208  return {
209    v: 1,
210    gen: m.gen,
211    entities: mapTable(m.entities, e => ({ name: e.name, filing: e.filing })),
212    components: mapTable(m.components, c => ({ name: c.name, entity: c.entity, activity: c.activity, poe: c.poe, rdt: c.rdt })),
213    repos: mapTable(m.repos, canonicalRule),
214    pretag: m.pretag.map(r => ({ repo: r.repo, branch: r.branch, activity: r.activity, poe: r.poe })),
215    extra,
216  }
217}
218
219/**
220 * CONTRACT (W2). The mapping from the file's text (null when the file does not
221 * exist → the seed). Validates every table: ids by `isId`, names through
222 * `cleanField(…, CAP.name)`, enums closed, `repos` keys `key` or `key#<12 hex>`
223 * with `#` and `|` otherwise forbidden, pre-tag globs ≤ 100 chars; invalid
224 * entries are dropped and named in `problems` (never silently kept); a `v`
225 * other than 1 or unparseable JSON returns the seed with `readOnly: true` (the
226 * file must not be rewritten). Unknown top-level keys go to `extra`; keys
227 * starting with `_` are left in `extra` untouched. Tables come back as
228 * null-prototype objects.
229 *
230 * Beyond the contract, on the side of the person's file: a table that is
231 * present but not an object (or `pretag` not a list) also sets `readOnly`,
232 * since a save would write it back empty. A rule naming an unknown component
233 * or entity, and a component naming an unknown entity, are kept as written
234 * (resolution already treats such a rule as unmapped) and named in `problems`.
235 */
236export const loadMapping = (text: string | null): { mapping: Mapping; problems: string[]; readOnly: boolean } => {
237  if (text === null) return { mapping: seedMapping(), problems: [], readOnly: false }
238  let raw: unknown
239  try {
240    raw = JSON.parse(text)
241  } catch {
242    return { mapping: seedMapping(), problems: ['components.json is not valid JSON; it will not be rewritten'], readOnly: true }
243  }
244  if (!isRecordObject(raw) || own(raw, 'v') !== 1) return { mapping: seedMapping(), problems: ['components.json is not version 1; it will not be rewritten'], readOnly: true }
245  const top = raw
246  const problems: string[] = []
247  let readOnly = false
248  const m = emptyMapping()
249  m.extra = bareObject<unknown>({})
250  const gen = own(top, 'gen')
251  if (typeof gen === 'number' && Number.isSafeInteger(gen) && gen >= 0) m.gen = gen
252  else if (gen !== undefined) problems.push('gen is not a whole number; read as 0')
253  const table = (name: string): Record<string, unknown> => {
254    const t = own(top, name)
255    if (t === undefined) return {}
256    if (isRecordObject(t)) return t
257    problems.push(`${name} is not an object; the file will not be rewritten`)
258    readOnly = true
259    return {}
260  }
261  const entities = table('entities')
262  for (const id of Object.keys(entities)) {
263    const e = decodeEntity(problems, id, entities[id])
264    if (e) m.entities[id] = e
265  }
266  const components = table('components')
267  for (const id of Object.keys(components)) {
268    const c = decodeComponent(problems, id, components[id])
269    if (!c) continue
270    if (!hasOwn(m.entities, c.entity)) problems.push(`component ${shown(id)} names entity ${shown(c.entity)}, which is not in entities`)
271    m.components[id] = c
272  }
273  const repos = table('repos')
274  for (const key of Object.keys(repos)) {
275    const r = decodeRule(problems, key, repos[key])
276    if (!r) continue
277    if ('component' in r && !hasOwn(m.components, r.component)) problems.push(`repos ${shown(key)} names component ${shown(r.component)}, which is not in components; it stays unmapped`)
278    if ('nonRd' in r && !hasOwn(m.entities, r.entity)) problems.push(`repos ${shown(key)} names entity ${shown(r.entity)}, which is not in entities; it stays unmapped`)
279    m.repos[key] = r
280  }
281  const pretag = own(top, 'pretag')
282  if (Array.isArray(pretag)) {
283    for (const [i, x] of pretag.entries()) {
284      const rule = decodePretag(problems, i, x)
285      if (rule) m.pretag.push(rule)
286    }
287  } else if (pretag !== undefined) {
288    problems.push('pretag is not a list; the file will not be rewritten')
289    readOnly = true
290  }
291  for (const key of Object.keys(top)) {
292    if (KNOWN_KEYS.includes(key)) continue
293    if (!key.startsWith('_')) problems.push(`unknown top-level key ${shown(key)} is kept as written and not used`)
294    m.extra[key] = top[key]
295  }
296  return { mapping: m, problems, readOnly }
297}
298
299/**
300 * CONTRACT (W2). The file text to write: `v`, `gen` (already incremented by the caller), the tables in a fixed key order, then `extra`. Two-space JSON.
301 * Every entry is written in its canonical field order and `extra` keys keep their order, so load then save is a fixed
302 * point after the first save. The output object is null-prototype so an `extra` key named `__proto__` is written, not swallowed.
303 * Ends with a newline.
304 */
305export const saveMapping = (m: Mapping): string => {
306  const c = cloneMapping(m)
307  const out = bareObject<unknown>({})
308  out['v'] = 1
309  out['gen'] = Number.isSafeInteger(m.gen) && m.gen >= 0 ? m.gen : 0
310  out['entities'] = c.entities
311  out['components'] = c.components
312  out['repos'] = c.repos
313  out['pretag'] = c.pretag
314  for (const key of Object.keys(c.extra)) if (!KNOWN_KEYS.includes(key)) out[key] = c.extra[key]
315  return `${JSON.stringify(out, null, 2)}\n`
316}
317
318/**
319 * CONTRACT (W2). A new mapping with a queue decision applied under `key`
320 * (`repoKey#hash` when the hash is known, else the bare key); a `component`
321 * decision naming an unknown component or a `nonRd` naming an unknown entity
322 * is refused (`null`). Never mutates. A key the loader would drop is refused
323 * too, so a pick can never write an entry that vanishes on the next load.
324 */
325export const applyQueueDecision = (m: Mapping, key: string, decision: QueueDecision): Mapping | null => {
326  if (typeof key !== 'string' || !isRepoRuleKey(key)) return null
327  let rule: RepoRule
328  if ('component' in decision) {
329    if (typeof decision.component !== 'string' || !hasOwn(m.components, decision.component)) return null
330    rule = { component: decision.component }
331  } else if ('ignore' in decision && decision.ignore === true) {
332    rule = { ignore: true }
333  } else if ('nonRd' in decision && decision.nonRd === true) {
334    if (typeof decision.entity !== 'string' || !hasOwn(m.entities, decision.entity)) return null
335    rule = { nonRd: true, entity: decision.entity }
336  } else {
337    return null
338  }
339  const next = cloneMapping(m)
340  next.repos[key] = rule
341  return next
342}
343
344/**
345 * CONTRACT (W2). Merges an imported registry: entities and components whose
346 * ids are not already present are added (names cleaned, `rdt` = id, activity
347 * `direct_research`, poe false, filing from the import when valid); existing
348 * entries are never changed. Returns the added ids.
349 * Ids that are not kebab-case, and components whose entity is in neither the
350 * mapping nor the import, are skipped (an orphan component would claim hours
351 * for an entity nobody files). The ids come back sorted.
352 */
353export const mergeImport = (m: Mapping, imported: ImportedRegistry): { mapping: Mapping; added: string[] } => {
354  const next = cloneMapping(m)
355  const added: string[] = []
356  const nameOf = (x: Record<string, unknown>, id: string): string => {
357    const name = own(x, 'name')
358    return cleanField(typeof name === 'string' ? name : null, CAP.name).value ?? id
359  }
360  const entities: Record<string, unknown> = isRecordObject(imported?.entities) ? imported.entities : {}
361  for (const id of Object.keys(entities)) {
362    const e = entities[id]
363    if (!isId(id) || hasOwn(next.entities, id) || !isRecordObject(e)) continue
364    const filing = own(e, 'filing')
365    next.entities[id] = { name: nameOf(e, id), filing: isFiling(filing) ? filing : null }
366    added.push(id)
367  }
368  const components: Record<string, unknown> = isRecordObject(imported?.components) ? imported.components : {}
369  for (const id of Object.keys(components)) {
370    const c = components[id]
371    if (!isId(id) || hasOwn(next.components, id) || !isRecordObject(c)) continue
372    const entity = own(c, 'entity')
373    if (typeof entity !== 'string' || !isId(entity) || !hasOwn(next.entities, entity)) continue
374    next.components[id] = { name: nameOf(c, id), entity, activity: 'direct_research', poe: false, rdt: id }
375    added.push(id)
376  }
377  return { mapping: next, added: added.sort() }
378}
379
380const FORMS: Record<Filing, { form: string; due: string; extended: string } | null> = {
381  'c-corp': { form: 'Form 1120', due: '04-15', extended: '10-15' },
382  's-corp': { form: 'Form 1120-S', due: '03-15', extended: '09-15' },
383  'schedule-c': { form: 'Schedule C with Form 1040', due: '04-15', extended: '10-15' },
384  partnership: { form: 'Form 1065', due: '03-15', extended: '09-15' },
385  other: null,
386}
387
388const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
389const pad2 = (n: number): string => String(n).padStart(2, '0')
390const daysInMonth = (y: number, month: number): number => (Date.UTC(y, month, 1) - Date.UTC(y, month - 1, 1)) / DAY_MS
391
392/**
393 * The Monday on or after `date` (the date itself when it is a Monday), by
394 * calendar arithmetic in UTC terms; null for anything that is not a real date
395 * (Date.UTC alone would roll 02-31 into March).
396 */
397const mondayOnOrAfter = (date: string): string | null => {
398  if (!isDate(date)) return null
399  let [y, month, d] = date.split('-').map(Number) as [number, number, number]
400  const dim = daysInMonth(y, month)
401  if (d > dim) return null
402  const day = Date.UTC(y, month - 1, d) / DAY_MS
403  // Day 0 (1970-01-01) was a Thursday: with Monday as 0 that is 3.
404  const weekday = (((day + 3) % 7) + 7) % 7
405  d += (7 - weekday) % 7
406  if (d > dim) {
407    d -= dim
408    month += 1
409    if (month > 12) {
410      month = 1
411      y += 1
412    }
413  }
414  return `${String(y).padStart(4, '0')}-${pad2(month)}-${pad2(d)}`
415}
416
417/**
418 * CONTRACT (W2). The deadline calendar for `workYear` (SPEC §6.1 item 5): per
419 * entity with a filing, the return and extension dates in `workYear + 1`;
420 * plus the next weekly review (the Monday after `today`); sorted by date.
421 * The statutory dates are not moved for weekends or holidays, and every label
422 * says "calendar tax year; verify with your CPA". When `today` is a Monday the
423 * review is that day (the W2 brief's rule). Ties sort by label, then entity id.
424 */
425export const deadlines = (m: Mapping, workYear: number, today: string): Deadline[] => {
426  const out: Deadline[] = []
427  const caveat = '(calendar tax year; verify with your CPA)'
428  if (Number.isSafeInteger(workYear) && workYear >= 1970 && workYear < 9999) {
429    const fileYear = String(workYear + 1)
430    for (const id of Object.keys(m.entities)) {
431      const filing = (m.entities[id] as Entity).filing
432      const f = filing !== null && hasOwn(FORMS, filing) ? FORMS[filing] : null
433      if (!f) continue
434      out.push({ date: `${fileYear}-${f.due}`, label: `${f.form} due for tax year ${workYear} ${caveat}`, entity: id })
435      out.push({ date: `${fileYear}-${f.extended}`, label: `${f.form} extended due date for tax year ${workYear} ${caveat}`, entity: id })
436    }
437  }
438  const review = mondayOnOrAfter(today)
439  if (review !== null) out.push({ date: review, label: 'Next weekly review (Monday)', entity: null })
440  return out.sort((a, b) => cmp(a.date, b.date) || cmp(a.label, b.label) || cmp(a.entity ?? '', b.entity ?? ''))
441}
442
443/**
444 * CONTRACT (W2). One mapping-history line: `{ at, gen, change }` as JSON, `change` a short cleaned description.
445 * No trailing newline (the caller appends one per line). `change` passes the leak scan, so a description
446 * that quotes a token is stored as `[redacted:…]`, never the token.
447 */
448export const historyLine = (at: number, gen: number, change: string): string =>
449  JSON.stringify({ at: Number.isFinite(at) ? at : 0, gen: Number.isSafeInteger(gen) ? gen : 0, change: cleanField(change, CHANGE_CAP).value ?? '' })
450
hooks/report.ts 888 lines
1// rd-ledger: plain-text answers and the monthly log pages (SPEC §6, §6.4).
2// Workstream W3. Pure. Text answers carry counts, hours and validated ids
3// only, never repo keys or branch names (CYB-13): every id, model, zone and
4// week passes a shape check before it is drawn, and anything that fails it is
5// drawn as a fixed placeholder, so a hostile name in the store can neither
6// reach a headless transcript nor add a line to one.
7//
8// The calendar helpers below (local clock, ISO weeks, UTC ISO) are this
9// module's own because tz.ts was built in parallel; they read the zone through
10// Intl and never read the clock: the caller passes every instant.
11
12import type { Allocation, Block, Rollup, SessionRow, SessionSummary, SpendRow } from '../types'
13import { PRICES_AS_OF } from './prices'
14import { IGNORED, NON_RD, UNMAPPED } from './store'
15import { compactInt, hoursText, isDate, isId, isMonth, isWeek, money, oneLine } from './text'
16
17// --- calendar arithmetic (days since 1970-01-01, proleptic Gregorian) ---------------
18
19const DAY_MS = 86_400_000
20const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
21const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const
22const pad = (n: number, width = 2): string => String(n).padStart(width, '0')
23
24/** Year, month and day of a day number (Hinnant's civil_from_days). */
25const civil = (days: number): { y: number; m: number; d: number } => {
26  const z = days + 719_468
27  const era = Math.floor(z / 146_097)
28  const doe = z - era * 146_097
29  const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36_524) - Math.floor(doe / 146_096)) / 365)
30  const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100))
31  const mp = Math.floor((5 * doy + 2) / 153)
32  const d = doy - Math.floor((153 * mp + 2) / 5) + 1
33  const m = mp < 10 ? mp + 3 : mp - 9
34  return { y: yoe + era * 400 + (m <= 2 ? 1 : 0), m, d }
35}
36
37/** The day number of a `YYYY-MM-DD` date (shape-checked by the caller). */
38export const dayNumber = (date: string): number => Math.floor(Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10))) / DAY_MS)
39
40/** The `YYYY-MM-DD` of a day number. */
41export const dateOfDay = (days: number): string => {
42  const c = civil(days)
43  return `${pad(c.y, 4)}-${pad(c.m)}-${pad(c.d)}`
44}
45
46/** Monday = 0 … Sunday = 6 (1970-01-01 was a Thursday). */
47const weekdayIndex = (days: number): number => (((days + 3) % 7) + 7) % 7
48
49/** The ISO week `YYYY-Www` of a calendar date. */
50export const isoWeekOfDate = (date: string): string => {
51  const days = dayNumber(date)
52  const thursday = days - weekdayIndex(days) + 3
53  const year = civil(thursday).y
54  const week = Math.floor((thursday - dayNumber(`${pad(year, 4)}-01-01`)) / 7) + 1
55  return `${pad(year, 4)}-W${pad(week)}`
56}
57
58/** The day number of the Monday that starts an ISO week (shape-checked by the caller). */
59export const weekMondayDay = (week: string): number => {
60  const jan4 = dayNumber(`${week.slice(0, 4)}-01-04`)
61  return jan4 - weekdayIndex(jan4) + (Number(week.slice(6, 8)) - 1) * 7
62}
63
64/** The seven dates of an ISO week, Monday first. */
65export const weekDateList = (week: string): string[] => {
66  const monday = weekMondayDay(week)
67  return Array.from({ length: 7 }, (_, i) => dateOfDay(monday + i))
68}
69
70/** The month before `YYYY-MM`. */
71export const monthBefore = (month: string): string => {
72  const y = Number(month.slice(0, 4))
73  const m = Number(month.slice(5, 7))
74  return m === 1 ? `${pad(y - 1, 4)}-12` : `${pad(y, 4)}-${pad(m - 1)}`
75}
76
77/** `ms` as ISO 8601 in UTC with milliseconds, the shape of toISOString, without constructing a Date. */
78export const utcIso = (ms: number): string => {
79  const t = Math.floor(Number.isFinite(ms) ? ms : 0)
80  const days = Math.floor(t / DAY_MS)
81  const rest = t - days * DAY_MS
82  const hh = Math.floor(rest / 3_600_000)
83  const mi = Math.floor((rest % 3_600_000) / 60_000)
84  const ss = Math.floor((rest % 60_000) / 1000)
85  return `${dateOfDay(days)}T${pad(hh)}:${pad(mi)}:${pad(ss)}.${pad(rest % 1000, 3)}Z`
86}
87
88export type LocalClock = {
89  /** `YYYY-MM-DD` of `ts` in the zone */
90  date: (ts: number) => string
91  /** `HH:MM` of `ts` in the zone */
92  hm: (ts: number) => string
93  /** ISO 8601 with the zone's offset at that instant, seconds precision, e.g. `2026-10-11T09:00:00-04:00` */
94  iso: (ts: number) => string
95}
96
97/**
98 * One Intl formatter for `zone`, reused for every row of an export (building
99 * one per row costs seconds over a year of rows). An unknown zone throws
100 * RangeError here, loudly, before any file is built.
101 */
102export const localClock = (zone: string): LocalClock => {
103  const fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
104  const parts = (ts: number) => {
105    const out = { year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0 }
106    for (const p of fmt.formatToParts(ts)) if (p.type in out) out[p.type as keyof typeof out] = Number(p.value)
107    if (out.hour === 24) out.hour = 0
108    return out
109  }
110  const dateOf = (p: ReturnType<typeof parts>) => `${pad(p.year, 4)}-${pad(p.month)}-${pad(p.day)}`
111  return {
112    date: ts => dateOf(parts(ts)),
113    hm: ts => {
114      const p = parts(ts)
115      return `${pad(p.hour)}:${pad(p.minute)}`
116    },
117    iso: ts => {
118      const whole = Math.floor(ts / 1000) * 1000
119      const p = parts(whole)
120      const offsetMin = Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - whole) / 60_000)
121      const sign = offsetMin < 0 ? '-' : '+'
122      const abs = Math.abs(offsetMin)
123      return `${dateOf(p)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
124    },
125  }
126}
127
128// --- what a text answer may carry (CYB-13) -------------------------------------------
129
130export const BUCKETS: ReadonlySet<string> = new Set([UNMAPPED, IGNORED, NON_RD])
131const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@[\]-]{0,79}$/
132const ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+){0,3}$/
133const METHOD_RE = /^[a-z0-9-]{1,20}\/[0-9a-f]{8}$/
134const REASON_RE = /^(?:origin:)?[a-z0-9-]{1,40}$/
135const FLAG_KINDS: ReadonlySet<string> = new Set(['day-over-cap', 'block-over-4h', 'automation-heavy', 'rows-dropped'])
136
137/** A component or entity id, or a bucket name; anything else is drawn as a placeholder. */
138export const idText = (s: string | null | undefined): string => (typeof s === 'string' && (isId(s) || BUCKETS.has(s)) ? s : '(unrecognized id)')
139/** An entity id; empty (a bucket with no entity) reads `-`. */
140const entityText = (s: string | null | undefined): string => (s === '' ? '-' : idText(s))
141export const modelText = (s: string | null | undefined): string => (typeof s === 'string' && MODEL_RE.test(s) ? s : '(unrecognized model)')
142const zoneText = (s: string): string => (typeof s === 'string' && s.length <= 64 && ZONE_RE.test(s) ? s : '(unrecognized zone)')
143const methodText = (s: string): string => (typeof s === 'string' && METHOD_RE.test(s) ? s : '(unrecognized method)')
144const weekText = (s: string): string => (isWeek(s) ? s : '(unrecognized week)')
145const seconds = (n: number): number => (Number.isFinite(n) && n > 0 ? Math.floor(n) : 0)
146const count = (n: number): number => (Number.isFinite(n) && n > 0 ? Math.floor(n) : 0)
147const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
148
149/** `line` cut to `width` code points, the last one an ellipsis when cut. */
150export const fit = (line: string, width: number): string => {
151  const chars = [...line]
152  return chars.length <= width ? line : `${chars.slice(0, Math.max(0, width - 1)).join('')}…`
153}
154
155/** Buckets after the components: `__non_rd__`, then `__unmapped__`, then `__ignored__`. */
156const bucketRank = (id: string): number => (id === NON_RD ? 1 : id === UNMAPPED ? 2 : id === IGNORED ? 3 : 0)
157const byComponent = (a: string, b: string): number => bucketRank(a) - bucketRank(b) || (a < b ? -1 : a > b ? 1 : 0)
158
159const shortDate = (days: number): string => {
160  const c = civil(days)
161  return `${MONTHS[c.m - 1]} ${c.d}`
162}
163
164const BRIDGE_TEXT: Record<string, string> = {
165  off: 'off',
166  standalone: 'standalone (rd-tracker not found)',
167  unreachable: 'rd-tracker unreachable',
168  corroborate: 'corroborate (rd-tracker reachable)',
169  'source-of-truth': 'source of truth (rd-tracker reachable)',
170}
171
172export const ESTIMATE_LABEL = 'list-price estimate, not an invoice'
173
174/** Spend rows summed by one key, largest estimate first, then by key. */
175const sumBy = (rows: readonly SpendRow[], key: (r: SpendRow) => string): { key: string; turns: number; in: number; out: number; cr: number; cw: number; usd: number; unpriced: number }[] => {
176  const acc = new Map<string, { key: string; turns: number; in: number; out: number; cr: number; cw: number; usd: number; unpriced: number }>()
177  for (const r of rows) {
178    const k = key(r)
179    const a = acc.get(k) ?? { key: k, turns: 0, in: 0, out: 0, cr: 0, cw: 0, usd: 0, unpriced: 0 }
180    a.turns += count(r.turns)
181    a.in += count(r.in)
182    a.out += count(r.out)
183    a.cr += count(r.cr)
184    a.cw += count(r.cw)
185    if (r.priced && r.usd !== null && Number.isFinite(r.usd)) a.usd += r.usd
186    else a.unpriced += count(r.turns)
187    acc.set(k, a)
188  }
189  return [...acc.values()].sort((a, b) => b.usd - a.usd || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
190}
191
192const rowsUsd = (rows: readonly SpendRow[]): number => rows.reduce((s, r) => s + (r.priced && r.usd !== null && Number.isFinite(r.usd) ? r.usd : 0), 0)
193
194/** This week's and last week's review statuses, by week string. */
195const reviewOf = (rollup: Rollup, week: string) => rollup.reviews.find(r => r.week === week) ?? null
196// --- width: tiers, wrapping and tables (SPEC §6) ----------------------------------------
197
198/** The pane's three widths: wide ≥ 84 columns, medium 60–83, narrow below 60. */
199export type Tier = 'wide' | 'medium' | 'narrow'
200export const tierOf = (cols: number): Tier => (cols >= 84 ? 'wide' : cols >= 60 ? 'medium' : 'narrow')
201/** A renderer's width: a finite width of at least 1, else 80 (a text answer with no known width). */
202const widthOf = (width: number): number => (Number.isFinite(width) && width >= 1 ? Math.floor(width) : 80)
203const len = (s: string): number => [...s].length
204
205/**
206 * `text` wrapped to `width` code points: the first line indented by `indent`
207 * spaces, the rest by `indent + 2` so a wrapped item reads as one. Breaks at
208 * whitespace (the run of spaces at a break is dropped, runs inside a line are
209 * kept); a word longer than a line is cut across lines rather than lost. Free
210 * text in the views (notes, recommendations, captions, warnings) goes through
211 * this instead of being cut with an ellipsis, so nothing is silently hidden.
212 */
213export const wrapText = (text: string, width: number, indent = 0): string[] => {
214  const w = Math.max(1, widthOf(width))
215  const first = ' '.repeat(Math.max(0, Math.min(indent, w - 1)))
216  const rest = ' '.repeat(Math.max(0, Math.min(indent + 2, w - 1)))
217  const out: string[] = []
218  let line = first
219  let has = false
220  const tokens = [...String(text).matchAll(/(\s*)(\S+)/g)].map(m => ({ sep: m[1] ?? '', word: m[2] ?? '' }))
221  for (const t of tokens) {
222    let chars = [...t.word]
223    let sep = t.sep === '' ? '' : t.sep
224    while (chars.length > 0) {
225      const gap = has ? len(sep) : 0
226      if (len(line) + gap + chars.length <= w) {
227        line += (has ? sep : '') + chars.join('')
228        has = true
229        chars = []
230      } else if (has) {
231        out.push(line)
232        line = rest
233        has = false
234        sep = ''
235      } else {
236        const take = Math.max(1, w - len(line))
237        line += chars.slice(0, take).join('')
238        chars = chars.slice(take)
239        out.push(line)
240        line = rest
241      }
242    }
243  }
244  if (has) out.push(line)
245  return out.length > 0 ? out : ['']
246}
247
248/**
249 * A table column. `align: 'r'` marks a figure: its cells are never cut.
250 * `drop` is the column's place in the drop order (1 goes first); a column
251 * without one is never dropped. `min` is the narrowest a text column may be
252 * cut to (default 6); an id column sets its own width so it is never cut.
253 */
254export type Col = { head: string; align: 'l' | 'r'; drop?: number; min?: number }
255
256/**
257 * A table fitted to `width`: columns padded to their widest cell and two
258 * spaces apart, figures right-aligned, the header first. While it is too wide
259 * the widest text column above its `min` gives up a character (its cells cut
260 * with an ellipsis); when none can, the next column in the drop order goes.
261 * A figure is never cut: a figure column is either drawn whole or dropped.
262 * Only when nothing is left to shrink or drop is a line cut to `width`, so
263 * the invariant (no line past the pane) still holds.
264 */
265export const fitTable = (cols: readonly Col[], rows: readonly (readonly string[])[], width: number, indent = 2): string[] => {
266  const w = widthOf(width)
267  const natural = cols.map((c, i) => Math.max(len(c.head), ...rows.map(r => len(r[i] ?? ''))))
268  const widths = natural.slice()
269  const shown = cols.map(() => true)
270  const total = (): number => {
271    const live = widths.filter((_, i) => shown[i])
272    return indent + live.reduce((a, b) => a + b, 0) + 2 * Math.max(0, live.length - 1)
273  }
274  while (total() > w) {
275    let best = -1
276    cols.forEach((c, i) => {
277      if (!shown[i] || c.align !== 'l' || (widths[i] as number) <= Math.min(natural[i] as number, c.min ?? 6)) return
278      if (best < 0 || (widths[i] as number) > (widths[best] as number)) best = i
279    })
280    if (best >= 0) {
281      widths[best] = (widths[best] as number) - 1
282      continue
283    }
284    let drop = -1
285    cols.forEach((c, i) => {
286      if (shown[i] && c.drop !== undefined && (drop < 0 || c.drop < (cols[drop]?.drop as number))) drop = i
287    })
288    if (drop < 0) break
289    shown[drop] = false
290    // A dropped column gives its room back: the text columns may grow again.
291    cols.forEach((c, i) => {
292      if (c.align === 'l') widths[i] = natural[i] as number
293    })
294  }
295  const pad = ' '.repeat(indent)
296  const line = (cells: readonly string[]): string =>
297    fit(
298      pad +
299        widths
300          .map((cw, i) => {
301            if (!shown[i]) return null
302            const raw = cells[i] ?? ''
303            const cell = cols[i]?.align === 'r' ? raw : fit(raw, cw)
304            const fill = ' '.repeat(Math.max(0, cw - len(cell)))
305            return cols[i]?.align === 'r' ? fill + cell : cell + fill
306          })
307          .filter((c): c is string => c !== null)
308          .join('  ')
309          .trimEnd(),
310      w,
311    )
312  return [line(cols.map(c => c.head)), ...rows.map(line)]
313}
314
315/**
316 * A label/value pair (a headline figure): the label in a column of
317 * `labelWidth`, the value right-aligned in at least 12, when both fit the
318 * width; otherwise the label on its own line(s) and the value under it,
319 * indented by two more. Neither is ever cut, so a narrow pane still reads
320 * every figure. The one pair helper the Session view and the Usage pane share.
321 */
322export const pairLines = (label: string, value: string, width: number, labelWidth: number, indent = 2): string[] => {
323  const w = widthOf(width)
324  const lead = ' '.repeat(indent)
325  const room = w - indent - labelWidth
326  if (len(label) < labelWidth && len(value) <= room) return [`${lead}${label.padEnd(labelWidth)}${' '.repeat(Math.max(0, Math.min(room, Math.max(12, len(value))) - len(value)))}${value}`]
327  return [...wrapText(label, w, indent), ...wrapText(value, w, indent + 2)]
328}
329
330/** rd-tracker's `hours()`: seconds as hours with two decimals, no unit. */
331export const hours = (s: number): string => (seconds(s) / 3600).toFixed(2)
332
333/** One drawn line of a view: section titles bold, captions dim. */
334export type ViewLine = { text: string; style: 'title' | 'body' | 'dim' }
335
336const BUCKET_NAME: Record<string, string> = { [NON_RD]: 'non-R&D', [UNMAPPED]: 'unmapped', [IGNORED]: 'ignored' }
337const BUCKET_ORDER = [NON_RD, UNMAPPED, IGNORED] as const
338const ACTIVITY_NAME: Record<string, string> = { direct_research: 'direct_research', direct_support: 'direct_support', non_qualified: 'non_qualified', unclassified: 'unclassified', mixed: 'mixed' }
339const FLAG_REASON: Record<string, string> = {
340  'block-over-4h': 'block over the 4 h cap',
341  'day-over-cap': 'over the daily flag',
342  'automation-heavy': 'automation swamped human turns',
343  'rows-dropped': 'rows dropped',
344}
345const days = (n: number): string => `${n} day(s)`
346
347/**
348 * rd-tracker's R41 calendar (rdt/reports/dashboard.py `DEADLINES`), the six
349 * obligations its dashboard lists, copied as data so the R&D view reads the
350 * same. A row with no calendar date says what it is tied to instead.
351 */
352export const RDT_DEADLINES: readonly { title: string; due: string | null; when: string }[] = [
353  { title: 'Form 6765 Section G becomes mandatory', due: null, when: 'first mandatory tax year: TY2026' },
354  { title: '§70302(f)(1) retroactive §174A election window closes', due: '2026-07-06', when: 'fixed statutory date' },
355  { title: '§280C(c)(2) late election / revocation window closes', due: '2026-07-06', when: 'fixed date, or the §6511 period if earlier' },
356  { title: '§280C reduced-credit election (Form 6765 Item A)', due: null, when: 'original timely filed return, including extensions' },
357  { title: '§41(h) qualified small business payroll-tax election (Section D)', due: null, when: 'original timely filed return, including extensions' },
358  { title: 'Section G small-filer exemption is lost on an amended return', due: null, when: 'whenever an amended return is filed' },
359]
360const CPA_CAVEAT = 'Calendar tax year; dates are not moved for weekends or holidays; verify with your CPA.'
361const LABEL_CAVEAT = ' (calendar tax year; verify with your CPA)'
362
363/** A deadline's status against today, rd-tracker's wording. */
364const deadlineStatus = (due: string | null, today: string): string => {
365  if (due === null || !isDate(due) || !isDate(today)) return 'no calendar date — see due'
366  const n = dayNumber(due) - dayNumber(today)
367  return n < 0 ? `CLOSED ${days(-n)} ago` : `${days(n)} remaining`
368}
369
370/** The week-over-week change in words. */
371const deltaText = (now: number, before: number): string => {
372  if (!(before > 0)) return 'no prior period'
373  const pct = ((now - before) / before) * 100
374  if (Math.abs(pct) > 999) return `${pct > 0 ? 'up' : 'down'} over 999% vs last week`
375  const shown = Math.abs(pct).toFixed(1)
376  if (shown === '0.0') return 'no change vs last week'
377  return `${pct > 0 ? 'up' : 'down'} ${shown}% vs last week`
378}
379
380/** Local `HH:MM` and date of `ts`, UTC when the zone is unknown here. */
381const atText = (ts: number, zone: string): string => {
382  try {
383    const c = localClock(zone)
384    return `${c.hm(ts)} on ${c.date(ts)}`
385  } catch {
386    return `${utcIso(ts).slice(11, 16)} UTC on ${utcIso(ts).slice(0, 10)}`
387  }
388}
389
390/**
391 * CONTRACT (W3). The R&D view as drawn lines, ≤ `width` columns each (SPEC
392 * §6.1): the header, Hours in range, Weekly trend, Hours by entity, Hours by
393 * component, Activity mix, Review status, Spend this month, Pipeline and
394 * Deadlines, rd-tracker's dashboard in text. Tables drop columns by tier and
395 * free text wraps. Ids, zones and reasons pass the same shape checks as every
396 * text answer (CYB-13): no repository key or branch is ever drawn here.
397 */
398export const rdLines = (rollup: Rollup, width: number): ViewLine[] => {
399  const w = widthOf(width)
400  const tier = tierOf(w)
401  const out: ViewLine[] = []
402  const title = (t: string) => out.push(...wrapText(t, w, 0).map((text, i) => ({ text, style: i === 0 ? ('title' as const) : ('body' as const) })))
403  const body = (t: string, indent = 2) => out.push(...wrapText(t, w, indent).map(text => ({ text, style: 'body' as const })))
404  const dim = (t: string, indent = 4) => out.push(...wrapText(t, w, indent).map(text => ({ text, style: 'dim' as const })))
405  const table = (cols: readonly Col[], rows: readonly (readonly string[])[]) => out.push(...fitTable(cols, rows, w).map(text => ({ text, style: 'body' as const })))
406  const gap = () => out.push({ text: '', style: 'body' })
407  const kv = (label: string, value: string) => out.push({ text: fit(`  ${label.padEnd(12)}${value.padStart(8)}`, w), style: 'body' })
408
409  const monday = isWeek(rollup.week) ? weekMondayDay(rollup.week) : null
410  const range = monday === null ? weekText(rollup.week) : `${shortDate(monday)} – ${shortDate(monday + 6)}`
411  out.push(...wrapText(`R&D ledger · week of ${range} · ${zoneText(rollup.zone)} · ${methodText(rollup.method)}`, w, 0).map(text => ({ text, style: 'title' as const })))
412
413  // Hours in range
414  gap()
415  title('Hours in range')
416  kv('Net hours', hours(rollup.weekTotalS))
417  dim('engaged time, gaps removed; the figure a claim is built from')
418  kv('Gross span', hours(rollup.weekGrossS))
419  dim('first to last prompt each day, gaps included (context, not a claim basis)')
420  kv('Today', hours(rollup.todayS))
421  body(deltaText(seconds(rollup.weekTotalS), seconds(rollup.lastWeekTotalS)))
422
423  // Weekly trend
424  gap()
425  title('Weekly trend')
426  // Every week in scope (at most ten); the Total foots exactly the rows shown.
427  const trend = rollup.weekTrend
428  const narrow = tier === 'narrow'
429  const trendCols: Col[] = [{ head: 'ISO week', align: 'l', min: 18 }, { head: 'Net hours', align: 'r' }, ...(narrow ? [] : [{ head: 'Gross span', align: 'r' as const, drop: 1 }])]
430  const trendRow = (week: string, n: number, g: number) => [week, hours(n), ...(narrow ? [] : [hours(g)])]
431  // The oldest week can start before the first of last month: only its in-scope days are counted, so it says so.
432  const scopeStart = isDate(rollup.today) ? dayNumber(`${monthBefore(rollup.today.slice(0, 7))}-01`) : null
433  const isPartial = (week: string) => isWeek(week) && scopeStart !== null && weekMondayDay(week) < scopeStart
434  // The tag is never cut: wide and medium carry it in the cell, narrow prints the week alone and says so under the table.
435  const weekLabel = (week: string) => (isPartial(week) && !narrow ? `${week} (partial)` : weekText(week))
436  table(trendCols, [...trend.map(t => trendRow(weekLabel(t.week), t.netS, t.grossS)), trendRow('Total', trend.reduce((s, t) => s + seconds(t.netS), 0), trend.reduce((s, t) => s + seconds(t.grossS), 0))])
437  for (const t of trend) if (narrow && isPartial(t.week)) dim(`(partial) ${t.week} starts before the first of last month; only its in-scope days count`, 2)
438
439  // Hours by entity: the buckets always print, so the rows add up to the week
440  gap()
441  title('Hours by entity')
442  const entityS = (id: string) => rollup.byEntity.filter(e => e.entity === id).reduce((s, e) => s + seconds(e.seconds), 0)
443  const entities = rollup.byEntity.filter(e => !BUCKETS.has(e.entity))
444  const entityRows = [...entities.map(e => [entityText(e.entity), hours(e.seconds)]), ...BUCKET_ORDER.map(b => [BUCKET_NAME[b] as string, hours(entityS(b))])]
445  const entityTotal = rollup.byEntity.reduce((s, e) => s + seconds(e.seconds), 0)
446  table([{ head: 'Entity', align: 'l' }, { head: 'Net hours', align: 'r' }], [...entityRows, ['Total', hours(entityTotal)]])
447
448  // Hours by component
449  gap()
450  title('Hours by component')
451  const showEntity = tier !== 'narrow'
452  const showActivity = tier === 'wide'
453  const compCols: Col[] = [{ head: 'Component', align: 'l' }, ...(showEntity ? [{ head: 'Entity', align: 'l' as const, drop: 2 }] : []), ...(showActivity ? [{ head: 'Activity', align: 'l' as const, drop: 1 }] : []), { head: 'Net hours', align: 'r' }]
454  const compRow = (name: string, entity: string, activity: string, s: number) => [name, ...(showEntity ? [entity] : []), ...(showActivity ? [activity] : []), hours(s)]
455  const compS = (id: string) => rollup.byComponent.filter(c => c.component === id).reduce((s, c) => s + seconds(c.seconds), 0)
456  const compRows = rollup.byComponent.filter(c => !BUCKETS.has(c.component)).map(c => compRow(idText(c.component), entityText(c.entity), c.activity === null ? '-' : (ACTIVITY_NAME[c.activity] ?? '-'), c.seconds))
457  const compTotal = rollup.byComponent.reduce((s, c) => s + seconds(c.seconds), 0)
458  table(compCols, [...compRows, ...BUCKET_ORDER.map(b => compRow(BUCKET_NAME[b] as string, '(none)', '-', compS(b))), compRow('Total', '', '', compTotal)])
459
460  // Activity mix
461  gap()
462  title('Activity mix')
463  const mix = rollup.activityMix.filter(a => seconds(a.seconds) > 0)
464  table([{ head: 'Activity type', align: 'l' }, { head: 'Net hours', align: 'r' }], [...mix.map(a => [ACTIVITY_NAME[a.activity] ?? '-', hours(a.seconds)]), ['Total', hours(rollup.activityMix.reduce((s, a) => s + seconds(a.seconds), 0))]])
465  const qualified = rollup.activityMix.filter(a => a.activity === 'direct_research' || a.activity === 'direct_support')
466  const qualS = qualified.reduce((s, a) => s + seconds(a.seconds), 0)
467  const poeS = qualified.reduce((s, a) => s + seconds(a.poeS), 0)
468  body(`Process-of-experimentation share: ${hours(poeS)} of ${hours(qualS)} qualified-services hours (${qualS > 0 ? `${((poeS / qualS) * 100).toFixed(1)}%` : 'n/a'})`)
469
470  // Review status
471  gap()
472  title('Review status')
473  const queueS = rollup.queue.reduce((s, q) => s + seconds(q.seconds), 0)
474  const queueSessions = rollup.queue.reduce((s, q) => s + count(q.sessions), 0)
475  body(`${hours(queueS)} hours in the review queue · ${rollup.queue.length} unmapped repo(s), ${queueSessions} session(s)`)
476  body('Unconfirmed weeks')
477  const open = rollup.weekTrend.filter(t => t.confirmed === false && seconds(t.netS) > 0)
478  const changed = rollup.reviews.filter(r => r.confirmed && count(r.changedRows) > 0 && isWeek(r.week))
479  for (const t of open) body(`${weekText(t.week)}  unconfirmed  ${hours(t.netS)}`, 4)
480  for (const r of changed) body(`${weekText(r.week)}  ${count(r.changedRows)} row(s) changed since confirmation`, 4)
481  if (open.length === 0 && changed.length === 0) body('Every week with recorded time is confirmed.', 4)
482  const flagged = rollup.flaggedDays.filter(f => isDate(f.date) && FLAG_KINDS.has(f.reason))
483  if (flagged.length > 0) {
484    body('Days to adjudicate')
485    for (const f of flagged.slice(0, 8)) body(`${f.date}  ${FLAG_REASON[f.reason]}  ${hours(f.netS)} net · ${hours(f.grossS)} gross`, 4)
486    if (flagged.length > 8) body(`and ${flagged.length - 8} more`, 4)
487  } else {
488    body('Longest days (no flags)')
489    const longest = rollup.longestDays.filter(d => isDate(d.date))
490    for (const d of longest) body(`${d.date}  ${hours(d.netS)} net · ${hours(d.grossS)} gross`, 4)
491    if (longest.length === 0) body('no time recorded this week', 4)
492  }
493  body('Confirm weeks in Review (v) — hours are proposals until confirmed.')
494
495  // Spend this month
496  gap()
497  title('Spend this month')
498  const monthUsd = rollup.spendMonthUsd ?? (rollup.spendMonth.length > 0 ? rowsUsd(rollup.spendMonth) : null)
499  body(`${monthUsd === null ? 'no priced turns' : `~${money(monthUsd)}`} est. list price · Spend (s)`)
500
501  // Pipeline (the facts the old Data health carried)
502  gap()
503  title('Pipeline')
504  const h = rollup.health
505  body(`last refresh at ${atText(rollup.at, rollup.zone)}`)
506  body(h.daysSinceExport === null ? 'never exported' : `last export ${days(count(h.daysSinceExport))} ago`)
507  body(`bridge ${BRIDGE_TEXT[h.bridge] ?? '(unrecognized bridge status)'}`)
508  body(`${plural(count(h.sessionsInScope), 'session', 'sessions')} · ${plural(count(h.segmentsInScope), 'segment', 'segments')} in scope · ${count(h.unreadable)} unreadable`)
509  const denied = Object.keys(h.deniedByReason)
510    .map(r => ({ r: REASON_RE.test(r) ? r : '(unrecognized reason)', n: count(h.deniedByReason[r] as number) }))
511    .sort((a, b) => b.n - a.n || (a.r < b.r ? -1 : a.r > b.r ? 1 : 0))
512  body(`denied events: ${denied.length === 0 ? 'none' : denied.slice(0, 5).map(d => `${d.r} ${d.n}`).join(' · ')}${denied.length > 5 ? ` · and ${denied.length - 5} more` : ''}`)
513  body(`${count(h.redactions)} redactions · ${count(h.rowsDropped)} dropped rows`)
514  body(`prices as of ${isDate(h.pricesAsOf) ? h.pricesAsOf : PRICES_AS_OF}`)
515  const detected = h.zoneDetectedNow !== null && h.zoneDetectedNow !== h.zone ? ` (this Mac now reads ${zoneText(h.zoneDetectedNow)})` : ''
516  body(`zone ${zoneText(h.zone)}${detected}`)
517  if (h.storeIsLink) body('warning: the store is a symbolic link')
518  if (typeof h.lockStaleMs === 'number') body(`warning: a store lock from a crashed session is ${Math.round(h.lockStaleMs / 60_000)} min old; saves and hand-offs wait until it is cleared with Clear stale lock (l)`)
519  if (h.readOnly) body('read-only: a newer build wrote this store')
520
521  // Deadlines
522  gap()
523  title('Deadlines')
524  for (const d of RDT_DEADLINES) body(`${d.due ?? d.when}  ${d.title} — ${deadlineStatus(d.due, rollup.today)}`)
525  const ours = rollup.deadlines.filter(d => isDate(d.date)).slice().sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
526  for (const d of ours.slice(0, 8)) {
527    const label = oneLine(d.label.replace(LABEL_CAVEAT, ''), 80)
528    body(`${d.date}  ${label}${d.entity === null ? '' : ` · ${idText(d.entity)}`} — ${deadlineStatus(d.date, rollup.today)}`)
529  }
530  for (const id of rollup.entityIds) body(`Section G exemption · ${idText(id)} — unknown`)
531  dim(CPA_CAVEAT, 2)
532  return out
533}
534
535/** CONTRACT (W3). The R&D view as text (a headless or non-placed answer), ≤ `width` columns. */
536export const overviewText = (rollup: Rollup, width: number): string =>
537  rdLines(rollup, width)
538    .map(l => l.text)
539    .join('\n')
540
541/** CONTRACT (W3). One line: hours this week, today, queue size, review status. */
542export const summaryLine = (rollup: Rollup): string => {
543  const review = reviewOf(rollup, rollup.week)
544  const state = review && review.confirmed ? `confirmed rev ${count(review.rev)}` : 'open'
545  return `R&D: ${hoursText(seconds(rollup.weekTotalS))} this week · ${hoursText(seconds(rollup.todayS))} today · ${rollup.queue.length} unmapped · week ${weekText(rollup.week)} ${state}`
546}
547
548const tokens = (n: number): string => String(count(n))
549
550const spendMonthLines = (title: string, month: string, rows: readonly SpendRow[], engine: Rollup['engineCost'] | null, w: number): string[] => {
551  const tier = tierOf(w)
552  const out = wrapText(`${title} ${isMonth(month) ? month : '(unrecognized month)'}: ${money(rowsUsd(rows))} estimated`, w, 0)
553  if (rows.length === 0) return [...out, '  no turns recorded']
554  out.push('  By model')
555  // As the pane narrows the table gives up cache write and input (medium), then cache read (narrow).
556  const showIn = tier === 'wide'
557  const showCw = tier === 'wide'
558  const showCr = tier !== 'narrow'
559  const cols: Col[] = [
560    { head: 'Model', align: 'l' },
561    { head: 'Turns', align: 'r', drop: 4 },
562    ...(showIn ? [{ head: 'Input', align: 'r' as const, drop: 2 }] : []),
563    { head: 'Output', align: 'r', drop: 5 },
564    ...(showCr ? [{ head: 'Cache read', align: 'r' as const, drop: 3 }] : []),
565    ...(showCw ? [{ head: 'Cache write', align: 'r' as const, drop: 1 }] : []),
566    { head: 'Est.', align: 'r' },
567  ]
568  const models = sumBy(rows, r => r.model)
569  out.push(
570    ...fitTable(
571      cols,
572      models.map(m => [modelText(m.key), String(m.turns), ...(showIn ? [compactInt(m.in)] : []), compactInt(m.out), ...(showCr ? [compactInt(m.cr)] : []), ...(showCw ? [compactInt(m.cw)] : []), money(m.usd)]),
573      w,
574      4,
575    ),
576  )
577  const unpriced = models.filter(m => m.unpriced > 0)
578  for (const m of unpriced) out.push(...wrapText(`${modelText(m.key)}: ${plural(m.unpriced, 'turn', 'turns')} unpriced (no price row for the model)`, w, 4))
579  out.push('  By component')
580  const showEntity = tier !== 'narrow'
581  const comps = sumBy(rows, r => `${r.component}|${r.entity}`).sort((a, b) => byComponent(a.key.split('|')[0] as string, b.key.split('|')[0] as string) || b.usd - a.usd)
582  out.push(
583    ...fitTable(
584      [{ head: 'Component', align: 'l' }, ...(showEntity ? [{ head: 'Entity', align: 'l' as const, drop: 1 }] : []), { head: 'Turns', align: 'r', drop: 2 }, { head: 'Est.', align: 'r' }],
585      comps.map(c => {
586        const [component, entity] = c.key.split('|') as [string, string]
587        return [idText(component), ...(showEntity ? [entityText(entity)] : []), String(c.turns), money(c.usd)]
588      }),
589      w,
590      4,
591    ),
592  )
593  if (engine) out.push(...wrapText(`engine /cost figure ${money(engine.usd)} over ${plural(count(engine.segments), 'segment', 'segments')}`, w, 2))
594  return out
595}
596
597/** CONTRACT (W3). The Spend view as text, ≤ `width` columns. */
598export const spendText = (rollup: Rollup, width = 100): string => {
599  const w = widthOf(width)
600  const thisMonth = isDate(rollup.today) ? rollup.today.slice(0, 7) : (rollup.spendMonth[0]?.month ?? '')
601  const lastMonth = isMonth(thisMonth) ? monthBefore(thisMonth) : (rollup.spendLastMonth[0]?.month ?? '')
602  const out = [...wrapText(`Spend · ${ESTIMATE_LABEL} · prices as of ${isDate(rollup.health.pricesAsOf) ? rollup.health.pricesAsOf : PRICES_AS_OF}`, w, 0), '']
603  out.push(...spendMonthLines('This month', thisMonth, rollup.spendMonth, rollup.engineCost, w), '')
604  out.push(...spendMonthLines('Last month', lastMonth, rollup.spendLastMonth, null, w), '')
605  out.push(...wrapText('Cache writes are priced at the cache TTL in force for each turn (the cacheTtl option where the session recorded none): x1.25 input for 5m, x2.0 for 1h.', w, 2))
606  out.push(...wrapText("The estimate excludes the engine's side calls (compaction, titles); the engine /cost figure is the engine's own number.", w, 2))
607  return out.map(l => fit(l, w)).join('\n')
608}
609
610// --- the monthly log -------------------------------------------------------------------
611
612/** Lines per printed page of the monthly log (US Letter at 6 lpi). */
613export const LOG_PAGE_LINES = 66
614
615/** CONTRACT (W3). The monthly log: per day, blocks and hours by component; 66-line pages with a header and `Page n of m`. */
616export const monthlyLogPages = (month: string, blocks: readonly Block[], allocations: readonly import('../types').Allocation[], zone: string, method: string): string => {
617  const valid = isMonth(month)
618  const label = valid ? month : '(unrecognized month)'
619  const inMonth = (date: string): boolean => valid && isDate(date) && date.startsWith(`${month}-`)
620  const clock = localClock(zone)
621
622  const days = new Map<string, { blocks: Block[]; comps: Map<string, { entity: string; seconds: number }> }>()
623  const dayOf = (date: string) => {
624    const d = days.get(date) ?? { blocks: [], comps: new Map<string, { entity: string; seconds: number }>() }
625    days.set(date, d)
626    return d
627  }
628  for (const b of blocks) if (inMonth(b.date)) dayOf(b.date).blocks.push(b)
629  for (const a of allocations as readonly Allocation[]) {
630    if (!inMonth(a.date)) continue
631    const comps = dayOf(a.date).comps
632    const key = `${a.component}|${a.entity}`
633    const c = comps.get(key) ?? { entity: a.entity, seconds: 0 }
634    c.seconds += seconds(a.seconds)
635    comps.set(key, c)
636  }
637
638  // The body as lines tagged with their day, so a page that opens mid-day can say so.
639  const body: { text: string; day: string | null; header: boolean }[] = []
640  for (const date of [...days.keys()].sort()) {
641    const d = days.get(date) as { blocks: Block[]; comps: Map<string, { entity: string; seconds: number }> }
642    body.push({ text: `${date} ${WEEKDAYS[weekdayIndex(dayNumber(date))]}`, day: date, header: true })
643    const sorted = d.blocks.slice().sort((a, b) => a.startTs - b.startTs || a.endTs - b.endTs)
644    for (const b of sorted) {
645      const flag = b.flags.includes('over-4h') ? '  over-4h' : ''
646      body.push({ text: `  ${clock.hm(b.startTs)}–${clock.hm(b.endTs)}  ${plural(b.events.length, 'event', 'events')}  ${hoursText(seconds(b.seconds))}${flag}`, day: date, header: false })
647    }
648    if (d.comps.size > 0) body.push({ text: '  hours by component', day: date, header: false })
649    const keys = [...d.comps.keys()].sort((a, b) => byComponent(a.split('|')[0] as string, b.split('|')[0] as string) || (a < b ? -1 : 1))
650    for (const key of keys) {
651      const c = d.comps.get(key) as { entity: string; seconds: number }
652      body.push({ text: `    ${idText(key.split('|')[0])}  ${entityText(c.entity)}  ${hoursText(c.seconds)}`, day: date, header: false })
653    }
654    body.push({ text: `  day total ${hoursText(sorted.reduce((s, b) => s + seconds(b.seconds), 0))}`, day: date, header: false })
655    body.push({ text: '', day: null, header: false })
656  }
657  if (body.length === 0) body.push({ text: `No engagement recorded in ${label}.`, day: null, header: false })
658
659  // Pages: a header line and a blank line, then the body, padded to exactly LOG_PAGE_LINES.
660  const capacity = LOG_PAGE_LINES - 2
661  const pages: string[][] = []
662  let page: string[] = []
663  for (const row of body) {
664    if (page.length === capacity) {
665      pages.push(page)
666      page = []
667    }
668    if (page.length === 0 && !row.header && row.day !== null) page.push(`${row.day} (continued)`)
669    if (page.length === 0 && row.text === '') continue
670    page.push(row.text)
671  }
672  if (page.length > 0 || pages.length === 0) pages.push(page)
673  const total = pages.length
674  const methodLabel = methodText(method)
675  return pages
676    .map((lines, i) => {
677      const head = [`rd-ledger monthly log ${label} · method ${methodLabel} · page ${i + 1} of ${total}`, '']
678      const filled = [...head, ...lines]
679      while (filled.length < LOG_PAGE_LINES) filled.push('')
680      return filled.join('\n')
681    })
682    .join('\n')
683    .concat('\n')
684}
685
686// --- the footer, the Session and Sessions views (SPEC §6.1a, §6.1b, §6.5) ------------------
687
688/** U+29D7, a black hourglass: a monochrome mathematical symbol, not an emoji (the emoji hourglass is U+231B). */
689export const TIME_MARK = '\u29D7'
690/** The footer chip never grows past this many characters, so the strip never clips it (no space after the mark: the strip is shared with other mods' chips). */
691export const FOOTER_MAX = 9
692
693/**
694 * The footer chip: the hourglass mark and today's recorded R&D hours (the
695 * R&D view's "Today" figure, engaged union seconds) to one decimal, `--`
696 * before the rollup exists. A negative or non-finite figure reads 0.0h and
697 * a runaway one a floor, so the chip never outgrows the desktop's mode strip.
698 * The one label SessionMode and PromptHint share.
699 */
700export const footerLabel = (todayS: number | null): string => {
701  if (todayS === null) return `${TIME_MARK}--`
702  const tenths = Number.isFinite(todayS) ? Math.round((Math.max(0, todayS) / 3600) * 10) : 0
703  if (tenths >= 100_000) return `${TIME_MARK}9999h+`
704  return `${TIME_MARK}${(tenths / 10).toFixed(1)}h`
705}
706
707/**
708 * Columns padded to their widest cell, two spaces apart, the first line the
709 * header; `align` is per column, `r` for figures. The one table formatter the
710 * Session and Sessions views share.
711 */
712export const textTable = (header: readonly string[], rows: readonly (readonly string[])[], align: readonly ('l' | 'r')[]): string[] => {
713  const len = (s: string): number => [...s].length
714  const widths = header.map((h, i) => Math.max(len(h), ...rows.map(r => len(r[i] ?? ''))))
715  const line = (cells: readonly string[]): string =>
716    widths
717      .map((w, i) => {
718        const cell = cells[i] ?? ''
719        const padding = ' '.repeat(Math.max(0, w - len(cell)))
720        return align[i] === 'r' ? padding + cell : cell + padding
721      })
722      .join('  ')
723      .trimEnd()
724  return [line(header), ...rows.map(line)]
725}
726
727const ID8_RE = /^[0-9a-f]{8}$/i
728const AGENT_RE = /^[0-9a-f]{8}$/
729const ENTRYPOINT_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/
730const id8Text = (s: string): string => (ID8_RE.test(s) ? s.toLowerCase() : '(unrecognized id)')
731/** `others` is the row folding the subagents past the twelfth (rollup.ts AGENTS_CAP). */
732const agentText = (key: string, merged: number): string => (key === '' ? 'main' : key === 'others' ? `${count(merged)} others` : AGENT_RE.test(key) ? key : '(unrecognized agent)')
733const percent = (share: number): string => `${Math.round(Math.max(0, Math.min(1, Number.isFinite(share) ? share : 0)) * 100)} %`
734const tokenSum = (t: { in: number; out: number; cr: number; cw: number }): number => count(t.in) + count(t.out) + count(t.cr) + count(t.cw)
735/** Local `YYYY-MM-DD HH:MM` in the zone; UTC when the zone is unknown to this runtime. */
736const openedText = (ts: number, zone: string): string => {
737  try {
738    const c = localClock(zone)
739    return `${c.date(ts)} ${c.hm(ts)}`
740  } catch {
741    return `${utcIso(ts).slice(0, 16).replace('T', ' ')} UTC`
742  }
743}
744/** The engine figure when the session recorded one, else the estimate marked `~`. */
745const sessionCost = (s: { engineUsd: number | null; estUsd: number | null }): string => (s.engineUsd !== null ? money(s.engineUsd) : `~${money(s.estUsd)}`)
746
747export type SessionTextOptions = {
748  /** Draw the repository key: the pane does; a text answer never does (CYB-13) */
749  showRepo: boolean
750}
751
752/**
753 * The Session view as lines: the header, engagement, tokens and cache-hit
754 * ratio, both cost figures, the by-model and agents tables, then the
755 * recommendations or the line saying there are none. Shared by the current
756 * session and a selected one.
757 */
758export const sessionText = (s: SessionSummary, zone: string, opts: SessionTextOptions, width = 100): string => {
759  const w = widthOf(width)
760  const narrow = tierOf(w) === 'narrow'
761  const fields = [`opened ${openedText(s.openedAt, zone)}`]
762  if (opts.showRepo) fields.push(`repo ${s.repoKey === null ? '-' : oneLine(s.repoKey, 40)}`)
763  fields.push(s.entrypoint !== null && ENTRYPOINT_RE.test(s.entrypoint) ? s.entrypoint : 'entrypoint unknown')
764  fields.push(plural(count(s.segments), 'segment', 'segments'))
765  // The header is one line when it fits, else the id alone and one field per line.
766  const joined = `Session ${id8Text(s.id8)} · ${fields.join(' · ')}`
767  const out = len(joined) <= w ? [joined] : [...wrapText(`Session ${id8Text(s.id8)}`, w, 0), ...fields.flatMap(f => wrapText(f, w, 2))]
768
769  const labelWidth = narrow ? 14 : 20
770  const pair = (label: string, value: string) => out.push(...pairLines(label, value, w, labelWidth))
771  const t = s.tokens
772  const hitBase = count(t.in) + count(t.cr) + count(t.cw)
773  out.push('')
774  pair('Engaged hours', hours(s.engagedS))
775  pair('Prompts', String(count(s.prompts)))
776  pair('Main turns', String(count(s.turnsMain)))
777  pair('Agent turns', String(count(s.turnsAgent)))
778  pair('Input tokens', tokens(t.in))
779  pair('Output tokens', tokens(t.out))
780  pair('Cache read', tokens(t.cr))
781  pair('Cache write', tokens(t.cw))
782  pair('Cache hit', hitBase > 0 ? `${Math.round((count(t.cr) / hitBase) * 100)} %` : '-')
783  pair('Engine /cost', money(s.engineUsd))
784  pair('Estimate', money(s.estUsd))
785  out.push(...wrapText(`the estimate is a ${ESTIMATE_LABEL}`, w, 4))
786  if (count(s.unpricedTurns) > 0) out.push(...wrapText(`${plural(count(s.unpricedTurns), 'turn', 'turns')} unpriced (no price row for the model)`, w, 4))
787
788  out.push('', 'By model')
789  if (s.models.length === 0) out.push('  no turns recorded')
790  else
791    out.push(
792      ...fitTable(
793        [{ head: 'Model', align: 'l' }, { head: 'Turns', align: 'r', drop: 1 }, { head: 'Est.', align: 'r' }],
794        s.models.map(m => [m.model === 'others' && count(m.merged) > 0 ? `${count(m.merged)} other models` : modelText(m.model), String(count(m.turns)), money(m.estUsd)]),
795        w,
796      ),
797    )
798
799  out.push('', 'Agents')
800  if (s.agents.length === 0) out.push('  no turns recorded')
801  else {
802    // Narrow drops the model and token columns; the estimate and share stay.
803    const cols: Col[] = [{ head: 'Agent', align: 'l', min: 8 }, { head: 'Turns', align: 'r', drop: 3 }, ...(narrow ? [] : [{ head: 'Model', align: 'l' as const, drop: 1 }, { head: 'Tokens', align: 'r' as const, drop: 2 }]), { head: 'Est.', align: 'r' }, { head: 'Share', align: 'r', drop: 4 }]
804    out.push(...fitTable(cols, s.agents.map(a => [agentText(a.agentKey, a.merged), String(count(a.turns)), ...(narrow ? [] : [a.model === null ? '-' : modelText(a.model), compactInt(tokenSum(a.tokens))]), money(a.estUsd), percent(a.share)]), w))
805  }
806
807  out.push('')
808  // Titles wrap like any text: a continuation line is indented, so only the first reads as a title.
809  if (s.recommendations.length === 0) out.push(...wrapText('No recommendations for this session', w, 0))
810  else {
811    out.push('Recommendations')
812    for (const r of s.recommendations) out.push(...wrapText(`- ${oneLine(r.text, 400)}`, w, 2))
813  }
814  return out.map(l => fit(l, w)).join('\n')
815}
816
817/** Rows the Sessions view lists at first, and adds per press of More. */
818export const SESSIONS_SHOWN = 40
819/** The most rows the Sessions view lists: every row the rollup keeps. */
820export const SESSIONS_SHOWN_MAX = 300
821
822/** `shown` as a row count the view can use: SESSIONS_SHOWN when it is not a number, clamped to 1..SESSIONS_SHOWN_MAX. */
823export const shownRows = (shown: unknown): number => (typeof shown === 'number' && Number.isFinite(shown) ? Math.max(1, Math.min(SESSIONS_SHOWN_MAX, Math.floor(shown))) : SESSIONS_SHOWN)
824
825export type SessionsTextOptions = SessionTextOptions & {
826  /** How many rows to list (the persisted sessionsShown) */
827  shown: number
828  /** The pane has a More button; a text answer does not, so it names only the command */
829  hasMore: boolean
830}
831
832/**
833 * The Sessions view (the global ledger) as lines, ≤ `width` columns: one row
834 * per session in scope, newest first, `shown` of them, then how many more and
835 * how to reach them. As the pane narrows the table gives up Recs (medium),
836 * then Agents and Repo (narrow, where the date drops its year).
837 */
838export const sessionsText = (rollup: Rollup, opts: SessionsTextOptions, width = 100): string => {
839  const w = widthOf(width)
840  const tier = tierOf(w)
841  const all: readonly SessionRow[] = rollup.sessions
842  const out = wrapText(`Sessions this and last month · ${plural(all.length, 'session', 'sessions')}${all.length >= SESSIONS_SHOWN_MAX ? ' (the newest 300)' : ''} · cost is the engine /cost figure, ~ marks a ${ESTIMATE_LABEL}`, w, 0)
843  out.push(...wrapText(`Median est. per engaged hour ${money(rollup.medianUsdPerHour)} (sessions with at least 0.25 h engaged)`, w, 2))
844  if (all.length === 0) return [...out, '', '  no sessions recorded yet'].map(l => fit(l, w)).join('\n')
845  const listed = all.slice(0, shownRows(opts.shown))
846  const showRepo = opts.showRepo && tier !== 'narrow'
847  const showAgents = tier !== 'narrow'
848  const showRecs = tier === 'wide'
849  const cols: Col[] = [
850    { head: 'Date', align: 'l', min: 10, drop: 6 },
851    { head: 'Session', align: 'l', min: 8 },
852    ...(showRepo ? [{ head: 'Repo', align: 'l' as const, drop: 3 }] : []),
853    { head: 'Hours', align: 'r', drop: 5 },
854    { head: 'Turns', align: 'r', drop: 4 },
855    ...(showAgents ? [{ head: 'Agents', align: 'r' as const, drop: 2 }] : []),
856    { head: 'Cost', align: 'r' },
857    ...(showRecs ? [{ head: 'Recs', align: 'r' as const, drop: 1 }] : []),
858  ]
859  const rows = listed.map(s => {
860    const date = openedText(s.openedAt, rollup.zone).slice(0, 10)
861    return [
862      tier === 'narrow' ? date.slice(5) : date,
863      id8Text(s.id8),
864      ...(showRepo ? [s.repoKey === null ? '-' : oneLine(s.repoKey, 18)] : []),
865      hours(s.engagedS),
866      String(count(s.turns)),
867      ...(showAgents ? [String(count(s.agentCount))] : []),
868      sessionCost(s),
869      ...(showRecs ? [String(count(s.recCount))] : []),
870    ]
871  })
872  out.push('', ...fitTable(cols, rows, w))
873  const more = all.length - listed.length
874  if (more > 0) out.push(...wrapText(`${more} more — ${opts.hasMore ? 'press More or ' : ''}/rd session <id8>`, w, 2))
875  else out.push(...wrapText('open one with /rd session <id8>', w, 2))
876  return out.map(l => fit(l, w)).join('\n')
877}
878
879/** One option per listed session for the Sessions view's picker: the full id as the value. */
880export const sessionOptions = (rollup: Rollup, shown: number, width = 60): { value: string; label: string }[] =>
881  rollup.sessions.slice(0, shownRows(shown)).map(s => {
882    // The id and the cost lead when the picker is narrow; the date and repository fill what is left.
883    const rest = `${openedText(s.openedAt, rollup.zone).slice(0, 10)} ${s.repoKey === null ? '' : oneLine(s.repoKey, 18)}`
884    const full = oneLine(`${openedText(s.openedAt, rollup.zone).slice(0, 10)} ${id8Text(s.id8)} ${s.repoKey === null ? '' : oneLine(s.repoKey, 18)} ${sessionCost(s)}`, 60)
885    const w = Math.min(60, widthOf(width))
886    return { value: s.sessionId, label: len(full) <= w ? full : fit(`${id8Text(s.id8)} ${sessionCost(s)} ${rest}`, w) }
887  })
888
hooks/review.ts 356 lines
1// rd-ledger: the weekly review file (SPEC §3.4). The READ side is lead-owned
2// and complete: decode with validation, the latest confirmed revision, row
3// status against the live rollup. The WRITE side (confirm, reopen, notes,
4// other hours, encode) is workstream W2's; its signatures are the contract.
5// Pure: no `$`, no I/O.
6
7import type { Activity, ConfirmedRow, ReviewFile, ReviewRevision, ReviewStatus, RowStatus, UnionParams } from '../types'
8import { sha256 } from './hash'
9import { isActivity } from './mapping'
10import { cleanField, hasOwn, isDate, isId, isRedacted, isWeek, oneLine } from './text'
11import { CAP, IGNORED, NON_RD, UNMAPPED } from './store'
12
13const isRecordObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
14const num = (x: unknown): number | null => (typeof x === 'number' && Number.isFinite(x) ? x : null)
15
16export const emptyReview = (week: string): ReviewFile => ({ v: 1, week, revisions: [] })
17
18const decodeParams = (x: unknown): UnionParams | null => {
19  if (!isRecordObject(x)) return null
20  const gapS = num(x['gapS'])
21  const floorS = num(x['floorS'])
22  const maxBlockS = num(x['maxBlockS'])
23  const dayFlagH = num(x['dayFlagH'])
24  return gapS === null || floorS === null || maxBlockS === null || dayFlagH === null ? null : { gapS, floorS, maxBlockS, dayFlagH }
25}
26
27const decodeRow = (x: unknown, isBucketKey: boolean): ConfirmedRow | null => {
28  if (!isRecordObject(x)) return null
29  const seconds = num(x['seconds'])
30  const entity = x['entity']
31  // A frozen `__unmapped__` or `__ignored__` row carries no entity (entity ''); every other row names one (SPEC §3.4).
32  const entityOk = typeof entity === 'string' && (isId(entity) || (entity === '' && isBucketKey))
33  if (seconds === null || seconds < 0 || !entityOk || !isActivity(x['activity'])) return null
34  return { seconds: Math.floor(seconds), entity: entity as string, activity: x['activity'], poe: x['poe'] === true, source: x['source'] === 'confirmed' ? 'confirmed' : 'pretag' }
35}
36
37const decodeRevision = (x: unknown): ReviewRevision | null => {
38  if (!isRecordObject(x)) return null
39  const rev = num(x['rev'])
40  const at = num(x['at'])
41  const params = decodeParams(x['params'])
42  if (rev === null || rev < 1 || at === null || params === null) return null
43  const kind = x['kind'] === 'reopen' ? 'reopen' : x['kind'] === 'confirm' ? 'confirm' : null
44  if (kind === null) return null
45  const rows: Record<string, ConfirmedRow> = {}
46  if (isRecordObject(x['rows'])) {
47    for (const [key, value] of Object.entries(x['rows'])) {
48      const component = key.slice(0, key.indexOf('|'))
49      const row = decodeRow(value, component === UNMAPPED || component === IGNORED)
50      if (row && /^[^|]+\|\d{4}-\d{2}-\d{2}$/.test(key)) rows[key] = row
51    }
52  }
53  const otherHours: Record<string, number | null> = {}
54  if (isRecordObject(x['otherHours'])) {
55    for (const [key, value] of Object.entries(x['otherHours'])) if (isId(key) || key === 'non_business') otherHours[key] = value === null ? null : (num(value) !== null && (num(value) as number) >= 0 && (num(value) as number) <= 168 ? (num(value) as number) : null)
56  }
57  const notes: Record<string, string> = {}
58  if (isRecordObject(x['notes'])) {
59    for (const [key, value] of Object.entries(x['notes'])) {
60      const text = cleanField(typeof value === 'string' ? value : null, CAP.note).value
61      if (text !== null && /^[^|]+\|\d{4}-W\d{2}$/.test(key)) notes[key] = text
62    }
63  }
64  const lag = num(x['confirmedLagDays'])
65  return {
66    rev: Math.floor(rev),
67    at,
68    kind,
69    confirmedAt: num(x['confirmedAt']),
70    method: cleanField(typeof x['method'] === 'string' ? x['method'] : null, 40).value ?? '',
71    params,
72    zone: cleanField(typeof x['zone'] === 'string' ? x['zone'] : null, 64).value ?? '',
73    mappingDigest: typeof x['mappingDigest'] === 'string' && /^[0-9a-f]{64}$/.test(x['mappingDigest']) ? x['mappingDigest'] : '',
74    inputsDigest: typeof x['inputsDigest'] === 'string' && /^[0-9a-f]{64}$/.test(x['inputsDigest']) ? x['inputsDigest'] : '',
75    rows,
76    otherHours,
77    notes,
78    confirmedLagDays: lag === null ? null : Math.floor(lag),
79  }
80}
81
82/**
83 * The review file from its text (null → empty). Never throws. Revisions must
84 * have strictly increasing `rev` numbers: the first revision that repeats or
85 * goes backwards, and everything after it, is dropped and named in `problems`
86 * (DBM-11). A `v` other than 1 or a week mismatch returns an empty file with
87 * `readOnly: true` (the file must not be rewritten).
88 */
89export const loadReview = (text: string | null, week: string): { review: ReviewFile; problems: string[]; readOnly: boolean } => {
90  if (text === null) return { review: emptyReview(week), problems: [], readOnly: false }
91  let raw: unknown
92  try {
93    raw = JSON.parse(text)
94  } catch {
95    return { review: emptyReview(week), problems: ['unparseable'], readOnly: true }
96  }
97  if (!isRecordObject(raw) || raw['v'] !== 1 || raw['week'] !== week || !isWeek(week)) return { review: emptyReview(week), problems: ['wrong version or week'], readOnly: true }
98  const problems: string[] = []
99  const revisions: ReviewRevision[] = []
100  let last = 0
101  if (Array.isArray(raw['revisions'])) {
102    for (const [i, item] of raw['revisions'].entries()) {
103      const rev = decodeRevision(item)
104      if (!rev) {
105        problems.push(`revision ${i} malformed`)
106        continue
107      }
108      if (rev.rev <= last) {
109        problems.push(`revision ${i} repeats or goes backwards (rev ${rev.rev} after ${last}); it and later ones ignored`)
110        break
111      }
112      last = rev.rev
113      revisions.push(rev)
114    }
115  }
116  return { review: { v: 1, week, revisions }, problems, readOnly: false }
117}
118
119/** The latest revision of kind `confirm` not followed by a `reopen`; null when the week is open. */
120export const latestConfirmed = (r: ReviewFile): ReviewRevision | null => {
121  const last = r.revisions[r.revisions.length - 1]
122  return last && last.kind === 'confirm' ? last : null
123}
124
125/** The latest revision of any kind (its notes and other hours carry forward as drafts). */
126export const latestRevision = (r: ReviewFile): ReviewRevision | null => r.revisions[r.revisions.length - 1] ?? null
127
128export const reviewStatus = (r: ReviewFile, changedRows: number): ReviewStatus => {
129  const confirmed = latestConfirmed(r)
130  return { week: r.week, confirmed: confirmed !== null, rev: latestRevision(r)?.rev ?? 0, changedRows }
131}
132
133/**
134 * A live row's status against the latest confirmed revision: `pretag` with
135 * no confirmation, `confirmed` when the frozen seconds and entity match the
136 * live ones, `changed` otherwise (a remap, a zone or parameter change, or a
137 * late event); a frozen row that no longer exists live is reported by the
138 * caller as `changed` too.
139 */
140export const rowStatus = (confirmed: ReviewRevision | null, key: string, liveSeconds: number, liveEntity: string): { status: RowStatus; activity: Activity | null; poe: boolean | null; confirmedSeconds: number | null } => {
141  if (!confirmed) return { status: 'pretag', activity: null, poe: null, confirmedSeconds: null }
142  const row = Object.prototype.hasOwnProperty.call(confirmed.rows, key) ? confirmed.rows[key] : undefined
143  if (!row) return { status: 'changed', activity: null, poe: null, confirmedSeconds: null }
144  const same = row.seconds === liveSeconds && row.entity === liveEntity
145  return { status: same ? 'confirmed' : 'changed', activity: row.activity, poe: row.poe, confirmedSeconds: row.seconds }
146}
147
148/** Keys of frozen rows absent from the live set (DBM-1: shown, never dropped). */
149export const missingLiveRows = (confirmed: ReviewRevision | null, liveKeys: ReadonlySet<string>): string[] =>
150  confirmed ? Object.keys(confirmed.rows).filter(k => !liveKeys.has(k)) : []
151
152// ---------------------------------------------------------------------------------
153// W2 — the write side. Implement exactly these exported names and signatures.
154// ---------------------------------------------------------------------------------
155
156export type ConfirmInput = {
157  at: number
158  method: string
159  params: UnionParams
160  zone: string
161  mappingDigest: string
162  inputsDigest: string
163  /** The live week rows with the drafts applied: key → row */
164  rows: Record<string, ConfirmedRow>
165  otherHours: Record<string, number | null>
166  notes: Record<string, string>
167  /** The week's Sunday as `YYYY-MM-DD` (for the lag) */
168  weekEnd: string
169}
170
171const DAY_MS = 86_400_000
172const HEX64 = /^[0-9a-f]{64}$/
173/** The key shapes loadReview keeps; a revision built here must survive its own reload unchanged. */
174const ROW_KEY = /^[^|]+\|\d{4}-\d{2}-\d{2}$/
175const NOTE_KEY = /^[^|]+\|\d{4}-W\d{2}$/
176/** The allocation buckets that are frozen with the week but are not components (no POE, entity '' when unmapped or ignored). */
177const BUCKETS: readonly string[] = [UNMAPPED, IGNORED, NON_RD]
178
179const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
180/** An outside string as a refusal may show it: one clean short line, a token redacted. */
181const shown = (s: string): string => cleanField(String(s), 64).value ?? '(empty)'
182
183/** Days since 1970-01-01 of a real calendar date, or null (Date.UTC alone would roll 02-31 into March). */
184const dayNumber = (date: string): number | null => {
185  if (typeof date !== 'string' || !isDate(date)) return null
186  const [y, m, d] = date.split('-').map(Number) as [number, number, number]
187  const first = Date.UTC(y, m - 1, 1) / DAY_MS
188  return d > Date.UTC(y, m, 1) / DAY_MS - first ? null : first + d - 1
189}
190
191/** The day number of the Sunday that ends ISO week `week` (week 1 is the one holding January 4). */
192const isoWeekSunday = (week: string): number => {
193  const jan4 = Date.UTC(Number(week.slice(0, 4)), 0, 4) / DAY_MS
194  // Day 0 (1970-01-01) was a Thursday: with Monday as 0 that is 3.
195  const monday = jan4 - ((((jan4 + 3) % 7) + 7) % 7)
196  return monday + 7 * (Number(week.slice(6)) - 1) + 6
197}
198
199const copyRow = (row: ConfirmedRow): ConfirmedRow => ({ seconds: row.seconds, entity: row.entity, activity: row.activity, poe: row.poe, source: row.source })
200
201/** A table with its keys in sorted order, values mapped; `bare` gives a null-prototype object for the encoder. */
202const sortedTable = <A, B>(table: Record<string, A>, f: (a: A) => B, bare = false): Record<string, B> => {
203  const out: Record<string, B> = bare ? (Object.create(null) as Record<string, B>) : {}
204  for (const key of Object.keys(table).sort()) out[key] = f(table[key] as A)
205  return out
206}
207
208/** A deep copy: a built file never shares a row, a note or a parameter object with the file it came from. */
209const copyRevision = (x: ReviewRevision, bare = false): ReviewRevision => ({
210  rev: x.rev,
211  at: x.at,
212  kind: x.kind,
213  confirmedAt: x.confirmedAt ?? null,
214  method: x.method,
215  params: { gapS: x.params.gapS, floorS: x.params.floorS, maxBlockS: x.params.maxBlockS, dayFlagH: x.params.dayFlagH },
216  zone: x.zone,
217  mappingDigest: x.mappingDigest,
218  inputsDigest: x.inputsDigest,
219  rows: sortedTable(x.rows, copyRow, bare),
220  otherHours: sortedTable(x.otherHours, v => v ?? null, bare),
221  notes: sortedTable(x.notes, v => v, bare),
222  confirmedLagDays: x.confirmedLagDays ?? null,
223})
224
225/**
226 * CONTRACT (W2). A new file with a `confirm` revision appended: rev = last + 1,
227 * `confirmedAt = at`, `confirmedLagDays` = whole days from the week's Sunday
228 * 23:59:59 to `at` (0 when confirmed within the week), rows copied, notes
229 * through `cleanField(…, CAP.note)`, other hours validated (0–168 or null).
230 * Refused (`{ refused }`) when a row has `poe: true` without a non-empty note
231 * for its `${component}|${week}` (PRV-3), or when a row's seconds are negative.
232 * Never mutates.
233 *
234 * Also refused, because a frozen record that loadReview would partly drop is
235 * worse than no record: a `weekEnd` that is not this week's Sunday, a missing
236 * method, zone, parameter or digest, a row key or entity loadReview rejects,
237 * POE on an `__unmapped__`/`__ignored__`/`__non_rd__` bucket, and POE whose
238 * note the leak scan withheld (a `[redacted:…]` marker is not a justification).
239 * Notes for other weeks are left out; seconds are floored.
240 */
241export const buildConfirm = (r: ReviewFile, input: ConfirmInput): { review: ReviewFile } | { refused: string } => {
242  if (!isWeek(r.week)) return { refused: 'the review file names no valid week' }
243  if (typeof input.at !== 'number' || !Number.isFinite(input.at)) return { refused: 'the confirmation time is not a number' }
244  const endDay = dayNumber(input.weekEnd)
245  if (endDay === null || endDay !== isoWeekSunday(r.week)) return { refused: `the week end ${oneLine(String(input.weekEnd), 20)} is not the Sunday of ${r.week}` }
246  const p = input.params
247  const params = { gapS: p?.gapS, floorS: p?.floorS, maxBlockS: p?.maxBlockS, dayFlagH: p?.dayFlagH }
248  if (!Object.values(params).every(n => typeof n === 'number' && Number.isFinite(n))) return { refused: 'the method parameters are incomplete' }
249  const method = cleanField(typeof input.method === 'string' ? input.method : null, 40).value
250  const zone = cleanField(typeof input.zone === 'string' ? input.zone : null, 64).value
251  if (method === null || zone === null) return { refused: 'the method or the time zone is missing' }
252  if (!HEX64.test(String(input.mappingDigest)) || !HEX64.test(String(input.inputsDigest))) return { refused: 'a digest is missing' }
253
254  const notes: Record<string, string> = {}
255  for (const key of Object.keys(input.notes ?? {}).sort()) {
256    if (!NOTE_KEY.test(key) || !key.endsWith(`|${r.week}`)) continue
257    const text = input.notes[key]
258    const value = cleanField(typeof text === 'string' ? text : null, CAP.note).value
259    if (value !== null) notes[key] = value
260  }
261
262  const rows: Record<string, ConfirmedRow> = {}
263  for (const key of Object.keys(input.rows ?? {}).sort()) {
264    if (!ROW_KEY.test(key)) return { refused: `row ${shown(key)} is not keyed component|date` }
265    const row = input.rows[key] as Partial<ConfirmedRow> | undefined
266    const component = key.slice(0, key.lastIndexOf('|'))
267    const bucket = BUCKETS.includes(component)
268    const what = `${shown(component)} on ${key.slice(-10)}`
269    if (!bucket && !isId(component)) return { refused: `row ${what} does not name a component` }
270    if (typeof row?.seconds !== 'number' || !Number.isFinite(row.seconds)) return { refused: `${what} has no seconds` }
271    if (row.seconds < 0) return { refused: `${what} has negative seconds` }
272    const entity = row.entity
273    const entityOk = typeof entity === 'string' && (isId(entity) || (entity === '' && (component === UNMAPPED || component === IGNORED)))
274    if (!entityOk) return { refused: `${what} has no valid entity` }
275    if (!isActivity(row.activity)) return { refused: `${what} has no valid activity` }
276    if (row.poe === true) {
277      if (bucket) return { refused: `${what} is not a component, so POE cannot be on` }
278      const noteKey = `${component}|${r.week}`
279      const note = hasOwn(notes, noteKey) ? notes[noteKey] : undefined
280      if (note === undefined) return { refused: `POE for ${shown(component)} needs a note for ${r.week}` }
281      if (isRedacted(note)) return { refused: `the note for ${shown(component)} held a credential or an address and was withheld; rewrite it before turning POE on` }
282    }
283    rows[key] = { seconds: Math.floor(row.seconds), entity: entity as string, activity: row.activity, poe: row.poe === true, source: row.source === 'confirmed' ? 'confirmed' : 'pretag' }
284  }
285
286  const otherHours: Record<string, number | null> = {}
287  for (const key of Object.keys(input.otherHours ?? {}).sort()) {
288    if (!isId(key) && key !== 'non_business') continue
289    const h = input.otherHours[key]
290    otherHours[key] = typeof h === 'number' && Number.isFinite(h) && h >= 0 && h <= 168 ? h : null
291  }
292
293  // The lag counts whole days past Sunday 23:59:59.999 in UTC terms of the date string; never negative.
294  const confirmedLagDays = Math.max(0, Math.floor((input.at - ((endDay + 1) * DAY_MS - 1)) / DAY_MS))
295  const revision: ReviewRevision = {
296    rev: (latestRevision(r)?.rev ?? 0) + 1,
297    at: input.at,
298    kind: 'confirm',
299    confirmedAt: input.at,
300    method,
301    params: params as UnionParams,
302    zone,
303    mappingDigest: input.mappingDigest,
304    inputsDigest: input.inputsDigest,
305    rows,
306    otherHours,
307    notes,
308    confirmedLagDays,
309  }
310  return { review: { v: 1, week: r.week, revisions: [...r.revisions.map(x => copyRevision(x)), revision] } }
311}
312
313/**
314 * CONTRACT (W2). A new file with a `reopen` revision appended (rev = last + 1, rows/notes/otherHours copied from the latest revision, `confirmedAt: null`). Refused when the week is not confirmed.
315 * The method, zone and digests are carried from the confirmation being reopened; `confirmedLagDays` is null (a reopen confirms nothing).
316 */
317export const buildReopen = (r: ReviewFile, at: number): { review: ReviewFile } | { refused: string } => {
318  const confirmed = latestConfirmed(r)
319  if (!confirmed) return { refused: 'the week is not confirmed, so there is nothing to reopen' }
320  if (typeof at !== 'number' || !Number.isFinite(at)) return { refused: 'the reopen time is not a number' }
321  const revision: ReviewRevision = { ...copyRevision(confirmed), rev: confirmed.rev + 1, at, kind: 'reopen', confirmedAt: null, confirmedLagDays: null }
322  return { review: { v: 1, week: r.week, revisions: [...r.revisions.map(x => copyRevision(x)), revision] } }
323}
324
325/**
326 * CONTRACT (W2). The file text to write (two-space JSON, keys in a fixed order).
327 * Fields in the type's order, table keys sorted (a re-encode never reorders a diff), every nullable written as null,
328 * a trailing newline.
329 */
330export const encodeReview = (r: ReviewFile): string => {
331  const out = { v: 1, week: r.week, revisions: r.revisions.map(x => copyRevision(x, true)) }
332  return `${JSON.stringify(out, null, 2)}\n`
333}
334
335/**
336 * CONTRACT (W2). An other-hours field value parsed: '' → null; a number 0–168 (one decimal) → the number; anything else → `{ error }`.
337 * Plain decimal digits only (no sign, exponent or comma); rounded half up to one decimal on the digits themselves, so
338 * binary floating point cannot round 7.55 down; the range is checked after rounding. The error never echoes the input.
339 */
340export const parseOtherHours = (text: string): number | null | { error: string } => {
341  const t = String(text ?? '').trim()
342  if (t === '') return null
343  const m = /^(\d{1,4})(?:\.(\d{1,6}))?$/.exec(t.startsWith('.') ? `0${t}` : t)
344  if (!m) return { error: 'Enter hours as a number from 0 to 168, or leave it blank' }
345  const frac = m[2] ?? ''
346  const tenths = Number(m[1]) * 10 + Number(frac[0] ?? '0') + (Number(frac[1] ?? '0') >= 5 ? 1 : 0)
347  if (tenths > 1680) return { error: 'A week has at most 168 hours' }
348  return tenths / 10
349}
350
351/** CONTRACT (W2). sha256 over the sorted admitted events in scope, each as `ts|sessionId|seg|seq`, joined by newlines (the inputs digest of a confirmation). */
352export const inputsDigest = (events: readonly { ts: number; sessionId: string; seg: string; seq: number }[]): string => {
353  const sorted = [...events].sort((a, b) => a.ts - b.ts || cmp(a.sessionId, b.sessionId) || cmp(a.seg, b.seg) || a.seq - b.seq)
354  return sha256(sorted.map(e => `${e.ts}|${e.sessionId}|${e.seg}|${e.seq}`).join('\n'))
355}
356
hooks/rollup.ts 782 lines
1// rd-ledger: the rollup the pane and footer draw (SPEC §6.7). Workstream W1.
2// Pure: decoded segments in, a Rollup out. The register owns the file cache.
3//
4// One pass of union-v2 runs over every admitted event in scope (this and last
5// month), so a block that starts on a Sunday night and the week that follows
6// see the same blocks; the week, the day totals and the queue are then cuts of
7// that one allocation table, which is why they always sum to each other.
8
9import type { Activity, ActivityHours, AgentSummary, Allocation, Block, ComponentHours, DayFigure, EngineCost, EntityHours, Flag, FlaggedDay, Health, Mapping, QueueRow, Recommendation, ReviewFile, ReviewRevision, Rollup, SessionRecord, SessionRow, SessionSummary, SpendRow, TokenCounts, TurnRecord, UnionEvent, UnionParams, WeekRow, WeekTrendRow } from '../types'
10import { isNewLocation, pretag } from './mapping'
11import { PRICES_AS_OF, priceRow, priceTurn, spendByMonth } from './prices'
12import { emptyReview, latestConfirmed, missingLiveRows, reviewStatus, rowStatus } from './review'
13import { CAP, IGNORED, NON_RD, ownEngineCost, ttlAt, UNMAPPED } from './store'
14import { compactInt, money, oneLine } from './text'
15import { modelText } from './report'
16import { addDays, dayBounds, isoWeek, localDate, monthOfTs, previousMonth, weekDates } from './tz'
17import { allocate, buildBlocks, flags, methodString, segmentKey } from './union'
18
19export type RollupInput = {
20  now: number
21  zone: string
22  params: UnionParams
23  defaultTtl: '5m' | '1h'
24  /** Every decoded segment in scope (this and last month) */
25  segments: readonly import('../types').SessionRecord[]
26  mapping: Mapping
27  /** The review files for the weeks in scope, by week */
28  reviews: ReadonlyMap<string, ReviewFile>
29  /** Health figures the register knows and the rollup only carries */
30  health: Pick<Health, 'zoneDetectedNow' | 'unreadable' | 'daysSinceExport' | 'storeIsLink' | 'lockStaleMs' | 'bridge' | 'readOnly'>
31  deadlines: import('../types').Deadline[]
32  /** The segment this process is writing, for `thisSessionId` and the Session view's engine figure; null before one opens */
33  current?: { sessionId: string; seg: string } | null
34  /** The session the Session view shows (a full id), so its summary is carried even past the newest rows */
35  selected?: string | null
36}
37
38const SENTINELS: ReadonlySet<string> = new Set([UNMAPPED, IGNORED, NON_RD])
39const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
40const add = <K>(m: Map<K, number>, key: K, n: number): void => {
41  m.set(key, (m.get(key) ?? 0) + n)
42}
43/** The key with the most seconds; ties to the smaller key, so the answer never depends on input order. */
44const dominant = (m: ReadonlyMap<string, number>): string => [...m.entries()].sort((a, b) => b[1] - a[1] || cmp(a[0], b[0]))[0]?.[0] ?? ''
45
46/**
47 * The branches a segment showed on a local day: the one in force when the day
48 * began (the last change before it, or the segment's only branch when it never
49 * changed) and every change made during the day.
50 */
51const branchesOn = (r: SessionRecord, date: string, zone: string): string[] => {
52  const { startTs, endTs } = dayBounds(date, zone)
53  const out = new Set<string>()
54  if (r.branchChanges.length === 0) {
55    if (r.branch) out.add(r.branch)
56    return [...out]
57  }
58  let atStart: string | null = null
59  for (const c of r.branchChanges) {
60    if (c.ts < startTs) atStart = c.branch
61    else if (c.ts < endTs && c.branch) out.add(c.branch)
62  }
63  if (atStart) out.add(atStart)
64  return [...out]
65}
66
67type Group = { component: string; date: string; seconds: number; entities: Map<string, number>; repos: Map<string, number>; records: Set<SessionRecord>; over4h: boolean }
68
69/** The live rows of one ISO week against its latest confirmed revision (SPEC §3.4). */
70const weekRowsFor = (week: string, allocations: readonly Allocation[], blocksByStart: ReadonlyMap<number, Block>, segments: ReadonlyMap<string, SessionRecord>, mapping: Mapping, review: ReviewFile, zone: string): WeekRow[] => {
71  const dates = new Set(weekDates(week))
72  const groups = new Map<string, Group>()
73  for (const a of allocations) {
74    if (!dates.has(a.date)) continue
75    const key = `${a.component}|${a.date}`
76    const g: Group = groups.get(key) ?? { component: a.component, date: a.date, seconds: 0, entities: new Map<string, number>(), repos: new Map<string, number>(), records: new Set<SessionRecord>(), over4h: false }
77    g.seconds += a.seconds
78    add(g.entities, a.entity, a.seconds)
79    const record = segments.get(segmentKey(a.sessionId, a.seg))
80    if (record) {
81      g.records.add(record)
82      if (record.repoKey) add(g.repos, record.repoKey, a.seconds)
83    }
84    if (blocksByStart.get(a.blockStartTs)?.flags.includes('over-4h')) g.over4h = true
85    groups.set(key, g)
86  }
87  const confirmed: ReviewRevision | null = latestConfirmed(review)
88  const rows: WeekRow[] = []
89  for (const [key, g] of groups) {
90    const entity = dominant(g.entities)
91    const status = rowStatus(confirmed, key, g.seconds, entity)
92    let tag: { activity: Activity; poe: boolean } = { activity: 'non_qualified', poe: false }
93    if (!SENTINELS.has(g.component)) {
94      const repoKey = dominant(g.repos)
95      const branches = [...new Set([...g.records].filter(r => r.repoKey === repoKey).flatMap(r => branchesOn(r, g.date, zone)))].sort(cmp)
96      tag = pretag(mapping, g.component, repoKey, branches)
97    }
98    const rowFlags: string[] = []
99    if (g.over4h) rowFlags.push('over-4h')
100    // Two nonRd rules naming different entities on one day share the `__non_rd__|date` key; say so rather than hide it.
101    if (g.entities.size > 1) rowFlags.push('mixed-entities')
102    rows.push({ key, component: g.component, entity, date: g.date, seconds: g.seconds, confirmedSeconds: status.confirmedSeconds, activity: status.activity ?? tag.activity, poe: status.poe ?? tag.poe, status: status.status, flags: rowFlags })
103  }
104  // A frozen row with no live time left is shown at zero, never dropped (DBM-1).
105  for (const key of missingLiveRows(confirmed, new Set(groups.keys()))) {
106    const frozen = (confirmed as ReviewRevision).rows[key]
107    const cut = key.lastIndexOf('|')
108    if (!frozen || cut < 0) continue
109    rows.push({ key, component: key.slice(0, cut), entity: frozen.entity, date: key.slice(cut + 1), seconds: 0, confirmedSeconds: frozen.seconds, activity: frozen.activity, poe: frozen.poe, status: 'changed', flags: [] })
110  }
111  return rows.sort((a, b) => Number(SENTINELS.has(a.component)) - Number(SENTINELS.has(b.component)) || cmp(a.component, b.component) || cmp(a.date, b.date))
112}
113
114/** One queue row per repository location with unmapped time this week, or with a hash new under a mapped bare key (DBM-8). */
115const queueFor = (dates: ReadonlySet<string>, allocations: readonly Allocation[], segments: ReadonlyMap<string, SessionRecord>, mapping: Mapping, zone: string): QueueRow[] => {
116  type Entry = { row: QueueRow; sessions: Set<string>; branches: Set<string> }
117  const rows = new Map<string, Entry>()
118  for (const a of allocations) {
119    // An empty repoRef (no repository known) cannot be mapped from the queue; its time still counts in weekUnmappedS.
120    if (!dates.has(a.date) || a.repoRef === '') continue
121    const record = segments.get(segmentKey(a.sessionId, a.seg))
122    if (!record?.repoKey) continue
123    const newLocation = isNewLocation(mapping, record.repoKey, record.rootHash)
124    if (a.component !== UNMAPPED && !newLocation) continue
125    const entry: Entry = rows.get(a.repoRef) ??{ row: { key: a.repoRef, repoKey: record.repoKey, rootHash: record.rootHash, seconds: 0, branches: [], sessions: 0, isNewLocation: newLocation }, sessions: new Set<string>(), branches: new Set<string>() }
126    entry.row.seconds += a.seconds
127    entry.sessions.add(a.sessionId)
128    for (const b of branchesOn(record, a.date, zone)) entry.branches.add(b)
129    rows.set(a.repoRef, entry)
130  }
131  return [...rows.values()]
132    .map(x => ({ ...x.row, sessions: x.sessions.size, branches: [...x.branches].sort(cmp) }))
133    .sort((a, b) => b.seconds - a.seconds || cmp(a.key, b.key))
134}
135
136/** This month's list-price total: the priced rows summed; null when rows exist and none could be priced. */
137const monthUsd = (rows: readonly SpendRow[]): number | null => {
138  const priced = rows.filter(r => r.priced && r.usd !== null)
139  if (rows.length > 0 && priced.length === 0) return null
140  return priced.reduce((sum, r) => sum + (r.usd as number), 0)
141}
142
143// --- sessions: the Session and Sessions views (SPEC §6.1a, §6.1b) ---------------------------
144//
145// Every figure here is a list-price estimate over the stored turns (the same
146// priceTurn and TTL rule as spendByMonth), beside the engine's own /cost figure
147// where a segment recorded one. Agents come from the turns' agentKey, never
148// from a live agent list, so a closed session reads the same as an open one.
149
150/** At most this many sessions reach the Sessions view, newest first. */
151export const SESSIONS_CAP = 300
152/** The newest rows that carry a full summary in the rollup (beside this session and the selected one). */
153export const DETAILS_ROWS = 40
154/** A summary lists at most this many subagents, then one `others` row. */
155export const AGENTS_CAP = 12
156/** A summary lists at most this many models, then one `others` row. */
157export const MODELS_CAP = 8
158/** A session counts toward the per-hour median, and the cost-per-hour rule, from this many engaged seconds. */
159export const MIN_RATE_S = 900
160/** A turn that starts this long after its chain's previous turn ended found a 5-minute cache expired. */
161export const PAUSE_MS = 5 * 60_000
162/** The model whose prices the agent-tier rule quotes. */
163export const SONNET_MODEL = 'claude-sonnet-5-5'
164/** The agentKey and model of a row that folds the rest together. */
165export const OTHERS = 'others'
166
167/** One stored turn with the model it is priced under, the TTL in force for it and its estimate. */
168export type PricedTurn = { turn: TurnRecord; model: string; ttl: '5m' | '1h'; usd: number | null }
169
170/** What the recommendation rules read of one session. */
171export type SessionFacts = {
172  /** Every turn of the session, in (ts, endTs, turnId) order */
173  turns: readonly PricedTurn[]
174  /** The TTL in force for every turn; null when it changed during the session (then neither cache rule speaks) */
175  ttl: '5m' | '1h' | null
176  engagedS: number
177  estUsd: number | null
178  agents: readonly AgentSummary[]
179}
180
181/** What the rules read of the whole scope. */
182export type ScopeFacts = { sessionsInScope: number; medianUsdPerHour: number | null }
183
184const count = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0)
185const zeroTokens = (): TokenCounts => ({ in: 0, out: 0, cr: 0, cw: 0 })
186const addTokens = (acc: TokenCounts, t: TurnRecord): void => {
187  acc.in += count(t.in)
188  acc.out += count(t.out)
189  acc.cr += count(t.cr)
190  acc.cw += count(t.cw)
191}
192/** The sum of the priced estimates; null when there are turns and none was priced. */
193const sumUsd = (turns: readonly PricedTurn[]): number | null => {
194  let usd = 0
195  let priced = 0
196  for (const p of turns) {
197    if (p.usd === null) continue
198    usd += p.usd
199    priced += 1
200  }
201  return turns.length > 0 && priced === 0 ? null : usd
202}
203const pushTo = <K, V>(m: Map<K, V[]>, key: K, value: V): void => {
204  const list = m.get(key)
205  if (list) list.push(value)
206  else m.set(key, [value])
207}
208
209/** The median of `values`; the mean of the middle two for an even count; null when empty. */
210export const median = (values: readonly number[]): number | null => {
211  const v = values.filter(Number.isFinite).slice().sort((a, b) => a - b)
212  if (v.length === 0) return null
213  const mid = Math.floor(v.length / 2)
214  return v.length % 2 === 1 ? (v[mid] as number) : ((v[mid - 1] as number) + (v[mid] as number)) / 2
215}
216
217/**
218 * The turns that began more than PAUSE_MS after the previous turn of the same
219 * agent ended, each mapped to `maxCtx`: the largest context (cr + cw) any
220 * earlier turn of its chain had built, which bounds what a longer TTL could
221 * have kept cached for it. Each agent keeps its own cache prefix, so the chain
222 * is per agentKey; the first turn of a chain is never paused (nothing was
223 * cached yet). `turns` must be in ts order.
224 */
225export const pausedTurns = (turns: readonly PricedTurn[]): Map<PricedTurn, number> => {
226  const out = new Map<PricedTurn, number>()
227  const chains = new Map<string, { last: PricedTurn; maxCtx: number }>()
228  for (const p of turns) {
229    const chain = chains.get(p.turn.agentKey)
230    if (chain && p.turn.ts - chain.last.turn.endTs > PAUSE_MS) out.set(p, chain.maxCtx)
231    const ctx = count(p.turn.cr) + count(p.turn.cw)
232    chains.set(p.turn.agentKey, { last: p, maxCtx: Math.max(chain?.maxCtx ?? 0, ctx) })
233  }
234  return out
235}
236/** `tokens` at `perMTok(row)` dollars per million for the turn's model; 0 for a model without a price row. */
237const atRate = (p: PricedTurn, tokens: number, perMTok: (row: { input: number; cacheRead: number }) => number): number => {
238  const row = priceRow(p.model)
239  return row ? (tokens * perMTok(row)) / 1_000_000 : 0
240}
241/** What re-writing a token costs over reading it from cache: 1.25 × input − cache read. */
242const rewriteOverRead = (row: { input: number; cacheRead: number }): number => 1.25 * row.input - row.cacheRead
243/** What every cache write costs more under 1h (2.0 × input) than under 5m (1.25 × input). */
244const longTtlPremium = (row: { input: number }): number => 0.75 * row.input
245
246/** A TTL recommendation needs a net saving above this to be worth a line. */
247const MIN_SAVING_USD = 0.5
248
249/**
250 * cache-ttl-1h: every turn under 5m and ≥ 30 % of turns paused. A paused turn's
251 * avoidable rewrite is min(cw, maxCtx), the most its chain had cached; against
252 * that, every turn that was not paused would pay the 1h premium on its writes.
253 */
254export const recCacheTtl1h = (f: SessionFacts): Recommendation | null => {
255  if (f.ttl !== '5m' || f.turns.length === 0) return null
256  const paused = pausedTurns(f.turns)
257  if (paused.size / f.turns.length < 0.3) return null
258  let saving = 0
259  for (const p of f.turns) {
260    const maxCtx = paused.get(p)
261    saving += maxCtx !== undefined ? atRate(p, Math.min(count(p.turn.cw), maxCtx), rewriteOverRead) : -atRate(p, count(p.turn.cw), longTtlPremium)
262  }
263  if (!(saving > MIN_SAVING_USD)) return null
264  return {
265    code: 'cache-ttl-1h',
266    text: `${paused.size} of ${f.turns.length} turns started more than 5 minutes after the one before; assuming the paused turns re-wrote their context, a 1-hour cache TTL would have saved about ${money(saving)} here (estimate).`,
267    estSavingUsd: saving,
268  }
269}
270
271/**
272 * cache-ttl-5m: every turn under 1h and < 10 % of turns paused. Under 5m each
273 * paused turn would have had to re-write everything it read (its cache read is
274 * already bounded by the cache that existed); every write would have saved
275 * the 1h premium.
276 */
277export const recCacheTtl5m = (f: SessionFacts): Recommendation | null => {
278  if (f.ttl !== '1h' || f.turns.length === 0) return null
279  const paused = pausedTurns(f.turns)
280  if (paused.size / f.turns.length >= 0.1) return null
281  let saving = 0
282  for (const p of f.turns) {
283    saving += atRate(p, count(p.turn.cw), longTtlPremium)
284    if (paused.has(p)) saving -= atRate(p, count(p.turn.cr), rewriteOverRead)
285  }
286  if (!(saving > MIN_SAVING_USD)) return null
287  return {
288    code: 'cache-ttl-5m',
289    text: `Only ${paused.size} of ${f.turns.length} turns came after a pause over 5 minutes; assuming the paused turns would re-write the context they read, the 5-minute cache TTL would have saved about ${money(saving)} here (estimate).`,
290    estSavingUsd: saving,
291  }
292}
293
294/** The Opus and Fable price tiers (Mythos rows carry Fable's prices). */
295export const isTopTier = (model: string | null): boolean => /^claude-(opus|fable|mythos)(-|$)|^claude-3-opus/.test(priceRow(model)?.prefix ?? '')
296
297/**
298 * agent-tier: a subagent with ≥ 15 turns on an Opus- or Fable-tier model,
299 * short answers (median output < 400 tokens) and ≥ 20 % of the session's
300 * estimate; quotes its same tokens at Sonnet prices. The main loop is the
301 * person's own model choice and is never named.
302 */
303export const recAgentTier = (f: SessionFacts): Recommendation[] => {
304  const out: Recommendation[] = []
305  if (f.estUsd === null || !(f.estUsd > 0)) return out
306  for (const a of f.agents) {
307    if (a.agentKey === '' || a.agentKey === OTHERS || a.turns < 15 || a.estUsd === null || !isTopTier(a.model)) continue
308    if (a.estUsd < 0.2 * f.estUsd) continue
309    const mine = f.turns.filter(p => p.turn.agentKey === a.agentKey)
310    const mid = median(mine.map(p => count(p.turn.out)))
311    if (mid === null || mid >= 400) continue
312    let sonnet = 0
313    for (const p of mine) if (p.usd !== null) sonnet += priceTurn({ ...p.turn, model: SONNET_MODEL }, p.ttl).usd ?? 0
314    const saving = a.estUsd - sonnet
315    if (!(saving > 0)) continue
316    out.push({ code: 'agent-tier', text: `Agent ${/^[0-9a-f]{8}$/.test(a.agentKey) ? a.agentKey : '(unrecognized agent)'} ran ${a.turns} turns on ${modelText(a.model)} with a median of ${Math.round(mid)} output tokens; at Sonnet prices the same tokens would cost about ${money(sonnet)} instead of ${money(a.estUsd)} (estimate).`, estSavingUsd: saving })
317  }
318  return out
319}
320
321/** context-size: ≥ 20 main-loop turns re-reading a median of ≥ 150 000 cached tokens each. */
322export const recContextSize = (f: SessionFacts): Recommendation | null => {
323  const main = f.turns.filter(p => p.turn.agent === 0)
324  if (main.length < 20) return null
325  const mid = median(main.map(p => count(p.turn.cr)))
326  if (mid === null || mid < 150_000) return null
327  let reads = 0
328  for (const p of main) reads += atRate(p, count(p.turn.cr), row => row.cacheRead)
329  return { code: 'context-size', text: `The main loop re-read a median of ${compactInt(mid)} cached tokens on each of ${main.length} turns; cache reads cost ${money(reads)} of this session, so /compact or a fresh session for the next task would trim that (estimate).`, estSavingUsd: null }
330}
331
332/** errored-turns: ≥ 3 turns that ended in an error, and what they cost. */
333export const recErroredTurns = (f: SessionFacts): Recommendation | null => {
334  const errored = f.turns.filter(p => p.turn.reason === 'error')
335  if (errored.length < 3) return null
336  const usd = sumUsd(errored)
337  const text = usd === null ? `${errored.length} turns ended in an error; their models have no price row, so their cost is not estimated.` : `${errored.length} turns ended in an error and cost about ${money(usd)} with no answer to show for it (estimate).`
338  return { code: 'errored-turns', text, estSavingUsd: usd }
339}
340
341/** cost-per-hour: ≥ 0.25 h engaged, ≥ 5 sessions in scope, and at least twice the median estimate per engaged hour. */
342export const recCostPerHour = (f: SessionFacts, scope: ScopeFacts): Recommendation | null => {
343  const m = scope.medianUsdPerHour
344  if (f.engagedS < MIN_RATE_S || scope.sessionsInScope < 5 || m === null || !(m > 0) || f.estUsd === null) return null
345  const hours = f.engagedS / 3600
346  const rate = f.estUsd / hours
347  if (rate < 2 * m) return null
348  return { code: 'cost-per-hour', text: `This session cost ${money(rate)} per engaged hour, at least twice the median of ${money(m)} across your sessions this and last month (estimate).`, estSavingUsd: f.estUsd - m * hours }
349}
350
351/** Every rule over one session, in a fixed order. */
352export const recommendationsFor = (f: SessionFacts, scope: ScopeFacts): Recommendation[] =>
353  [recCacheTtl1h(f), recCacheTtl5m(f), ...recAgentTier(f), recContextSize(f), recErroredTurns(f), recCostPerHour(f, scope)].filter((r): r is Recommendation => r !== null)
354
355/** The key with the highest count; ties to the smaller key. */
356const mostUsed = (m: ReadonlyMap<string, number>): string | null => [...m.entries()].sort((a, b) => b[1] - a[1] || cmp(a[0], b[0]))[0]?.[0] ?? null
357const byUsd = (a: { estUsd: number | null }, b: { estUsd: number | null }): number => (b.estUsd ?? -1) - (a.estUsd ?? -1)
358/** A sum of estimates where null means "none priced": null only when every part is null. */
359const sumNullable = (values: readonly (number | null)[]): number | null => (values.length > 0 && values.every(v => v === null) ? null : values.reduce<number>((s, v) => s + (v ?? 0), 0))
360
361/** One agent's row: its turns, most-used model, tokens, estimate and share of the session's estimate. */
362const agentOf = (agentKey: string, ps: readonly PricedTurn[], sessionUsd: number | null): AgentSummary => {
363  const t = zeroTokens()
364  const used = new Map<string, number>()
365  for (const p of ps) {
366    addTokens(t, p.turn)
367    used.set(p.model, (used.get(p.model) ?? 0) + 1)
368  }
369  const usd = sumUsd(ps)
370  return { agentKey, merged: 0, turns: ps.length, model: mostUsed(used), tokens: t, estUsd: usd, share: usd !== null && sessionUsd !== null && sessionUsd > 0 ? usd / sessionUsd : 0 }
371}
372
373/** The main loop, then the AGENTS_CAP subagents with the largest estimates, then one `others` row folding the rest. */
374const capAgents = (all: AgentSummary[]): AgentSummary[] => {
375  const main = all.filter(a => a.agentKey === '')
376  const subs = all.filter(a => a.agentKey !== '').sort((a, b) => byUsd(a, b) || cmp(a.agentKey, b.agentKey))
377  if (subs.length <= AGENTS_CAP) return [...main, ...subs]
378  const rest = subs.slice(AGENTS_CAP)
379  const tokens = zeroTokens()
380  for (const a of rest) {
381    tokens.in += a.tokens.in
382    tokens.out += a.tokens.out
383    tokens.cr += a.tokens.cr
384    tokens.cw += a.tokens.cw
385  }
386  const others: AgentSummary = { agentKey: OTHERS, merged: rest.length, turns: rest.reduce((s, a) => s + a.turns, 0), model: null, tokens, estUsd: sumNullable(rest.map(a => a.estUsd)), share: rest.reduce((s, a) => s + a.share, 0) }
387  return [...main, ...subs.slice(0, AGENTS_CAP), others]
388}
389
390/** The MODELS_CAP models with the largest estimates, then one `others` row folding the rest. */
391const capModels = (all: SessionSummary['models']): SessionSummary['models'] => {
392  const sorted = all.slice().sort((a, b) => byUsd(a, b) || cmp(a.model, b.model))
393  if (sorted.length <= MODELS_CAP) return sorted
394  const rest = sorted.slice(MODELS_CAP)
395  return [...sorted.slice(0, MODELS_CAP), { model: OTHERS, turns: rest.reduce((s, m) => s + m.turns, 0), estUsd: sumNullable(rest.map(m => m.estUsd)), merged: rest.length }]
396}
397
398/**
399 * A session's engine figure, the one number the Session view and
400 * the Sessions row all show: the engine's running total in the latest
401 * segment (what /cost prints in its most recent process), less the total the
402 * first segment opened with when that segment began a `/clear` or a fork,
403 * since that part belongs to the session it came from. A first segment opened
404 * `unknown` means recording began mid-session, so its earlier cost is this
405 * session's and stays. Null while the latest segment has no figure.
406 */
407export const sessionEngineUsd = (group: readonly SessionRecord[]): number | null => {
408  const first = group[0]
409  const latest = group[group.length - 1]
410  if (!first || !latest || latest.engineCostUsd === null) return null
411  const inherited = (first.source === 'clear' || first.source === 'fork') && first.engineCostAtOpen !== null ? first.engineCostAtOpen : 0
412  return Math.max(0, latest.engineCostUsd - inherited)
413}
414
415/** The compact row the Sessions view lists. */
416export const sessionRow = (s: SessionSummary): SessionRow => ({
417  sessionId: s.sessionId,
418  id8: s.id8,
419  openedAt: s.openedAt,
420  lastAt: s.lastAt,
421  repoKey: s.repoKey,
422  engagedS: s.engagedS,
423  turns: s.turnsMain + s.turnsAgent,
424  agentCount: s.agents.filter(a => a.agentKey !== '').reduce((n, a) => n + (a.agentKey === OTHERS ? a.merged : 1), 0),
425  engineUsd: s.engineUsd,
426  estUsd: s.estUsd,
427  recCount: s.recommendations.length,
428})
429
430/**
431 * Every session among the segments as a SessionSummary, newest first and not
432 * capped, plus the median estimate per engaged hour over sessions with at
433 * least MIN_RATE_S engaged. The recommendations are filled in once that
434 * median is known, since the cost-per-hour rule reads it. Groups are built
435 * with push, never spreads: a month can hold tens of thousands of turns.
436 */
437export const summarizeSessions = (segments: readonly SessionRecord[], params: UnionParams, zone: string, defaultTtl: '5m' | '1h'): { sessions: SessionSummary[]; medianUsdPerHour: number | null } => {
438  const groups = new Map<string, SessionRecord[]>()
439  for (const r of segments) pushTo(groups, r.sessionId, r)
440  const built: { summary: SessionSummary; facts: SessionFacts }[] = []
441  for (const [sessionId, group] of groups) {
442    group.sort((a, b) => a.openedAt - b.openedAt || cmp(a.seg, b.seg))
443    const admitted: UnionEvent[] = []
444    const turns: PricedTurn[] = []
445    let lastAt = Number.NEGATIVE_INFINITY
446    let repoKey: string | null = null
447    let entrypoint: string | null = null
448    for (const r of group) {
449      lastAt = Math.max(lastAt, r.openedAt, r.closedAt ?? Number.NEGATIVE_INFINITY)
450      if (r.repoKey !== null) repoKey = r.repoKey
451      if (r.entrypoint !== null) entrypoint = r.entrypoint
452      for (const ev of r.events) {
453        if (Number.isFinite(ev.ts)) lastAt = Math.max(lastAt, ev.ts)
454        if (ev.admit === 1) admitted.push({ ts: ev.ts, sessionId: r.sessionId, seg: r.seg, seq: ev.seq })
455      }
456      for (const t of r.turns) {
457        if (!Number.isFinite(t.ts)) continue
458        if (Number.isFinite(t.endTs)) lastAt = Math.max(lastAt, t.endTs)
459        const model = oneLine(t.model ?? r.model ?? 'unknown', CAP.model) || 'unknown'
460        const ttl = ttlAt(r, t.ts) ?? defaultTtl
461        turns.push({ turn: t, model, ttl, usd: priceTurn({ ...t, model }, ttl).usd })
462      }
463    }
464    turns.sort((a, b) => a.turn.ts - b.turn.ts || a.turn.endTs - b.turn.endTs || cmp(a.turn.turnId, b.turn.turnId))
465    const engagedS = buildBlocks(admitted, params, zone).reduce((s, b) => s + b.seconds, 0)
466    const tokens = zeroTokens()
467    for (const p of turns) addTokens(tokens, p.turn)
468    const estUsd = sumUsd(turns)
469
470    const byModel = new Map<string, PricedTurn[]>()
471    const byAgent = new Map<string, PricedTurn[]>()
472    let mainTurns = 0
473    for (const p of turns) {
474      pushTo(byModel, p.model, p)
475      pushTo(byAgent, p.turn.agentKey, p)
476      if (p.turn.agent === 0) mainTurns += 1
477    }
478    const allAgents = [...byAgent.entries()].map(([agentKey, ps]) => agentOf(agentKey, ps, estUsd))
479    const ttls = new Set(turns.map(p => p.ttl))
480    const first = group[0] as SessionRecord
481    const summary: SessionSummary = {
482      sessionId,
483      id8: sessionId.slice(0, 8),
484      openedAt: first.openedAt,
485      lastAt: Number.isFinite(lastAt) ? lastAt : first.openedAt,
486      repoKey,
487      entrypoint,
488      segments: group.length,
489      engagedS,
490      prompts: admitted.length,
491      turnsMain: mainTurns,
492      turnsAgent: turns.length - mainTurns,
493      tokens,
494      estUsd,
495      unpricedTurns: turns.filter(p => p.usd === null).length,
496      engineUsd: sessionEngineUsd(group),
497      models: capModels([...byModel.entries()].map(([model, ps]) => ({ model, turns: ps.length, estUsd: sumUsd(ps), merged: 0 }))),
498      agents: capAgents(allAgents),
499      recommendations: [],
500    }
501    // The rules read every agent, not the capped list.
502    built.push({ summary, facts: { turns, ttl: ttls.size === 1 ? (turns[0] as PricedTurn).ttl : ttls.size === 0 ? defaultTtl : null, engagedS, estUsd, agents: allAgents } })
503  }
504  const rates = built.filter(b => b.summary.engagedS >= MIN_RATE_S && b.summary.estUsd !== null).map(b => (b.summary.estUsd as number) / (b.summary.engagedS / 3600))
505  const medianUsdPerHour = median(rates)
506  const scope: ScopeFacts = { sessionsInScope: built.length, medianUsdPerHour }
507  for (const b of built) b.summary.recommendations = recommendationsFor(b.facts, scope)
508  const sessions = built.map(b => b.summary).sort((a, b) => b.openedAt - a.openedAt || cmp(a.sessionId, b.sessionId))
509  return { sessions, medianUsdPerHour }
510}
511
512/**
513 * Every ISO week from the one holding the first of last month to the one
514 * holding `today`, oldest first: the weeks the R&D view's trend lists. A
515 * week's confirmation is known where its review file was passed in (the
516 * register reads this and last week's).
517 */
518export const scopeWeeks = (today: string): string[] => {
519  const first = `${previousMonth(today.slice(0, 7))}-01`
520  const last = isoWeek(today)
521  const out: string[] = []
522  // Stepping by a week from the first in-scope day visits every ISO week once; the guard bounds a malformed date.
523  for (let d = first, i = 0; i < 12; d = addDays(d, 7), i += 1) {
524    const w = isoWeek(d)
525    if (w > last) break
526    if (out[out.length - 1] !== w) out.push(w)
527    if (w === last) break
528  }
529  if (out[out.length - 1] !== last) out.push(last)
530  return out
531}
532
533/** The claim classes in the order the R&D view's activity mix lists them. */
534export const ACTIVITY_ORDER: readonly ActivityHours['activity'][] = ['direct_research', 'direct_support', 'non_qualified', 'unclassified']
535
536/**
537 * The R&D view's figures (SPEC §6.1): per-day net and gross, the weekly
538 * trend, this week by entity, component and claim class, the flagged days and
539 * the longest days. Net is always cut from the one allocation table, so every
540 * breakdown adds up to the week total; gross is the first-to-last span of
541 * each day's admitted events (context only, never a claim basis).
542 */
543const rdFigures = (o: {
544  today: string
545  week: string
546  lastWeek: string
547  admitted: readonly UnionEvent[]
548  allocations: readonly Allocation[]
549  weekRows: readonly WeekRow[]
550  flagList: readonly Flag[]
551  reviews: ReadonlyMap<string, ReviewFile>
552  zone: string
553}) => {
554  const net = new Map<string, number>()
555  for (const a of o.allocations) add(net, a.date, a.seconds)
556  const span = new Map<string, { first: number; last: number }>()
557  for (const ev of o.admitted) {
558    if (!Number.isFinite(ev.ts)) continue
559    const date = localDate(ev.ts, o.zone)
560    const s = span.get(date)
561    if (!s) span.set(date, { first: ev.ts, last: ev.ts })
562    else {
563      s.first = Math.min(s.first, ev.ts)
564      s.last = Math.max(s.last, ev.ts)
565    }
566  }
567  const grossOf = (date: string): number => {
568    const s = span.get(date)
569    return s ? Math.floor((s.last - s.first) / 1000) : 0
570  }
571  const day = (date: string): DayFigure => ({ date, netS: net.get(date) ?? 0, grossS: grossOf(date) })
572
573  const byWeek = new Map<string, { netS: number; grossS: number }>()
574  for (const date of new Set([...net.keys(), ...span.keys()])) {
575    const w = isoWeek(date)
576    const acc = byWeek.get(w) ?? { netS: 0, grossS: 0 }
577    acc.netS += net.get(date) ?? 0
578    acc.grossS += grossOf(date)
579    byWeek.set(w, acc)
580  }
581  const weekTrend: WeekTrendRow[] = scopeWeeks(o.today)
582    .reverse()
583    .map(w => {
584      const review = o.reviews.get(w)
585      return { week: w, netS: byWeek.get(w)?.netS ?? 0, grossS: byWeek.get(w)?.grossS ?? 0, confirmed: review ? latestConfirmed(review) !== null : null }
586    })
587
588  const dates = weekDates(o.week)
589  const inWeek = new Set(dates)
590  const entities = new Map<string, number>()
591  for (const a of o.allocations) if (inWeek.has(a.date)) add(entities, SENTINELS.has(a.component) ? a.component : a.entity, a.seconds)
592  const byEntity: EntityHours[] = [...entities.entries()].map(([entity, seconds]) => ({ entity, seconds })).sort((a, b) => b.seconds - a.seconds || cmp(a.entity, b.entity))
593
594  const comps = new Map<string, { seconds: number; entities: Map<string, number>; activities: Set<Activity> }>()
595  const mix = new Map<ActivityHours['activity'], { seconds: number; poeS: number }>(ACTIVITY_ORDER.map(k => [k, { seconds: 0, poeS: 0 }]))
596  for (const r of o.weekRows) {
597    const c = comps.get(r.component) ?? { seconds: 0, entities: new Map<string, number>(), activities: new Set<Activity>() }
598    c.seconds += r.seconds
599    add(c.entities, r.entity, r.seconds)
600    if (r.seconds > 0) c.activities.add(r.activity)
601    comps.set(r.component, c)
602    const bucket = SENTINELS.has(r.component)
603    // Unmapped time has no claim class yet; ignored and non-R&D time is non-qualified by definition.
604    const cls: ActivityHours['activity'] = r.component === UNMAPPED ? 'unclassified' : bucket ? 'non_qualified' : r.activity
605    const m = mix.get(cls) as { seconds: number; poeS: number }
606    m.seconds += r.seconds
607    if (!bucket && r.poe) m.poeS += r.seconds
608  }
609  const byComponent: ComponentHours[] = [...comps.entries()]
610    .filter(([, c]) => c.seconds > 0)
611    .map(([component, c]) => {
612      const acts = [...c.activities]
613      return { component, entity: dominant(c.entities), activity: SENTINELS.has(component) ? null : acts.length === 1 ? (acts[0] as Activity) : acts.length === 0 ? null : ('mixed' as const), seconds: c.seconds }
614    })
615    .sort((a, b) => b.seconds - a.seconds || cmp(a.component, b.component))
616  const activityMix: ActivityHours[] = ACTIVITY_ORDER.map(activity => ({ activity, ...(mix.get(activity) as { seconds: number; poeS: number }) }))
617
618  const seen = new Set<string>()
619  const flaggedDays: FlaggedDay[] = []
620  for (const f of o.flagList) {
621    const key = `${f.date}|${f.kind}`
622    if (seen.has(key)) continue
623    seen.add(key)
624    flaggedDays.push({ date: f.date, reason: f.kind, netS: net.get(f.date) ?? 0, grossS: grossOf(f.date) })
625  }
626  // Newest first; within a day the flags keep union.ts's fixed order (a stable sort).
627  flaggedDays.sort((a, b) => cmp(b.date, a.date))
628
629  const longestDays = dates
630    .map(day)
631    .filter(d => d.netS > 0)
632    .sort((a, b) => b.netS - a.netS || cmp(a.date, b.date))
633    .slice(0, 3)
634  const sumOver = (ds: readonly string[], f: (d: string) => number): number => ds.reduce((s, d) => s + f(d), 0)
635  return {
636    weekGrossS: sumOver(dates, grossOf),
637    todayGrossS: grossOf(o.today),
638    lastWeekTotalS: sumOver(weekDates(o.lastWeek), d => net.get(d) ?? 0),
639    weekTrend,
640    byEntity,
641    byComponent,
642    activityMix,
643    flaggedDays,
644    longestDays,
645  }
646}
647
648/**
649 * CONTRACT (W1). The whole rollup: the current ISO week's rows (status from
650 * review.ts `rowStatus`, frozen rows missing live reported as `changed` with
651 * zero seconds), the queue (one row per repoRef unmapped this week, with
652 * `isNewLocation`), today's seconds, spend for this and last month, the
653 * engine cost sum, flags, review statuses for this and last week, day totals,
654 * and Health (denied by reason, redactions, rows dropped, counts).
655 *
656 * Scope: rows, queue, day totals and the week sums cover the ISO week holding
657 * today; reviews cover this and last week; spend and the engine cost go by
658 * month; flags and Health cover every segment passed in. Health's rowsDropped
659 * counts events and turns past the caps plus rows a decode found malformed.
660 */
661export const computeRollup = (input: RollupInput): Rollup => {
662  const { now, zone, params, mapping, segments } = input
663  const today = localDate(now, zone)
664  const week = isoWeek(today)
665  const lastWeek = isoWeek(addDays(today, -7))
666  const month = monthOfTs(now, zone)
667  const lastMonth = previousMonth(month)
668
669  const byKey = new Map<string, SessionRecord>()
670  const admitted: UnionEvent[] = []
671  for (const r of segments) {
672    byKey.set(segmentKey(r.sessionId, r.seg), r)
673    for (const ev of r.events) if (ev.admit === 1) admitted.push({ ts: ev.ts, sessionId: r.sessionId, seg: r.seg, seq: ev.seq })
674  }
675  const blocks = buildBlocks(admitted, params, zone)
676  const blocksByStart = new Map(blocks.map(b => [b.startTs, b] as const))
677  const allocations = allocate(blocks, byKey, mapping)
678
679  const reviewOf = (w: string): ReviewFile => input.reviews.get(w) ?? emptyReview(w)
680  const weekRows = weekRowsFor(week, allocations, blocksByStart, byKey, mapping, reviewOf(week), zone)
681  const lastWeekRows = weekRowsFor(lastWeek, allocations, blocksByStart, byKey, mapping, reviewOf(lastWeek), zone)
682  const changed = (rows: readonly WeekRow[]): number => rows.filter(r => r.status === 'changed').length
683
684  const dates = weekDates(week)
685  const dayTotals: Record<string, number> = Object.fromEntries(dates.map(d => [d, 0]))
686  let weekUnmappedS = 0
687  let weekIgnoredS = 0
688  let todayS = 0
689  for (const a of allocations) {
690    if (a.date === today) todayS += a.seconds
691    if (!Object.prototype.hasOwnProperty.call(dayTotals, a.date)) continue
692    dayTotals[a.date] = (dayTotals[a.date] as number) + a.seconds
693    if (a.component === UNMAPPED) weekUnmappedS += a.seconds
694    if (a.component === IGNORED) weekIgnoredS += a.seconds
695  }
696
697  const spend = spendByMonth(segments, mapping, input.defaultTtl, zone)
698  const spendMonth = spend.filter(r => r.month === month)
699  const spendLastMonth = spend.filter(r => r.month === lastMonth)
700
701  let engineUsd = 0
702  let engineSegments = 0
703  for (const r of segments) {
704    const own = ownEngineCost(r)
705    if (own === null || monthOfTs(r.openedAt, zone) !== month) continue
706    engineUsd += own
707    engineSegments += 1
708  }
709  const engineCost: EngineCost | null = engineSegments > 0 ? { usd: engineUsd, segments: engineSegments } : null
710
711  const denied = new Map<string, number>()
712  let redactions = 0
713  let rowsDropped = 0
714  for (const r of segments) {
715    for (const ev of r.events) if (ev.admit !== 1) add(denied, ev.reason, 1)
716    redactions += r.redactions
717    rowsDropped += r.eventsDropped + r.turnsDropped + r.malformedRows
718  }
719
720  const flagList = flags(blocks, segments, params, zone)
721  // This and last week always have a review (an empty one when no file exists); other weeks only when passed in.
722  const known = new Map<string, ReviewFile>([...input.reviews, [week, reviewOf(week)], [lastWeek, reviewOf(lastWeek)]])
723  const rd = rdFigures({ today, week, lastWeek, admitted, allocations, weekRows, flagList, reviews: known, zone })
724
725  const summarized = summarizeSessions(segments, params, zone, input.defaultTtl)
726  const shown = summarized.sessions.slice(0, SESSIONS_CAP)
727  const thisSession = input.current ? (summarized.sessions.find(x => x.sessionId === input.current?.sessionId) ?? null) : null
728  // Full summaries only where a view can show one now: this session, the selected one, the newest rows.
729  const detailIds = new Set(shown.slice(0, DETAILS_ROWS).map(x => x.sessionId))
730  if (thisSession) detailIds.add(thisSession.sessionId)
731  if (input.selected) detailIds.add(input.selected)
732  const details: Record<string, SessionSummary> = {}
733  for (const x of shown) if (detailIds.has(x.sessionId)) details[x.sessionId] = x
734  if (thisSession) details[thisSession.sessionId] = thisSession
735
736  return {
737    at: now,
738    zone,
739    method: methodString(params, zone),
740    params,
741    week,
742    today,
743    weekRows,
744    weekTotalS: Object.values(dayTotals).reduce((a, b) => a + b, 0),
745    weekUnmappedS,
746    weekIgnoredS,
747    todayS,
748    queue: queueFor(new Set(dates), allocations, byKey, mapping, zone),
749    spendMonth,
750    spendLastMonth,
751    spendMonthUsd: monthUsd(spendMonth),
752    engineCost,
753    deadlines: input.deadlines,
754    reviews: [reviewStatus(reviewOf(week), changed(weekRows)), reviewStatus(reviewOf(lastWeek), changed(lastWeekRows))],
755    flags: flagList,
756    health: {
757      zone,
758      zoneDetectedNow: input.health.zoneDetectedNow,
759      pricesAsOf: PRICES_AS_OF,
760      sessionsInScope: new Set(segments.map(r => r.sessionId)).size,
761      segmentsInScope: segments.length,
762      unreadable: input.health.unreadable,
763      deniedByReason: Object.fromEntries([...denied.entries()].sort((a, b) => cmp(a[0], b[0]))),
764      redactions,
765      rowsDropped,
766      daysSinceExport: input.health.daysSinceExport,
767      storeIsLink: input.health.storeIsLink,
768      lockStaleMs: input.health.lockStaleMs,
769      bridge: input.health.bridge,
770      readOnly: input.health.readOnly,
771    },
772    dayTotals,
773    sessions: shown.map(sessionRow),
774    details,
775    thisSessionId: thisSession?.sessionId ?? null,
776    medianUsdPerHour: summarized.medianUsdPerHour,
777    engineUsd: thisSession?.engineUsd ?? null,
778    ...rd,
779    entityIds: Object.keys(mapping.entities).sort(cmp),
780  }
781}
782
hooks/store.ts 418 lines
1// rd-ledger: the store format (SPEC v1.1 §3). Pure: segment constructors,
2// bounded updaters, encode/decode with shape validation (unknown keys kept in
3// `extra`, rows never truncated on decode), the admission table, and the path
4// builders. Every string that enters a record goes through cleanField here, so
5// a caller cannot store a line break, a token or an email by mistake. No `$`.
6
7import type { EngagementEvent, RecordSource, SessionRecord, TurnRecord, UnionParams } from '../types'
8import { sha256 } from './hash'
9import { cleanField, isSeg, isSessionId, oneLine, sha8 } from './text'
10
11export const STORE_VERSION = 1 as const
12/**
13 * $.fs.read refuses a file over 4 MiB and the session itself must be able to
14 * reload its segment. A turn is about 190 bytes as JSON and an event about 90,
15 * so 8,000 of each keeps a segment near 2.3 MB; past a cap the counter grows
16 * instead of the array (panel SEN-3). Decode never truncates (DBM-6).
17 */
18export const MAX_EVENTS = 8_000
19export const MAX_TURNS = 8_000
20export const MAX_BRANCH_CHANGES = 50
21export const MAX_TTL_CHANGES = 50
22/** rd-tracker's canonical union-v2 parameters (rdt/config.py 420-440). */
23export const DEFAULT_PARAMS: UnionParams = { gapS: 1800, floorS: 60, maxBlockS: 14_400, dayFlagH: 14 }
24/** The entrypoints rd-tracker admits as human (policy.py); the mod matches them. */
25export const HUMAN_ENTRYPOINTS: ReadonlySet<string> = new Set(['claude-desktop', 'cli'])
26/** The sentinel buckets an allocation can land in besides a component id. */
27export const UNMAPPED = '__unmapped__'
28export const IGNORED = '__ignored__'
29export const NON_RD = '__non_rd__'
30
31export const CAP = { entrypoint: 40, surface: 20, repoKey: 100, cwdBase: 100, branch: 200, model: 80, origin: 40, reason: 60, turnId: 80, reasonWord: 20, name: 80, note: 300, stdout: 200 } as const
32
33const SOURCES: ReadonlySet<string> = new Set(['startup', 'resume', 'clear', 'fork', 'unknown'])
34const REASONS: ReadonlySet<string> = new Set(['answer', 'aborted', 'refusal', 'error'])
35
36const finite = (n: unknown): number => (typeof n === 'number' && Number.isFinite(n) ? n : 0)
37const count = (n: unknown): number => Math.max(0, Math.floor(finite(n)))
38const isRecordObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
39const str = (x: unknown): string | null => (typeof x === 'string' ? x : null)
40
41/** A cleaned field, with the redaction counted on the record. */
42const take = (r: { redactions: number }, value: string | null | undefined, cap: number, isEngineId = false): string | null => {
43  const out = cleanField(value, cap, { isEngineId })
44  if (out.redacted) r.redactions += 1
45  return out.value
46}
47
48/** The segment id for an open time: base-36 ms. */
49export const segOf = (openedAt: number): string => Math.max(0, Math.floor(finite(openedAt))).toString(36)
50
51export type NewSegmentInput = {
52  sessionId: string
53  openedAt: number
54  source: RecordSource
55  entrypoint: string | null
56  interactive: boolean
57  surface: string | null
58  repoKey: string | null
59  repoKind: 'repo' | 'dir'
60  rootHash: string | null
61  cwdBase: string
62  branch: string | null
63  model: string | null
64  engineCostAtOpen: number | null
65}
66
67export const newSegment = (input: NewSegmentInput): SessionRecord => {
68  const r: SessionRecord = {
69    v: STORE_VERSION,
70    sessionId: input.sessionId,
71    seg: segOf(input.openedAt),
72    openedAt: finite(input.openedAt),
73    closedAt: null,
74    closeReason: null,
75    source: SOURCES.has(input.source) ? input.source : 'unknown',
76    entrypoint: null,
77    interactive: input.interactive === true,
78    surface: null,
79    repoKey: null,
80    repoKind: input.repoKind === 'repo' ? 'repo' : 'dir',
81    rootHash: input.rootHash && /^[0-9a-f]{12}$/.test(input.rootHash) ? input.rootHash : null,
82    cwdBase: 'unknown',
83    branch: null,
84    branchChanges: [],
85    model: null,
86    cacheTtlChanges: [],
87    nextSeq: 1,
88    events: [],
89    turns: [],
90    eventsDropped: 0,
91    turnsDropped: 0,
92    malformedRows: 0,
93    redactions: 0,
94    // Startup starts the engine's ledger at zero; a resumed, cleared or forked process inherits a figure (DBM-12).
95    engineCostAtOpen: input.source === 'startup' ? 0 : typeof input.engineCostAtOpen === 'number' && Number.isFinite(input.engineCostAtOpen) ? input.engineCostAtOpen : null,
96    engineCostUsd: null,
97    extra: {},
98    updatedAt: finite(input.openedAt),
99  }
100  r.entrypoint = take(r, input.entrypoint, CAP.entrypoint)
101  r.surface = take(r, input.surface, CAP.surface)
102  r.repoKey = take(r, input.repoKey, CAP.repoKey)
103  r.cwdBase = take(r, input.cwdBase, CAP.cwdBase) ?? 'unknown'
104  r.branch = take(r, input.branch, CAP.branch)
105  // The opening branch is the first change point, so the branch in force on any day is always recoverable (panel W1 note).
106  if (r.branch !== null) r.branchChanges = [{ ts: r.openedAt, branch: r.branch }]
107  r.model = take(r, input.model, CAP.model)
108  return r
109}
110
111/** The admission verdict for a prompt submission (SPEC §4 table; CYB-1 adds the surface rule). */
112export const admitEvent = (originKind: string, entrypoint: string | null, surface: string | null): { admit: 0 | 1; reason: string } => {
113  const origin = oneLine(originKind, CAP.origin)
114  const human = entrypoint !== null && HUMAN_ENTRYPOINTS.has(entrypoint)
115  if (origin === 'composer' || origin === 'bridge') {
116    if (human) return { admit: 1, reason: 'human' }
117    return { admit: 0, reason: entrypoint === null ? 'entrypoint-unknown' : 'automation-entrypoint' }
118  }
119  if (origin === 'sdk') {
120    if (entrypoint === 'claude-desktop') return surface === 'desktop' ? { admit: 1, reason: 'human-desktop-host' } : { admit: 0, reason: 'sdk-nested' }
121    return { admit: 0, reason: entrypoint === null ? 'entrypoint-unknown' : 'automation-entrypoint' }
122  }
123  return { admit: 0, reason: `origin:${origin || 'unknown'}` }
124}
125
126/** A new segment with the event appended (bounded); the input is never mutated. */
127export const withEvent = (r: SessionRecord, input: { ts: number; origin: string; queued: boolean }, now: number): SessionRecord => {
128  if (r.events.length >= MAX_EVENTS) return { ...r, eventsDropped: r.eventsDropped + 1, updatedAt: now }
129  const next = { ...r }
130  // The origin is cleaned before the verdict, so a reason never carries a raw string (verifier V11).
131  const origin = take(next, input.origin, CAP.origin) ?? 'unknown'
132  const verdict = admitEvent(origin, r.entrypoint, r.surface)
133  const event: EngagementEvent = { seq: r.nextSeq, ts: finite(input.ts), origin, admit: verdict.admit, reason: verdict.reason, queued: input.queued ? 1 : 0 }
134  return { ...next, nextSeq: r.nextSeq + 1, events: [...r.events, event], updatedAt: now }
135}
136
137export type TurnInput = {
138  endTs: number
139  durationMs: number
140  turnId: string
141  agentId: string | null
142  model: string | null
143  usage: { input_tokens?: unknown; output_tokens?: unknown; cache_read_input_tokens?: unknown; cache_creation_input_tokens?: unknown } | null
144  reason: string
145}
146
147/** `sha8(agentId)` or '' for the main loop. */
148export const agentKeyOf = (agentId: string | null | undefined): string => (agentId ? sha8(agentId) : '')
149
150/** A new segment with the turn appended (bounded); a `(turnId, agentKey)` already recorded is not recorded twice. */
151export const withTurn = (r: SessionRecord, input: TurnInput, now: number): SessionRecord => {
152  const idOut = cleanField(input.turnId, CAP.turnId, { isEngineId: true })
153  const turnId = idOut.value ?? ''
154  const agentKey = agentKeyOf(input.agentId)
155  if (turnId !== '' && r.turns.some(t => t.turnId === turnId && t.agentKey === agentKey)) return r
156  if (r.turns.length >= MAX_TURNS) return { ...r, turnsDropped: r.turnsDropped + 1, updatedAt: now }
157  const next = { ...r, redactions: r.redactions }
158  const endTs = finite(input.endTs)
159  const turn: TurnRecord = {
160    seq: r.nextSeq,
161    ts: endTs - count(input.durationMs),
162    endTs,
163    turnId,
164    agentKey,
165    agent: agentKey === '' ? 0 : 1,
166    model: take(next, input.model, CAP.model),
167    in: count(input.usage?.input_tokens),
168    out: count(input.usage?.output_tokens),
169    cr: count(input.usage?.cache_read_input_tokens),
170    cw: count(input.usage?.cache_creation_input_tokens),
171    reason: (REASONS.has(input.reason) ? input.reason : 'error') as TurnRecord['reason'],
172  }
173  return { ...next, nextSeq: r.nextSeq + 1, turns: [...r.turns, turn], updatedAt: now }
174}
175
176export const withBranch = (r: SessionRecord, branch: string | null, now: number): SessionRecord => {
177  const next = { ...r }
178  const value = take(next, branch, CAP.branch)
179  if (value === r.branch) return r
180  const changes = r.branchChanges.length >= MAX_BRANCH_CHANGES ? r.branchChanges : [...r.branchChanges, { ts: finite(now), branch: value }]
181  return { ...next, branch: value, branchChanges: changes, updatedAt: now }
182}
183
184/** The surface, learnt late: the desktop app attaches after session.start, so the record's surface is null until a draw or a command names it. */
185export const withSurface = (r: SessionRecord, surface: string | null, now: number): SessionRecord => {
186  const next = { ...r }
187  const value = take(next, surface, CAP.surface)
188  if (value === null || value === r.surface) return r
189  return { ...next, surface: value, updatedAt: now }
190}
191
192export const withClose = (r: SessionRecord, reason: string, now: number): SessionRecord => {
193  const next = { ...r }
194  return { ...next, closedAt: finite(now), closeReason: take(next, reason, CAP.reasonWord), updatedAt: now }
195}
196
197/** The latest engine figure; a segment opened without one adopts the first figure it sees as its figure at open. */
198export const withCost = (r: SessionRecord, usd: number | null, now: number): SessionRecord => {
199  if (usd === null || !Number.isFinite(usd) || usd === r.engineCostUsd) return r
200  return { ...r, engineCostUsd: usd, engineCostAtOpen: r.engineCostAtOpen ?? (r.engineCostUsd ?? usd), updatedAt: now }
201}
202
203/** A dated TTL change point; the same TTL again, an unknown value, or a full list changes nothing. */
204export const withCacheTtl = (r: SessionRecord, ttl: unknown, now: number): SessionRecord => {
205  const value = ttl === '1h' || ttl === '5m' ? ttl : null
206  if (value === null) return r
207  const last = r.cacheTtlChanges[r.cacheTtlChanges.length - 1]
208  if (last && last.ttl === value) return r
209  if (r.cacheTtlChanges.length >= MAX_TTL_CHANGES) return r
210  return { ...r, cacheTtlChanges: [...r.cacheTtlChanges, { ts: finite(now), ttl: value }], updatedAt: now }
211}
212
213/** The segment's own engine cost: the latest figure less the figure at open; null when either is unknown. */
214export const ownEngineCost = (r: SessionRecord): number | null =>
215  r.engineCostUsd === null || r.engineCostAtOpen === null ? null : Math.max(0, r.engineCostUsd - r.engineCostAtOpen)
216
217/** The cache TTL in force at `ts`: the last change point at or before it, else null (the option applies). */
218export const ttlAt = (r: SessionRecord, ts: number): '5m' | '1h' | null => {
219  let found: '5m' | '1h' | null = null
220  for (const c of r.cacheTtlChanges) if (c.ts <= ts) found = c.ttl
221  return found
222}
223
224const KNOWN_KEYS: ReadonlySet<string> = new Set([
225  'v', 'sessionId', 'seg', 'openedAt', 'closedAt', 'closeReason', 'source', 'entrypoint', 'interactive', 'surface', 'repoKey', 'repoKind', 'rootHash', 'cwdBase', 'branch', 'branchChanges', 'model',
226  'cacheTtlChanges', 'nextSeq', 'events', 'turns', 'eventsDropped', 'turnsDropped', 'malformedRows', 'redactions', 'engineCostAtOpen', 'engineCostUsd', 'extra', 'updatedAt',
227])
228
229/** The segment as JSON: the known keys in a fixed order, then `extra` spread back at the top level. */
230/** A segment past this many bytes rolls over to a new one, so no file nears the 4 MiB read limit (verifier V6). */
231export const SEGMENT_BYTES_MAX = 3 * 1024 * 1024
232/** Checked every 250 rows (a row is at most a few hundred bytes): true when the encoded segment is past SEGMENT_BYTES_MAX. */
233export const isOversize = (r: SessionRecord): boolean => {
234  const rows = r.events.length + r.turns.length
235  if (rows === 0 || rows % 250 !== 0) return false
236  return new TextEncoder().encode(encodeSession(r)).length > SEGMENT_BYTES_MAX
237}
238
239export const encodeSession = (r: SessionRecord): string => {
240  const { extra, ...known } = r
241  const out: Record<string, unknown> = { ...known }
242  for (const key of Object.keys(extra)) if (!KNOWN_KEYS.has(key)) out[key] = extra[key]
243  return JSON.stringify(out)
244}
245
246const decodeEvent = (x: unknown, r: SessionRecord): EngagementEvent | null => {
247  if (!isRecordObject(x)) return null
248  const ts = x['ts']
249  if (typeof ts !== 'number' || !Number.isFinite(ts)) return null
250  return {
251    seq: count(x['seq']),
252    ts,
253    origin: oneLine(str(x['origin']) ?? '', CAP.origin),
254    admit: x['admit'] === 1 ? 1 : 0,
255    reason: take(r, str(x['reason']) ?? 'unknown', CAP.reason) ?? 'unknown',
256    queued: x['queued'] === 1 ? 1 : 0,
257  }
258}
259
260const decodeTurn = (x: unknown, r: SessionRecord): TurnRecord | null => {
261  if (!isRecordObject(x)) return null
262  const ts = x['ts']
263  const endTs = x['endTs']
264  if (typeof ts !== 'number' || !Number.isFinite(ts) || typeof endTs !== 'number' || !Number.isFinite(endTs)) return null
265  const reason = str(x['reason']) ?? ''
266  const agentKey = str(x['agentKey']) ?? ''
267  return {
268    seq: count(x['seq']),
269    ts,
270    endTs,
271    turnId: cleanField(str(x['turnId']) ?? '', CAP.turnId, { isEngineId: true }).value ?? '',
272    agentKey: /^[0-9a-f]{8}$/.test(agentKey) ? agentKey : '',
273    agent: x['agent'] === 1 ? 1 : 0,
274    model: take(r, str(x['model']), CAP.model),
275    in: count(x['in']),
276    out: count(x['out']),
277    cr: count(x['cr']),
278    cw: count(x['cw']),
279    reason: (REASONS.has(reason) ? reason : 'error') as TurnRecord['reason'],
280  }
281}
282
283/**
284 * A segment from its text, every field re-validated and re-cleaned (another
285 * build, or a hand edit, may have written it); null for anything that is not
286 * a v1 segment with a session id, a seg and an open time. Never throws. Rows
287 * are never truncated (caps bound growth only); rows of the wrong shape are
288 * counted in `malformedRows`; unknown top-level keys ride in `extra`. A newer
289 * `v` is refused (null), so an older build never rewrites a newer file.
290 */
291export const decodeSession = (text: string): SessionRecord | null => {
292  let raw: unknown
293  try {
294    raw = JSON.parse(text)
295  } catch {
296    return null
297  }
298  if (!isRecordObject(raw) || raw['v'] !== STORE_VERSION) return null
299  const sessionId = raw['sessionId']
300  if (typeof sessionId !== 'string' || !isSessionId(sessionId)) return null
301  const seg = raw['seg']
302  if (typeof seg !== 'string' || !isSeg(seg)) return null
303  const openedAt = raw['openedAt']
304  if (typeof openedAt !== 'number' || !Number.isFinite(openedAt)) return null
305  const r: SessionRecord = {
306    v: STORE_VERSION,
307    sessionId,
308    seg,
309    openedAt,
310    closedAt: typeof raw['closedAt'] === 'number' && Number.isFinite(raw['closedAt']) ? raw['closedAt'] : null,
311    closeReason: null,
312    source: typeof raw['source'] === 'string' && SOURCES.has(raw['source']) ? (raw['source'] as RecordSource) : 'unknown',
313    entrypoint: null,
314    interactive: raw['interactive'] === true,
315    surface: null,
316    repoKey: null,
317    repoKind: raw['repoKind'] === 'repo' ? 'repo' : 'dir',
318    rootHash: typeof raw['rootHash'] === 'string' && /^[0-9a-f]{12}$/.test(raw['rootHash']) ? raw['rootHash'] : null,
319    cwdBase: 'unknown',
320    branch: null,
321    branchChanges: [],
322    model: null,
323    cacheTtlChanges: [],
324    nextSeq: Math.max(1, count(raw['nextSeq'])),
325    events: [],
326    turns: [],
327    eventsDropped: count(raw['eventsDropped']),
328    turnsDropped: count(raw['turnsDropped']),
329    malformedRows: count(raw['malformedRows']),
330    redactions: count(raw['redactions']),
331    engineCostAtOpen: typeof raw['engineCostAtOpen'] === 'number' && Number.isFinite(raw['engineCostAtOpen']) ? raw['engineCostAtOpen'] : null,
332    engineCostUsd: typeof raw['engineCostUsd'] === 'number' && Number.isFinite(raw['engineCostUsd']) ? raw['engineCostUsd'] : null,
333    extra: {},
334    updatedAt: typeof raw['updatedAt'] === 'number' && Number.isFinite(raw['updatedAt']) ? raw['updatedAt'] : openedAt,
335  }
336  // Redactions found while decoding are not re-counted on the record: the stored counter is the writer's.
337  const scratch = { redactions: 0 }
338  r.closeReason = take(scratch, str(raw['closeReason']), CAP.reasonWord)
339  r.entrypoint = take(scratch, str(raw['entrypoint']), CAP.entrypoint)
340  r.surface = take(scratch, str(raw['surface']), CAP.surface)
341  r.repoKey = take(scratch, str(raw['repoKey']), CAP.repoKey)
342  r.cwdBase = take(scratch, str(raw['cwdBase']), CAP.cwdBase) ?? 'unknown'
343  r.branch = take(scratch, str(raw['branch']), CAP.branch)
344  r.model = take(scratch, str(raw['model']), CAP.model)
345  if (Array.isArray(raw['branchChanges'])) {
346    for (const c of raw['branchChanges']) {
347      if (!isRecordObject(c) || typeof c['ts'] !== 'number' || !Number.isFinite(c['ts'])) {
348        r.malformedRows += 1
349        continue
350      }
351      r.branchChanges.push({ ts: c['ts'], branch: take(scratch, str(c['branch']), CAP.branch) })
352    }
353  }
354  if (Array.isArray(raw['cacheTtlChanges'])) {
355    for (const c of raw['cacheTtlChanges']) {
356      if (!isRecordObject(c) || typeof c['ts'] !== 'number' || !Number.isFinite(c['ts']) || (c['ttl'] !== '5m' && c['ttl'] !== '1h')) {
357        r.malformedRows += 1
358        continue
359      }
360      r.cacheTtlChanges.push({ ts: c['ts'], ttl: c['ttl'] })
361    }
362  }
363  const scratchRecord = { redactions: 0 } as SessionRecord
364  if (Array.isArray(raw['events'])) {
365    for (const e of raw['events']) {
366      const ev = decodeEvent(e, scratchRecord)
367      if (ev) r.events.push(ev)
368      else r.malformedRows += 1
369    }
370  }
371  if (Array.isArray(raw['turns'])) {
372    for (const t of raw['turns']) {
373      const tr = decodeTurn(t, scratchRecord)
374      if (tr) r.turns.push(tr)
375      else r.malformedRows += 1
376    }
377  }
378  for (const key of Object.keys(raw)) if (!KNOWN_KEYS.has(key)) r.extra[key] = raw[key]
379  return r
380}
381
382// --- paths (HOME must be a plain absolute path; the caller checks) ----------------
383
384export const storeDir = (home: string): string => `${home.replace(/\/+$/, '')}/.claude/rd-ledger`
385export const timezonePath = (home: string): string => `${storeDir(home)}/timezone`
386export const saltPath = (home: string): string => `${storeDir(home)}/salt`
387export const mappingPath = (home: string): string => `${storeDir(home)}/components.json`
388export const mappingHistoryPath = (home: string): string => `${storeDir(home)}/mapping-history.ndjson`
389export const lockDir = (home: string): string => `${storeDir(home)}/.lock`
390export const bridgeLogPath = (home: string, year: string): string => `${storeDir(home)}/bridge-${year.slice(0, 4)}.ndjson`
391export const reviewsDir = (home: string): string => `${storeDir(home)}/reviews`
392export const reviewPath = (home: string, week: string): string => `${reviewsDir(home)}/${week}.json`
393export const sessionsDir = (home: string): string => `${storeDir(home)}/sessions`
394/** `sessions/YYYY/MM` for a `YYYY-MM` month. */
395export const monthDir = (home: string, yearMonth: string): string => `${sessionsDir(home)}/${yearMonth.slice(0, 4)}/${yearMonth.slice(5, 7)}`
396export const segmentPath = (home: string, yearMonth: string, sessionId: string, seg: string): string => `${monthDir(home, yearMonth)}/${sessionId}.${seg}.json`
397export const exportDir = (home: string, stamp: string): string => `${storeDir(home)}/export/${stamp}`
398/** The temp file a flush goes to before `mv`: beside the final file, named for the segment and the flush count (SEN-4). */
399export const tmpPath = (finalPath: string, seg: string, n: number): string => `${finalPath}.tmp.${seg.replace(/[^0-9a-z]/g, '')}.${Math.max(0, Math.floor(finite(n)))}`
400export const isTmpName = (name: string): boolean => /\.json\.tmp\.[0-9a-z]*\.\d+$/.test(name)
401/** `<uuid>.<seg>.json`, the only names the rollup reads. */
402export const isSegmentName = (name: string): boolean => {
403  const m = /^([0-9a-f-]{36})\.([0-9a-z]{1,16})\.json$/i.exec(name)
404  return m !== null && isSessionId(m[1] as string)
405}
406/** `true` for a plain absolute path with no `..` segment and no line break: what HOME must be. */
407export const isPlainAbsolute = (path: string | null | undefined): path is string =>
408  typeof path === 'string' && path.startsWith('/') && !/(^|\/)\.\.(\/|$)/.test(path) && !/[\n\r\0]/.test(path) && path.length < 1024
409/** The pinned input of the salted root hash (DBM-8, PRV-11): no newline, the root relative to HOME when inside it. */
410export const rootHashOf = (salt: string, root: string, home: string): string => {
411  const base = home.replace(/\/+$/, '')
412  const rel = root === base ? '~' : root.startsWith(`${base}/`) ? `~/${root.slice(base.length + 1)}` : root
413  return sha256(`${salt}|${rel}`).slice(0, 12)
414}
415/** A 32-hex salt from material only this install saw (no randomness API in the runtime). */
416export const makeSalt = (material: string): string => sha256(`rd-ledger-salt|${material}`).slice(0, 32)
417export const isSalt = (s: string): boolean => /^[0-9a-f]{32}$/.test(s)
418
hooks/text.ts 281 lines
1// rd-ledger: text discipline. Every string that comes from outside the mod (a
2// branch name, a repo basename, a model id, a name in components.json, rdt's
3// stdout, an environment variable) passes through cleanField before it is
4// stored, drawn, exported or placed in an argv: the leak scan runs over the
5// uncapped value first (a hit stores `[redacted:<kind>]`, rd-tracker's field()
6// rule), then oneLine with the field's cap. The scan and the CSV cell rule port
7// rd-tracker's rdt/sanitize.py plus the panel's additions, so the two records
8// refuse the same things. Pure: no `$`, no I/O.
9
10/**
11 * One printable line of at most `max` code points, NFKC-normalized: control
12 * characters and line separators become spaces, format characters (bidi
13 * overrides, zero-width, Unicode tags), surrogates and private-use characters
14 * are dropped, whitespace runs collapse. A crafted branch name cannot add a row
15 * to a table or a line to a toast, and cannot hide one with a bidi override.
16 */
17/**
18 * Invisible or blank characters outside Unicode's format class that still split
19 * a token or an address without being seen (verifier N3, V13): the combining
20 * grapheme joiner, every variation selector (U+FE00-FE0F, U+180B-180F,
21 * U+E0100-E01EF), the Hangul fillers, the braille blank, Khmer vowel inherent
22 * marks. oneLine is NFKC-normalized, so a full-width letter or sign becomes the
23 * plain one before any scan.
24 */
25const INVISIBLE = /[\u034F\uFE00-\uFE0F\u115F\u1160\u3164\uFFA0\u2800\u180B-\u180F\u17B4\u17B5\u{E0100}-\u{E01EF}]/gu
26
27export const oneLine = (text: string, max: number): string =>
28  [
29    ...String(text)
30      // NFKC: a full-width @ or digit becomes the plain one, so a scan cannot be dodged by width.
31      .normalize('NFKC')
32      .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ')
33      .replace(/[\p{Cf}\p{Cs}\p{Co}]/gu, '')
34      .replace(INVISIBLE, '')
35      .replace(/\s+/g, ' ')
36      .trim(),
37  ]
38    .slice(0, max)
39    .join('')
40    .trim()
41
42/** A session id as the engine mints them: a UUID (any version), either case. */
43export const isSessionId = (s: string): boolean =>
44  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)
45
46/** An entity, component or activity id: kebab-case, 1 to 64 characters, no leading dash. */
47export const isId = (s: string): boolean => /^[a-z0-9][a-z0-9-]{0,63}$/.test(s)
48
49/** A segment id as store.ts mints it: base-36 digits. */
50export const isSeg = (s: string): boolean => /^[0-9a-z]{1,16}$/.test(s)
51
52/** A calendar date `YYYY-MM-DD` (shape only; the calendar is tz.ts's business). */
53export const isDate = (s: string): boolean => /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(s)
54
55/** A month `YYYY-MM`. */
56export const isMonth = (s: string): boolean => /^\d{4}-(0[1-9]|1[0-2])$/.test(s)
57
58/** An ISO week `YYYY-Www`. */
59export const isWeek = (s: string): boolean => /^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/.test(s)
60
61export const basename = (path: string): string => {
62  const trimmed = String(path).replace(/\/+$/, '')
63  return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed
64}
65
66/**
67 * The branch from `git rev-parse --abbrev-ref HEAD`: its first line, printable
68 * ASCII, at most 200 characters; null for a detached HEAD, an empty answer or
69 * anything else (a branch is drawn and exported, so only a safe one is kept).
70 */
71export const cleanBranch = (out: string): string | null => {
72  const line = (String(out).trim().split('\n')[0] ?? '').trim()
73  if (line === '' || line === 'HEAD') return null
74  return /^[\x21-\x7e]{1,200}$/.test(line) ? line : null
75}
76
77/**
78 * The repository root's basename as the mapping key: one line, printable, at
79 * most 100 characters, never empty. `#` is reserved for the `key#hash` form and
80 * `|` for row keys, so both are replaced. The person's HOME reads `~` and `/`
81 * reads `root` (CYB-10: a session started in HOME must not store the user name).
82 */
83export const repoKeyOf = (root: string, home: string | null): string => {
84  const trimmed = String(root).replace(/\/+$/, '')
85  if (home !== null && trimmed === home.replace(/\/+$/, '')) return '~'
86  if (trimmed === '' || trimmed === '/') return 'root'
87  const key = oneLine(basename(trimmed), 100).replace(/[#|]/g, '_')
88  return key === '' ? 'unknown' : key
89}
90
91/** `repoKey#hash12`, or the bare key when the hash is unknown. */
92export const repoRef = (repoKey: string, rootHash: string | null): string => (rootHash ? `${repoKey}#${rootHash}` : repoKey)
93
94/** A pre-tag glob: a literal, or a prefix ending in one `*`; matched linearly, never a regex (CYB-9). */
95export const globMatch = (pattern: string, text: string): boolean => {
96  if (pattern.length === 0 || pattern.length > 100) return false
97  if (pattern.endsWith('*')) {
98    const prefix = pattern.slice(0, -1)
99    return !prefix.includes('*') && text.startsWith(prefix)
100  }
101  return !pattern.includes('*') && text === pattern
102}
103
104const CSV_TRIGGER = /^[=+\-@\t\r]/
105
106/**
107 * One CSV cell: line breaks become spaces, a leading `= + - @ TAB CR` gets a
108 * `'` prefix so Excel reads text, then the cell is quoted when it holds a
109 * quote, a comma or a space-only edge (rd-tracker's csv_cell plus csv.writer).
110 */
111export const csvCell = (value: string | number | boolean | null | undefined): string => {
112  let text = value === null || value === undefined ? '' : String(value)
113  text = text.replace(/\r/g, ' ').replace(/\n/g, ' ')
114  if (CSV_TRIGGER.test(text)) text = `'${text}`
115  return /[",]/.test(text) || text !== text.trim() ? `"${text.replace(/"/g, '""')}"` : text
116}
117
118/** One CSV line from its cells. */
119export const csvLine = (cells: readonly (string | number | boolean | null | undefined)[]): string => cells.map(csvCell).join(',')
120
121// --- the leak scan (rdt/sanitize.py, ported; panel CYB-11 additions marked) -------
122
123const SCAN_LIMIT = 64 * 1024
124
125const CREDENTIAL_PATTERNS: readonly [string, RegExp][] = [
126  ['anthropic-key', /sk-ant-[A-Za-z0-9_-]{12,}/],
127  ['openai-key', /\bsk-[A-Za-z0-9_-]{16,}/],
128  ['stripe-key', /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}/], // CYB-11
129  ['github-token', /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/],
130  ['github-pat', /\bgithub_pat_[A-Za-z0-9_]{20,}/],
131  ['gitlab-token', /\bglpat-[A-Za-z0-9_-]{16,}/], // CYB-11
132  ['npm-token', /\bnpm_[A-Za-z0-9]{30,}/], // CYB-11
133  ['google-api-key', /\bAIza[0-9A-Za-z_-]{30,}/], // CYB-11
134  ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/],
135  ['shopify-token', /\bshp(?:at|ss|ca|pa)_[A-Fa-f0-9]{32}/],
136  ['aws-access-key', /\bAKIA[0-9A-Z]{16}\b/],
137  ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
138  ['pem-block', /-----BEGIN [A-Z ]*(?:PRIVATE KEY|CERTIFICATE)/],
139  ['client-secret', /\bclient[_-]?secret\b/i],
140  ['bearer-token', /\bbearer\s+[A-Za-z0-9._-]{12,}/i],
141  ['ein', /\b\d{2}-\d{7}\b/],
142]
143
144const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g
145const NOREPLY_DOMAIN = 'users.noreply.github.com'
146const EXEMPT_EXACT = new Set(['noreply@anthropic.com'])
147const RESERVED_DOMAINS = ['example.com', 'example.net', 'example.org']
148const RESERVED_TLDS = new Set(['invalid', 'test', 'localhost', 'example'])
149
150const isEmailExempt = (address: string): boolean => {
151  if (EXEMPT_EXACT.has(address.toLowerCase())) return true
152  const domain = address.slice(address.lastIndexOf('@') + 1).toLowerCase().replace(/\.+$/, '')
153  if (domain === NOREPLY_DOMAIN || domain.endsWith(`.${NOREPLY_DOMAIN}`)) return true
154  if (RESERVED_DOMAINS.includes(domain) || RESERVED_DOMAINS.some(r => domain.endsWith(`.${r}`))) return true
155  return RESERVED_TLDS.has(domain.slice(domain.lastIndexOf('.') + 1))
156}
157
158/** Every non-exempt email address in `value`, in order. */
159export const findEmails = (value: string): string[] => {
160  const out: string[] = []
161  for (const m of String(value).slice(0, SCAN_LIMIT).matchAll(EMAIL_RE)) if (!isEmailExempt(m[0])) out.push(m[0])
162  return out
163}
164
165const TOKEN_RE = /[A-Za-z0-9+=_-]{32,}/g
166const HEX_DIGEST_RE = /^(?:[0-9a-f]{32,64}|[0-9A-F]{32,64})$/
167const ENTROPY_THRESHOLD = 4.0
168
169/** Bits of Shannon entropy per character; 0 for the empty string. */
170export const shannonEntropy = (value: string): number => {
171  if (value.length === 0) return 0
172  const counts = new Map<string, number>()
173  for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1)
174  const n = [...value].length
175  let sum = 0
176  for (const c of counts.values()) sum -= (c / n) * Math.log2(c / n)
177  return sum
178}
179
180const looksLikeOpaqueSecret = (token: string): boolean => {
181  if (HEX_DIGEST_RE.test(token)) return false
182  if (!(/\d/.test(token) && /[A-Z]/.test(token) && /[a-z]/.test(token))) return false
183  const segments = token.split('-')
184  if (segments.length >= 4 && segments.every(s => s.length <= 12)) {
185    const alpha = segments.filter(s => /^[a-z]+$/.test(s)).length
186    if (alpha * 2 >= segments.length) return false
187  }
188  return shannonEntropy(token) >= ENTROPY_THRESHOLD
189}
190
191/**
192 * Every deny-list reason `value` matches, in rule order: the named credential
193 * shapes, then `email-address`, then `high-entropy` (skipped with `isEngineId`,
194 * for ids that already passed a shape check). Empty when clean.
195 */
196export const findCredentials = (value: string, options: { isEngineId?: boolean } = {}): string[] => {
197  const scanned = String(value ?? '').slice(0, SCAN_LIMIT)
198  if (scanned === '') return []
199  const reasons: string[] = []
200  for (const [reason, re] of CREDENTIAL_PATTERNS) if (re.test(scanned)) reasons.push(reason)
201  if (findEmails(scanned).length > 0) reasons.push('email-address')
202  if (!options.isEngineId) {
203    for (const m of scanned.matchAll(TOKEN_RE)) {
204      if (looksLikeOpaqueSecret(m[0])) {
205        reasons.push('high-entropy')
206        break
207      }
208    }
209  }
210  return reasons
211}
212
213/** The first reason `value` is refused, or null when it is clean. */
214export const leakScan = (value: string): string | null => findCredentials(value)[0] ?? null
215
216export const isRedacted = (value: string): boolean => /^\[redacted:[a-z-]+\]$/.test(value)
217
218/**
219 * rd-tracker's `field()` rule at the storage boundary: the uncapped value is
220 * scanned first; a hit becomes `[redacted:<kind>]` (never the raw value, never a
221 * partial); a clean value becomes one line of at most `cap` code points. An
222 * empty result is null. `redacted` says whether the scan fired.
223 */
224export const cleanField = (value: string | null | undefined, cap: number, options: { isEngineId?: boolean } = {}): { value: string | null; redacted: boolean } => {
225  if (value === null || value === undefined) return { value: null, redacted: false }
226  // Normalize and drop invisible characters FIRST (verifier V2): a zero-width space or a soft
227  // hyphen inside an address or a token would otherwise hide it from the scan and be
228  // rejoined by oneLine afterwards. The scan then sees exactly what would be stored.
229  const joined = oneLine(String(value), Number.MAX_SAFE_INTEGER)
230  // Scanned twice: as stored, and with every space removed, so a no-break space or a plain
231  // space pushed into an address or a token (verifier V13) cannot split it past the rules.
232  const reason = findCredentials(joined, options)[0] ?? findCredentials(joined.replace(/\s+/g, ''), options)[0]
233  if (reason !== undefined) return { value: `[redacted:${reason}]`, redacted: true }
234  const line = [...joined].slice(0, cap).join('').trim()
235  return { value: line === '' ? null : line, redacted: false }
236}
237
238/** The leak scan over text as it would be stored (normalized, invisible characters dropped): the form every scan of raw data must use. */
239export const scanJoined = (value: string, options: { isEngineId?: boolean } = {}): string | null => {
240  const joined = oneLine(String(value), Number.MAX_SAFE_INTEGER)
241  return findCredentials(joined, options)[0] ?? findCredentials(joined.replace(/\s+/g, ''), options)[0] ?? null
242}
243
244/**
245 * FNV-1a over the UTF-8 bytes, 64-bit, as 8 hex characters: a stable short
246 * fingerprint for a subagent id (`agentKey`). Not a security hash; sha256 in
247 * hash.ts is used for everything that must match another tool.
248 */
249export const sha8 = (text: string): string => {
250  let h = 0xcbf29ce484222325n
251  const bytes = new TextEncoder().encode(text)
252  for (const b of bytes) {
253    h ^= BigInt(b)
254    h = (h * 0x100000001b3n) & 0xffffffffffffffffn
255  }
256  return h.toString(16).padStart(16, '0').slice(0, 8)
257}
258
259/** `$12.50` style; a non-finite or null number reads `--`. */
260export const money = (usd: number | null): string => (usd === null || !Number.isFinite(usd) ? '--' : `$${usd.toFixed(2)}`)
261
262/** Seconds as `H.HH h`. */
263export const hoursText = (seconds: number): string => `${(seconds / 3600).toFixed(2)} h`
264
265/** `1234567` → `1.2M`, `12345` → `12.3k`. */
266export const compactInt = (n: number): string => {
267  if (!Number.isFinite(n)) return '--'
268  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
269  if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`
270  return String(Math.round(n))
271}
272
273/** A null-prototype copy of a plain object, so `__proto__` and `constructor` are ordinary keys (CYB-8). */
274export const bareObject = <T>(source: Record<string, T> | null | undefined): Record<string, T> => {
275  const out: Record<string, T> = Object.create(null) as Record<string, T>
276  if (source && typeof source === 'object') for (const key of Object.keys(source)) out[key] = source[key] as T
277  return out
278}
279
280export const hasOwn = (obj: object, key: string): boolean => Object.prototype.hasOwnProperty.call(obj, key)
281
hooks/tz.ts 213 lines
1// rd-ledger: zone math (SPEC §4.1, ADR-005). Workstream W1. Pure; `Intl` is the
2// only dependency and is injectable so its absence can be tested (TES-9).
3//
4// Calendar arithmetic runs on whole day numbers (days since 1970-01-01) so no
5// `Date` object and no clock is ever involved; only the wall-clock reading of
6// an instant in a zone needs `Intl`. Local midnights are found by asking the
7// zone, never by adding 86,400 s, so DST days come out 23 h and 25 h (the
8// rd-tracker `instant` rule: never subtract wall clocks, timeline.py 309-327).
9
10const DAY_MS = 86_400_000
11
12/** Days since 1970-01-01 of a proleptic Gregorian date (Hinnant's days_from_civil). */
13const daysFromCivil = (y: number, m: number, d: number): number => {
14  const yy = m <= 2 ? y - 1 : y
15  const era = Math.floor(yy / 400)
16  const yoe = yy - era * 400
17  const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1
18  const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy
19  return era * 146_097 + doe - 719_468
20}
21
22/** The date of a day number (Hinnant's civil_from_days). */
23const civilFromDays = (z0: number): { y: number; m: number; d: number } => {
24  const z = z0 + 719_468
25  const era = Math.floor(z / 146_097)
26  const doe = z - era * 146_097
27  const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36_524) - Math.floor(doe / 146_096)) / 365)
28  const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100))
29  const mp = Math.floor((5 * doy + 2) / 153)
30  const d = doy - Math.floor((153 * mp + 2) / 5) + 1
31  const m = mp + (mp < 10 ? 3 : -9)
32  return { y: yoe + era * 400 + (m <= 2 ? 1 : 0), m, d }
33}
34
35const pad = (n: number, width = 2): string => String(Math.abs(n)).padStart(width, '0')
36const fmtDate = (c: { y: number; m: number; d: number }): string => `${c.y < 0 ? '-' : ''}${pad(c.y, 4)}-${pad(c.m)}-${pad(c.d)}`
37
38/** A `YYYY-MM-DD` string as numbers; null when it is not a real calendar date. */
39const parseDate = (date: string): { y: number; m: number; d: number } | null => {
40  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
41  if (!match) return null
42  const c = { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) }
43  const back = civilFromDays(daysFromCivil(c.y, c.m, c.d))
44  return back.y === c.y && back.m === c.m && back.d === c.d ? c : null
45}
46
47const dayNumber = (date: string): number => {
48  const c = parseDate(date)
49  if (!c) throw new RangeError(`not a calendar date: ${date.slice(0, 20)}`)
50  return daysFromCivil(c.y, c.m, c.d)
51}
52
53/** ISO weekday, Monday 1 … Sunday 7, of a day number (1970-01-01 was a Thursday). */
54const isoWeekday = (days: number): number => ((((days + 3) % 7) + 7) % 7) + 1
55
56// Building a DateTimeFormat costs far more than using one, and the union asks
57// for a local date per block and per event, so one formatter per zone is kept.
58// The cache never changes an answer, only how fast it comes.
59const formatters = new Map<string, Intl.DateTimeFormat>()
60const formatterFor = (zone: string): Intl.DateTimeFormat => {
61  const known = formatters.get(zone)
62  if (known) return known
63  const f = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', era: 'short' })
64  if (formatters.size >= 32) formatters.clear()
65  formatters.set(zone, f)
66  return f
67}
68
69type Wall = { y: number; m: number; d: number; hh: number; mm: number; ss: number }
70
71/** The wall clock of the whole-second instant `ts` (ms) in `zone`. */
72const wallOf = (ts: number, zone: string): Wall => {
73  const parts = formatterFor(zone).formatToParts(Math.floor(ts / 1000) * 1000)
74  const get = (type: string): number => Number(parts.find(p => p.type === type)?.value ?? NaN)
75  const era = parts.find(p => p.type === 'era')?.value ?? 'AD'
76  const year = get('year')
77  const wall = { y: era === 'BC' || era === 'B' ? 1 - year : year, m: get('month'), d: get('day'), hh: get('hour') % 24, mm: get('minute'), ss: get('second') }
78  if (![wall.y, wall.m, wall.d, wall.hh, wall.mm, wall.ss].every(Number.isFinite)) throw new RangeError('the zone answered no usable wall clock')
79  return wall
80}
81
82/** The zone's offset from UTC at `ts`, in ms (local = UTC + offset). */
83const offsetAt = (ts: number, zone: string): number => {
84  const w = wallOf(ts, zone)
85  const wallMs = daysFromCivil(w.y, w.m, w.d) * DAY_MS + ((w.hh * 60 + w.mm) * 60 + w.ss) * 1000
86  return wallMs - Math.floor(ts / 1000) * 1000
87}
88
89/** CONTRACT (W1). The local calendar date `YYYY-MM-DD` of `ts` (ms) in `zone`. */
90export const localDate = (ts: number, zone: string): string => {
91  const w = wallOf(ts, zone)
92  return fmtDate(w)
93}
94
95/**
96 * The first instant of `date` in `zone` (ms). Python's zoneinfo, which
97 * rd-tracker's `local_midnight` uses, reads a wall time with the offset in
98 * force before a transition (fold 0): an ambiguous midnight is its first
99 * occurrence and a skipped midnight becomes the transition instant, which is
100 * also the first instant of that local day. Both candidates are tried in
101 * order and the first whose local date is `date` wins.
102 */
103const startOfDay = (date: string, zone: string): number => {
104  const wall = dayNumber(date) * DAY_MS
105  const before = wall - offsetAt(wall - DAY_MS, zone)
106  const after = wall - offsetAt(wall + DAY_MS, zone)
107  const exact = [before, after].filter(t => {
108    const w = wallOf(t, zone)
109    return fmtDate(w) === date && w.hh === 0 && w.mm === 0 && w.ss === 0
110  })
111  if (exact.length > 0) return Math.min(...exact)
112  return localDate(before, zone) === date ? before : after
113}
114
115/** CONTRACT (W1). The ms instants of local midnight starting `date` and local midnight starting the next day (DST-aware; 23/24/25 h). */
116export const dayBounds = (date: string, zone: string): { startTs: number; endTs: number } => ({ startTs: startOfDay(date, zone), endTs: startOfDay(addDays(date, 1), zone) })
117
118/** CONTRACT (W1). The ISO week `YYYY-Www` of a calendar date. */
119export const isoWeek = (date: string): string => {
120  const days = dayNumber(date)
121  // The ISO week belongs to the year of its Thursday.
122  const thursday = days + 4 - isoWeekday(days)
123  const year = civilFromDays(thursday).y
124  const week = Math.floor((thursday - daysFromCivil(year, 1, 1)) / 7) + 1
125  return `${pad(year, 4)}-W${pad(week)}`
126}
127
128/** CONTRACT (W1). Monday to Sunday of an ISO week, as dates. */
129export const weekDates = (week: string): string[] => {
130  const match = /^(\d{4})-W(\d{2})$/.exec(week)
131  if (!match) return []
132  const year = Number(match[1])
133  const n = Number(match[2])
134  // 4 January is always in week 1.
135  const jan4 = daysFromCivil(year, 1, 4)
136  const monday = jan4 - (isoWeekday(jan4) - 1) + (n - 1) * 7
137  const dates = Array.from({ length: 7 }, (_, i) => fmtDate(civilFromDays(monday + i)))
138  // A week 53 in a 52-week year (or week 00) is not a week: refuse rather than roll over.
139  return n >= 1 && isoWeek(dates[0] as string) === week ? dates : []
140}
141
142/** CONTRACT (W1). The ISO week of `ts` in `zone`. */
143export const weekOfTs = (ts: number, zone: string): string => isoWeek(localDate(ts, zone))
144
145/** CONTRACT (W1). `YYYY-MM` of `ts` in `zone`. */
146export const monthOfTs = (ts: number, zone: string): string => localDate(ts, zone).slice(0, 7)
147
148/** CONTRACT (W1). The previous month of `YYYY-MM`. */
149export const previousMonth = (month: string): string => {
150  const match = /^(\d{4})-(\d{2})$/.exec(month)
151  if (!match) throw new RangeError(`not a month: ${month.slice(0, 20)}`)
152  const y = Number(match[1])
153  const m = Number(match[2])
154  return m <= 1 ? `${pad(y - 1, 4)}-12` : `${pad(y, 4)}-${pad(m - 1)}`
155}
156
157/** CONTRACT (W1). `date` plus `days` (may be negative), as a date. */
158export const addDays = (date: string, days: number): string => fmtDate(civilFromDays(dayNumber(date) + Math.trunc(days)))
159
160/** CONTRACT (W1). ISO 8601 with the zone's offset at that instant, e.g. `2026-10-11T09:00:00-04:00`. */
161export const isoWithOffset = (ts: number, zone: string): string => {
162  const w = wallOf(ts, zone)
163  const minutes = Math.round(offsetAt(ts, zone) / 60_000)
164  const sign = minutes < 0 ? '-' : '+'
165  return `${fmtDate(w)}T${pad(w.hh)}:${pad(w.mm)}:${pad(w.ss)}${sign}${pad(Math.floor(Math.abs(minutes) / 60))}:${pad(Math.abs(minutes) % 60)}`
166}
167
168/** One line, ASCII, no spaces, ≤ 64: the only shape an IANA name (or `UTC`) takes. */
169const ZONE_SHAPE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/
170
171// A default parameter would treat an explicit `undefined` as "use the default",
172// so `detectZone(undefined)` would read the real Intl. The argument count
173// decides instead: no argument means the runtime's Intl, an explicit undefined
174// means Intl is absent. The call type is unchanged: `(intl?: typeof Intl)`.
175const intlArg = (args: readonly unknown[]): typeof Intl | undefined => (args.length === 0 ? globalThis.Intl : (args[0] as typeof Intl | undefined))
176
177/** CONTRACT (W1). The runtime's zone, or null when `Intl` (injected for tests) is missing, throws, or answers nothing usable. */
178export const detectZone = (...args: [intl?: typeof Intl | undefined]): string | null => {
179  const intl = intlArg(args)
180  if (!intl) return null
181  try {
182    const zone: unknown = new intl.DateTimeFormat().resolvedOptions().timeZone
183    return typeof zone === 'string' && isZone(zone, intl) ? zone : null
184  } catch {
185    return null
186  }
187}
188
189/** CONTRACT (W1). Whether `Intl` accepts the name as a time zone (and it is one line, ≤ 64 ASCII chars, no spaces). */
190export const isZone = (name: string, ...args: [intl?: typeof Intl | undefined]): boolean => {
191  const intl = intlArg(args)
192  if (typeof name !== 'string' || name.length === 0 || name.length > 64 || !ZONE_SHAPE.test(name) || !intl) return false
193  try {
194    new intl.DateTimeFormat('en-US', { timeZone: name })
195    return true
196  } catch {
197    return false
198  }
199}
200
201/** CONTRACT (W1). The zone named by a `/etc/localtime` link target such as `/var/db/timezone/zoneinfo/America/New_York`, or null. */
202export const zoneFromLocaltimeTarget = (target: string): string | null => {
203  if (typeof target !== 'string' || target.length > 512 || /[\r\n\0]/.test(target)) return null
204  const marker = '/zoneinfo/'
205  const at = target.lastIndexOf(marker)
206  if (at < 0) return null
207  let name = target.slice(at + marker.length)
208  // The posix/ and right/ trees hold the same zones under another root.
209  if (name.startsWith('posix/')) name = name.slice('posix/'.length)
210  else if (name.startsWith('right/')) name = name.slice('right/'.length)
211  return isZone(name) ? name : null
212}
213
hooks/union.ts 276 lines
1// rd-ledger: union-v2 (SPEC §4.1). Workstream W1. Pure. Parity with
2// rd-tracker's rdt/timeline.py is the acceptance criterion: apportion
3// (369-394), the gap tie rule (451-469), half-open midnight segments (522-527),
4// the flag date (585) and the method hash (98-113), golden `a5db146c`.
5//
6// Instants keep millisecond precision, as rd-tracker's instant()/elapsed() keep
7// fractional seconds (timeline.py 309-327; SPEC §4.1 rule 1, ruled 2026-10-12):
8// gaps and spans compare in milliseconds against gapS × 1000 and maxBlockS ×
9// 1000, and a block's seconds are floor(span ms / 1000). Nothing collapses: two
10// prompts of one session in the same second are two events there and here
11// (rule 2). Seconds stay integers from the block on; nothing is rounded until
12// the pane draws it, so every table sums exactly to its total (rule 10).
13
14import type { Allocation, Block, Flag, Mapping, SessionRecord, UnionEvent, UnionParams } from '../types'
15import { sha256 } from './hash'
16import { resolveRepo } from './mapping'
17import { IGNORED, NON_RD, UNMAPPED } from './store'
18import { repoRef as refOf } from './text'
19import { addDays, dayBounds, localDate } from './tz'
20
21/** rd-tracker's automation ratio: a day is automation-heavy when denied > ratio × admitted. Fixed; part of the method hash. */
22export const AUTOMATION_RATIO = 10
23
24/** The key `allocate` looks a segment up by: `${sessionId}|${seg}` (the row key without its seq). */
25export const segmentKey = (sessionId: string, seg: string): string => `${sessionId}|${seg}`
26
27/** The instant as the union compares it: the event's millisecond timestamp itself. */
28const instantOf = (ts: number): number => ts
29/** Code-unit order: deterministic and locale-free, like Python's str comparison for ASCII ids. */
30const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
31/** Python's `%`: the result takes the divisor's sign. */
32const pyMod = (a: number, b: number): number => ((a % b) + b) % b
33
34/** CONTRACT (W1). rd-tracker's `apportion`: split `total` over `weights` so the parts sum exactly; largest remainder, ties to the earlier index. */
35export const apportion = (total: number, weights: readonly number[]): number[] => {
36  const n = weights.length
37  if (n === 0) return []
38  const weightSum = weights.reduce((a, b) => a + b, 0)
39  // rd-tracker raises here too (timeline.py 380-381); every caller in the mod passes event counts, which are positive.
40  if (!(weightSum > 0)) throw new RangeError('apportion requires a positive total weight')
41  const base = weights.map(w => Math.floor((total * w) / weightSum))
42  const remainder = total - base.reduce((a, b) => a + b, 0)
43  if (remainder !== 0) {
44    const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => pyMod(total * (weights[j] as number), weightSum) - pyMod(total * (weights[i] as number), weightSum) || i - j)
45    const step = remainder > 0 ? 1 : -1
46    for (const i of order.slice(0, Math.abs(remainder))) base[i] = (base[i] as number) + step
47  }
48  return base
49}
50
51/** CONTRACT (W1). `union-v2/<sha8>` over rd-tracker's canonical six-field JSON (sorted keys, no spaces; automation_ratio 10). */
52export const methodString = (p: UnionParams, zone: string): string => {
53  // Keys written in sorted order by hand: JSON.stringify keeps insertion order, Python's sort_keys sorts.
54  const blob = JSON.stringify({
55    automation_ratio: AUTOMATION_RATIO,
56    day_flag_hours: p.dayFlagH,
57    idle_gap_seconds: p.gapS,
58    max_block_seconds: p.maxBlockS,
59    single_event_floor_seconds: p.floorS,
60    timezone: zone,
61  })
62  return `union-v2/${sha256(blob).slice(0, 8)}`
63}
64
65type Timed = { e: UnionEvent; s: number }
66
67/** The admitted events in union order (SPEC §4.1 rules 1-2): by instant, then sessionId, seg, seq; nothing collapses. */
68const ordered = (events: readonly UnionEvent[]): Timed[] => {
69  const timed = events.filter(e => Number.isFinite(e.ts)).map(e => ({ e, s: instantOf(e.ts) }))
70  timed.sort((a, b) => a.s - b.s || cmp(a.e.sessionId, b.e.sessionId) || cmp(a.e.seg, b.e.seg) || a.e.seq - b.e.seq)
71  return timed
72}
73
74/** Maximal runs whose consecutive gaps are ≤ gapS × 1000 ms (rule 3). */
75const chains = (events: readonly Timed[], gapS: number): Timed[][] => {
76  const out: Timed[][] = []
77  for (const t of events) {
78    const current = out[out.length - 1]
79    const last = current?.[current.length - 1]
80    if (current && last && t.s - last.s <= gapS * 1000) current.push(t)
81    else out.push([t])
82  }
83  return out
84}
85
86/** timeline.py 451-469: the largest gap; ties to the gap nearest the temporal midpoint, then the earlier. */
87const largestGapIndex = (chain: readonly Timed[]): number => {
88  let best = 1
89  let bestKey: [number, number, number] | null = null
90  const midpoint = (chain.length - 1) / 2
91  for (let i = 1; i < chain.length; i++) {
92    const key: [number, number, number] = [-((chain[i] as Timed).s - (chain[i - 1] as Timed).s), Math.abs(i - 0.5 - midpoint), i]
93    if (bestKey === null || key[0] < bestKey[0] || (key[0] === bestKey[0] && (key[1] < bestKey[1] || (key[1] === bestKey[1] && key[2] < bestKey[2])))) {
94      bestKey = key
95      best = i
96    }
97  }
98  return best
99}
100
101const spanOf = (piece: readonly Timed[]): number => (piece[piece.length - 1] as Timed).s - (piece[0] as Timed).s
102
103/** Rule 4: split at the largest gap and recurse until every piece fits the cap. */
104const splitOverCap = (chain: Timed[], maxBlockS: number): Timed[][] => {
105  if (chain.length < 2 || spanOf(chain) <= maxBlockS * 1000) return [chain]
106  const i = largestGapIndex(chain)
107  return [...splitOverCap(chain.slice(0, i), maxBlockS), ...splitOverCap(chain.slice(i), maxBlockS)]
108}
109
110/** Rule 6: half-open `[lo, hi)` day segments, the last closed on its end; seconds by event count (timeline.py 500-540). */
111const splitAtMidnights = (piece: readonly Timed[], seconds: number, zone: string, pieceFlags: string[]): Block[] => {
112  const start = (piece[0] as Timed).s
113  const end = (piece[piece.length - 1] as Timed).s
114  const boundaries: number[] = []
115  let day = localDate(start, zone)
116  for (;;) {
117    const next = addDays(day, 1)
118    const boundary = dayBounds(next, zone).startTs
119    if (boundary >= end) break
120    boundaries.push(boundary)
121    day = next
122  }
123  const edges = [start, ...boundaries, end]
124  const buckets: { lo: number; hi: number; members: Timed[] }[] = []
125  for (let i = 0; i < edges.length - 1; i++) {
126    const lo = edges[i] as number
127    const hi = edges[i + 1] as number
128    const last = i === edges.length - 2
129    buckets.push({ lo, hi, members: piece.filter(t => (lo <= t.s && t.s < hi) || (last && t.s === hi)) })
130  }
131  const nonEmpty = buckets.filter(b => b.members.length > 0)
132  const shares = apportion(seconds, nonEmpty.map(b => b.members.length))
133  return nonEmpty.map((b, i) => ({
134    date: localDate(b.lo, zone),
135    startTs: b.lo,
136    endTs: b.hi,
137    seconds: shares[i] as number,
138    events: b.members.map(t => t.e),
139    flags: [...pieceFlags],
140  }))
141}
142
143/**
144 * CONTRACT (W1). Blocks from admitted events: order by (ts,
145 * sessionId, seg, seq); nothing collapses; merge while gap ≤ gapS × 1000 ms; split chains over maxBlockS at the largest gap
146 * (midpoint-first tie, then earlier; recursive); single-event blocks get
147 * floorS once; then split at local midnights with half-open segments and
148 * event-count apportionment. Blocks never cross a local day.
149 */
150export const buildBlocks = (events: readonly UnionEvent[], p: UnionParams, zone: string): Block[] => {
151  const out: Block[] = []
152  for (const chain of chains(ordered(events), p.gapS)) {
153    const overCap = spanOf(chain) > p.maxBlockS * 1000
154    for (const piece of splitOverCap(chain, p.maxBlockS)) {
155      // timeline.py: a lone event is floored; a multi-event piece keeps int() of its true span, even 0 s.
156      const seconds = piece.length === 1 ? Math.max(0, p.floorS) : Math.floor(spanOf(piece) / 1000)
157      out.push(...splitAtMidnights(piece, seconds, zone, overCap ? ['over-4h'] : []))
158    }
159  }
160  return out
161}
162
163/**
164 * Where a segment's time and spend land: its repository resolved through the
165 * mapping to a component and entity, or a sentinel bucket (entity '' for
166 * unmapped and ignored, the rule's entity for nonRd). The one resolver that
167 * `allocate` and `spendByMonth` share, so hours and dollars never disagree.
168 */
169export const bucketOf = (r: SessionRecord, mapping: Mapping): { component: string; entity: string } => {
170  const res = r.repoKey ? resolveRepo(mapping, r.repoKey, r.rootHash) : null
171  if (res?.kind === 'component') return { component: res.component, entity: res.entity }
172  if (res?.kind === 'ignored') return { component: IGNORED, entity: '' }
173  if (res?.kind === 'nonRd') return { component: NON_RD, entity: res.entity }
174  return { component: UNMAPPED, entity: '' }
175}
176
177/**
178 * CONTRACT (W1). Allocations: each block's seconds apportioned over its events
179 * (equal weights); each event's seconds to its segment's `repoRef`
180 * (`repoKey#rootHash` or the bare key) resolved through the mapping to a
181 * component and entity, or `__unmapped__` / `__ignored__` / `__non_rd__`
182 * (entity '' for unmapped and ignored; the rule's entity for nonRd).
183 *
184 * One allocation per (block, session, segment), in the order the segment first
185 * appears in the block. `segments` is keyed by `segmentKey(sessionId, seg)`;
186 * an event whose segment is missing, or whose segment names no repository,
187 * lands in `__unmapped__` with an empty repoRef rather than anywhere guessed.
188 */
189export const allocate = (blocks: readonly Block[], segments: ReadonlyMap<string, SessionRecord>, mapping: Mapping): Allocation[] => {
190  const out: Allocation[] = []
191  for (const block of blocks) {
192    if (block.events.length === 0) continue
193    const shares = apportion(block.seconds, block.events.map(() => 1))
194    const bySegment = new Map<string, { sessionId: string; seg: string; seconds: number }>()
195    block.events.forEach((ev, i) => {
196      const key = segmentKey(ev.sessionId, ev.seg)
197      const known = bySegment.get(key)
198      if (known) known.seconds += shares[i] as number
199      else bySegment.set(key, { sessionId: ev.sessionId, seg: ev.seg, seconds: shares[i] as number })
200    })
201    for (const [key, part] of bySegment) {
202      const record = segments.get(key)
203      const repoKey = record?.repoKey ?? null
204      const where = record ? bucketOf(record, mapping) : { component: UNMAPPED, entity: '' }
205      out.push({ date: block.date, blockStartTs: block.startTs, sessionId: part.sessionId, seg: part.seg, repoRef: repoKey ? refOf(repoKey, record?.rootHash ?? null) : '', component: where.component, entity: where.entity, seconds: part.seconds })
206    }
207  }
208  return out
209}
210
211const FLAG_ORDER: Record<Flag['kind'], number> = { 'block-over-4h': 0, 'day-over-cap': 1, 'automation-heavy': 2, 'rows-dropped': 3 }
212
213/**
214 * CONTRACT (W1). Flags: `block-over-4h` per over-cap chain dated by its first
215 * event; `day-over-cap` per local day over dayFlagH hours; `automation-heavy`
216 * per day where denied > 10 × admitted (strictly); `rows-dropped` once per
217 * segment with eventsDropped > 0, dated by the segment's open date.
218 *
219 * Blocks carry no chain id, so the over-cap chains are rebuilt from them:
220 * consecutive `over-4h` blocks whose events lie within `gapS` of each other
221 * are pieces of one chain (distinct chains are by definition more than `gapS`
222 * apart). Day sums are block seconds per local date; the automation counts are
223 * the segments' raw event rows per local date.
224 */
225export const flags = (blocks: readonly Block[], segments: readonly SessionRecord[], p: UnionParams, zone: string): Flag[] => {
226  const out: Flag[] = []
227  const sorted = [...blocks].filter(b => b.events.length > 0).sort((a, b) => a.startTs - b.startTs)
228  let chain: { first: number; last: number; events: number } | null = null
229  const closeChain = (): void => {
230    if (chain) out.push({ kind: 'block-over-4h', date: localDate(chain.first, zone), detail: `chain of ${chain.events} events spans ${Math.floor((chain.last - chain.first) / 1000)} s before the split (cap ${p.maxBlockS} s)` })
231    chain = null
232  }
233  for (const b of sorted) {
234    const first = instantOf((b.events[0] as UnionEvent).ts)
235    const last = instantOf((b.events[b.events.length - 1] as UnionEvent).ts)
236    if (!b.flags.includes('over-4h')) {
237      closeChain()
238      continue
239    }
240    const open = chain as { first: number; last: number; events: number } | null
241    if (open && first - open.last <= p.gapS * 1000) chain = { first: open.first, last, events: open.events + b.events.length }
242    else {
243      closeChain()
244      chain = { first, last, events: b.events.length }
245    }
246  }
247  closeChain()
248
249  const perDay = new Map<string, number>()
250  for (const b of blocks) perDay.set(b.date, (perDay.get(b.date) ?? 0) + b.seconds)
251  for (const [date, seconds] of perDay) {
252    if (seconds > p.dayFlagH * 3600) out.push({ kind: 'day-over-cap', date, detail: `${seconds} s engaged, over the ${p.dayFlagH} h day flag` })
253  }
254
255  const counts = new Map<string, { admitted: number; denied: number }>()
256  for (const r of segments) {
257    for (const ev of r.events) {
258      if (!Number.isFinite(ev.ts)) continue
259      const date = localDate(ev.ts, zone)
260      const c = counts.get(date) ?? { admitted: 0, denied: 0 }
261      if (ev.admit === 1) c.admitted += 1
262      else c.denied += 1
263      counts.set(date, c)
264    }
265  }
266  for (const [date, c] of counts) {
267    if (c.denied > AUTOMATION_RATIO * c.admitted) out.push({ kind: 'automation-heavy', date, detail: `${c.denied} denied to ${c.admitted} admitted prompts` })
268  }
269
270  for (const r of segments) {
271    if (r.eventsDropped > 0) out.push({ kind: 'rows-dropped', date: localDate(r.openedAt, zone), detail: `${r.eventsDropped} prompts past the segment cap were not recorded` })
272  }
273
274  return out.sort((a, b) => cmp(a.date, b.date) || FLAG_ORDER[a.kind] - FLAG_ORDER[b.kind] || cmp(a.detail, b.detail))
275}
276