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…

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.
$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./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.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./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./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./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)./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./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.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).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.
Set in the config menu, or in settings under pluginConfigs → rd-ledger:
| Option | Default | What it does |
|---|---|---|
footerButton | true | The footer figure is a pressable chip that opens Usage with the R&D ledger beside it; off, it is plain text. |
footerChip | both | What the chip shows: both ($123 ⧗2.4h), cost ($123) or time (⧗2.4h). Where the host keeps no cost ledger the hours stand in. |
chipContext | false | Adds the context fill ( · ctx 12%). Within the 16-character bound the hours give way to it first, then it gives way to the hours. |
refreshMinutes | 5 | How often the week and month figures are recomputed (1 to 60). |
idleGapMinutes | 30 | Two 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. |
cacheTtl | 5m | The 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. |
bridgeMode | corroborate | corroborate: 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.
| Hook | Why |
|---|---|
session.start | registers /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.submit | one 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.complete | one usage row per turn (main loop and subagents): model and the four token counts; the Usage pane's turn count and active time |
turn.step | the 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.measure | the 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.PostModelSwitch | a dated cache-TTL change point |
session.end | closes 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 / PromptHint | the 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.
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:
a5db146c; sha256 against the FIPS vectors; every price row; zone math including ISO week-year edges and a sweep over every 2026 day;gen conflicts refused, confirm and reopen revisions, POE refused without a note, drift after a remap;/rd session, /rd sessions and /rd session <id8> with one, none and several matches, the picker and Back, and the view surviving a reload;/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;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.⧗ 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./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./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./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./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./bin/mv, /bin/rm, /bin/mkdir, /bin/rmdir, /usr/bin/git).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 variablehooks/register.tsx 2376 lines1// 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.valuehooks/bridge.ts 481 lines1// 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 })
481hooks/exporter.ts 718 lines1// 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('')
718hooks/hash.ts 71 lines1// 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)))
71hooks/mapping.ts 450 lines1// 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 ?? '' })
450hooks/report.ts 888 lines1// 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 })
888hooks/review.ts 356 lines1// 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}
356hooks/rollup.ts 782 lines1// 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}
782hooks/store.ts 418 lines1// 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)
418hooks/text.ts 281 lines1// 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)
281hooks/tz.ts 213 lines1// 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}
213hooks/union.ts 276 lines1// 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