Add billing-legal plugin — time tracking and billing for AI-assisted legal work - #61
Add billing-legal plugin — time tracking and billing for AI-assisted legal work#61emtcmca wants to merge 18 commits into
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
This is a practical and much-needed plugin. Time tracking and billing is one of the most tedious parts of legal work, and having it integrated into the AI-assisted workflow eliminates the context-switching tax. The file-based config at Also, if you're tracking time at the task level, correlating billable time with document changes (redlines, clause insertions) would provide the audit trail that legal billing reviewers often demand. We've found this kind of granular activity logging to be essential for client acceptance of AI-assisted work. Good work on following the claude-for-legal architecture conventions. Looking forward to seeing this merged. |
|
@zeweihan — Thank you — this feedback is exactly the kind of real-world validation that makes a plugin actually useful in practice, and I appreciate you taking the time to write it up in detail. Both suggestions are implemented in v1.1.0, just pushed to the PR branch. LEDES 1998B export — New Activity audit trail — New Both features are opt-in during cold-start setup or via |
…rail) Syncs the vendored plugin to the current published v1.1.0. The PR branch was cut from an earlier snapshot; this brings it fully up to date. - Add /billing-legal:ledes-export skill (spec-compliant LEDES 1998B export) - Add hooks/activity-log.ps1 PostToolUse hook (document audit trail) - Bump plugin version 1.0.0 -> 1.1.0 - Drop machine-specific .claude/settings.local.json (should not ship) - Fold in intervening fixes: author metadata, command namespace, /schedule Implements the LEDES export and activity-audit-trail suggestions from @zeweihan's review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Friendly nudge for maintainer review when someone has bandwidth 🙏 Quick status so this is easy to pick up:
Happy to make any changes needed to fit registry conventions — just let me know. Thanks for taking a look! |
The end-of-session billing panel told attorneys to run /billing:billing-status, which does not exist. Commands register under the plugin name, so the correct invocation is /billing-legal:billing-status. This was the last remaining reference to the old namespace, and the only one in a string the attorney actually sees.
Register billing-legal in marketplace.json. The plugin directory was added without a catalog entry, so the loader could not resolve it and the plugin could not be installed at all. cocounsel-legal is registered the same way. UTBMS: the task-code prompt offered activity codes. Task codes are the L-series; activity codes are the A-series and belong in their own field. The A-code labels were also wrong against the published ABA set -- A103 and A104 were swapped, A105 was labeled Research (that is A102), and A100 is not an A-series code. An entry coded there was written to task_code and exported in LINE_ITEM_TASK_CODE, which is the field e-billing platforms validate. Adds activity_code to the entry schema, prompts for it separately, and maps it to LINE_ITEM_ACTIVITY_CODE, which was previously pinned empty. Budget warning threshold: cold-start collected it and customize confirmed that changes took effect, but no skill read it. Eight sites across four files hardcoded 75, and billing-status carried an undocumented second tier at 90. All now read the configured value, defaulting to 75 when absent. The critical tier can no longer sit below the warning tier. Client creation: rate-card offered to create a profile in one place and disclaimed it in another, routing users to customize, which cannot create clients while advertising "add a client" as a trigger phrase. time-entry is now the single documented path and the other two route to it. session_minutes_actual was defined in the schema but never populated on manual entry, discarding the only record of the gap between time worked and time billed. It is the support for the round-up if a client's e-billing auditor asks. Also removes em dashes from the generated register headers so PowerShell 5.1 renders them correctly, and bumps the patch version. Verified: claude plugin validate --strict passes on the plugin and on the marketplace manifest.
Every other plugin in this repo ships a plugin-level guardrails layer. billing-legal had none: the ethics posture, the approval gate, and the "this plugin does not make billing decisions" boundary existed only in the README and in one screen of the cold-start interview, so nothing carried them at run time. This ships that layer as a skill rather than a root CLAUDE.md, because `claude plugin validate` warns that a root CLAUDE.md is not loaded as project context and names a skill as the correct vehicle. The plugin continues to pass --strict with no warnings. Twelve rules, most of them written from defects found while testing this branch rather than from first principles: - The attorney bills; the plugin records. No output is "compliant" or "approved by the system." - Billing for AI-assisted work is an open ethics question. Surfaced once at setup, not as a banner on every entry. - The model does not write or approve financial records on its own initiative, and must not route around the disable-model-invocation guard. - wip-review is a hard gate. invoice-generate reads approved entries only. - The register is append-only. A write-off zeroes the amount and keeps the record. - Rounding is disclosed. session_minutes_actual is the support for the round-up. Never re-round a total. - The anti-double-billing check is date-level only, because entries store no start or end times. Stated as a limitation rather than left implicit. - UTBMS task codes (L-series) and activity codes (A-series) are separate fields. A code in the wrong field is what e-billing platforms reject. - Configuration is authoritative. Never hardcode a threshold, increment, or rate. - Never invent a rate, cap, retainer balance, or client ID. - Invoices carry client-confidential narratives. Confirm the destination. - In shared firm folders, never touch another attorney's timer file. Minor version bump per CONTRIBUTING: new skill.
The guardrails skill only helps if something reads it. Each of the ten other skills now opens its Instructions with a preflight line pointing at /billing-legal:billing-guardrails and stating that the stricter rule wins on conflict. cold-start-interview takes it as step 0 of Phase 0, ahead of the config check, since the ethics posture it surfaces in Phase 1 comes from there. The billing-summary agent is handled differently. Its tools are Read and slack_send_message, so it cannot invoke a skill, and a pointer would be inert. The rules that bind it are restated inline and narrowed to what a read-only reporting agent can actually get wrong -- most importantly the destination check. That agent can post to Slack, and time entry narratives describe legal work for an identified client. Nothing previously constrained where those figures could be sent. Patch bump so `claude plugin update` detects the change; 1.2.0 shipped the guardrails skill without the wiring.
Adding activity_code to the entry schema left wip-review behind. Its edit list covered narrative, task code, date, and attorney, so an entry logged before the field existed could never acquire one -- and with LEDES export enabled, those entries ship an empty LINE_ITEM_ACTIVITY_CODE that some e-billing platforms reject. There was no in-plugin way to correct it. Found by the plugin itself during a WIP review of test data: two of three entries had no activity code and no route to set one. Also states explicitly that session_minutes_actual is not editable. It records what the attorney entered at capture time. A value typed in later is a reconstruction rather than a measurement, and its only purpose is supporting the round-up if a client's auditor asks.
INVOICE_DESCRIPTION was templated as "Legal Services -- [Firm Name]" with an em dash, and both worked-example rows carried the same character. A real export written from that template contained three U+2014 bytes, one per data row, in a pipe-delimited interchange file. LEDES 1998B predates widespread UTF-8 and portal parsers commonly assume ASCII. A non-ASCII byte in a field they parse is a rejected upload, and the error a firm gets back will not name the character. Found by generating a real export and byte-scanning it. The field mapping and both sample rows now use an ASCII hyphen. Em dashes remain in the skill's prose, which is never emitted.
Disabling the billing panel removes the UserPromptSubmit hook, which is the only thing that creates session timer files. activity-log.ps1 exits unless a timer file exists. So after disabling the panel, activity logging reads "enabled" in the config, fires on every tool call, and records nothing. Found by disabling the panel during testing and re-reading the config: line 64 said the panel was disabled, line 81 still said activity logging was enabled. Same defect class as a configured threshold nothing reads -- a setting that claims a capability it cannot deliver. customize now names the dependency, offers to disable activity logging and remove the PostToolUse hook alongside the other two, and defaults to doing so. If the attorney keeps it registered, the config records "enabled (inactive - panel disabled)" rather than a bare "enabled". Also has customize re-read settings.json after writing and report which hook events remain. Hook removal is an instruction the model carries out, not an enforced operation, and a partial removal leaves timer or block litter that nothing else detects.
LINE_ITEM_ADJUSTMENT_AMOUNT is a required LEDES 1998B column. It was being populated by pattern-matching the attorney's free-text note for the phrase "written down" and trying to read a figure out of the sentence, falling back to 0.00 when that failed. wip-review asks for the write-down reason with no format requirement, so the note almost never contains a parseable number and the fallback was the normal path. Every write-down exported an adjustment of zero. That column is how a corporate client sees the discount they were given. Reporting 0.00 on a real write-down understates it silently, inside a financial interchange file, and the firm has no way to notice. Write-off had the same shape from the other direction: it set hours and amount to zero with nothing preserving what was absorbed. The size of a write-off is its substance; a record that says only "zero" does not show a firm what it gave away. Adds original_hours and original_amount to the entry schema, null until an entry is reduced. wip-review copies the current figures into them before overwriting, on both write-down and write-off, and never overwrites an existing original -- a second write-down reduces from the already-reduced figure. ledes-export now computes original_amount - amount and is told explicitly never to parse notes for a number. Found during a write-off of real test data: the only record of the $115.50 absorbed was a sentence, and it was there because it had been typed in by hand rather than because the schema kept it. Minor bump: new schema fields.
Invoked without a client slug, the skill was told to list clients with approved entries and ask which one, with no instruction for what to do when that list is empty. A literal reading produces an empty menu and a dangling question. The client-slug branch already has an explicit message for the same condition; the two paths now end the same way, pointing at wip-review. Found by running invoice-generate with no arguments against a register where everything billable had been invoiced and the remainder written off.
Phase 2 offered "Starting invoice number (default 001)" with no awareness of what had already been issued. An attorney re-running setup and accepting defaults would have the next invoice come out as INV-2026-001 again -- a second document sent to a client under a number they already have on file. In a payment dispute the two cannot be told apart. Phase 2 now reads invoice-register.yaml first, defaults to the number after the highest issued, and refuses anything at or below it with an explanation. The --redo confirmation was also too generic to act on. "This will overwrite your config" does not tell the attorney which live values are at risk, so it now names them: the invoice counter, and the fact that Phase 6 re-registers the Stop and UserPromptSubmit hooks and silently re-enables a panel the attorney may have turned off through customize. It also states what is NOT touched -- the registers, attorney and client profiles, and issued invoices -- so the confirmation is a decision rather than a leap. Found by running cold-start-interview without --redo against a populated install: the refusal was correct, and reading what --redo would have done surfaced the collision.
Moving time-register.yaml aside and running billing-status produced a complete, confident panel: WIP, budget totals, and a five-row table of every entry including a write-off. The file it claims to read did not exist. The figures came from earlier in the conversation. No skill distinguished absent from empty. Several handle "empty or comment-only" -- the normal state of a fresh install -- and none handled the file being gone, which means something else: a wrong billing_data_path, an unsynced shared folder, a moved file, a cleanup job. Those are opposite conditions and only one of them is safe to proceed through. The failure mode is worse than a crash or an empty report. Stale figures presented as current are indistinguishable from correct ones, so nothing prompts the firm to look. billing-status, wip-review, invoice-generate, ledes-export, and time-entry now stop and name the path when the register is absent. Guardrail 13 states the rule canonically and adds the part the individual skills cannot enforce on their own: never report figures that were not read from disk this turn. The register is the record; the conversation is not. Also labels the panel's mixed scopes. Today is the active attorney's hours, WIP is the whole client's across every attorney, and Rate is the active attorney's. In a solo practice they coincide; in a firm they read as one statement while describing three different things. Minor bump: new guardrail, behavior change in five skills.
Every figure this plugin has reported so far was produced by a model reading YAML and doing arithmetic. Test 12 showed what that costs: with the register moved aside, billing-status returned a complete panel -- WIP, budget totals, a five-row entry table -- from a file that did not exist. The numbers came from earlier in the conversation. Instructions cannot fix that. An instruction to read a file is not a read. scripts/register-read.ps1 resolves the data path from config, confirms the register exists, parses every entry, and returns JSON totals by status. It also checks the invariant a model cannot be relied on to check: that each entry's amount equals hours x rate. billing-status, wip-review, invoice-generate, and ledes-export now report its output rather than their own arithmetic. Exit codes carry the distinction the skills were missing. 2 is an absent register, and stdout stays empty so an error cannot be mistaken for a result. 3 is a register that parsed but failed an invariant, which means something other than this plugin edited it. 0 covers an empty or comment-only file, the normal state of a fresh install. Verified against four states: the live five-entry register (totals matched exactly), a missing file, a comment-only file, and a register with a tampered amount, a duplicate id, an unknown status, and a malformed date -- which produced four errors with line numbers. cold-start copies the script into [billing_data_path]/scripts/ in Phase 9, which runs whether or not the panel is enabled, since every read skill needs it. Skills fall back to a direct read on installs predating it. Zero dependencies, pure ASCII, no BOM. A hash chain over the entries would make tampering localizable rather than just detectable; that is a schema change and belongs in a follow-up. Minor bump: new script, behavior change in four skills.
register-read.ps1 detects corruption. Nothing told anyone what to do next, and guardrail 5 actively forbids the obvious fix: an entry whose arithmetic is wrong is often already billed, and billed entries are closed to editing. An attorney hitting exit 3 was stuck between a validator saying the record is wrong and a rule saying not to touch it. Surfaced by corrupting a billed entry during testing -- amount inflated 1000x, from 154.00 to 154000.00. The right question turned out not to be "how do I fix the register" but "did that number reach the client," and the answer is knowable: invoice-register.yaml, the Markdown exhibit, and the LEDES export are all written at invoice time and are independent of the time register. In this case all three read 770.00 against a register claiming 154,753.50, which establishes the corruption never left the file. Guardrail 14 now requires that comparison before any repair, and splits the outcomes. Register-only corruption is a repair of a damaged record and does not violate append-only, because no one ever billed the corrupt figure. Corruption present in the issued records means the client was billed wrong, which is a credit and re-bill and belongs to the attorney, not to a file edit. An unbilled entry has nothing to compare against, so the attorney decides which figure is right. It also states the thing that is easy to get backwards: hours x rate disagreeing with amount proves one of three fields is wrong and says nothing about which. Never guess.
The summary emitted one aggregated "Write-downs (not billed)" row. On an invoice with two timekeepers at different rates, that tells the client an amount was absorbed and not whose time it was. It is also ambiguous on its own numbers. The exhibit generated during testing showed 0.5h billed, 0.2h written down, and separately billed a 0.2h entry from a different attorney. Two identical hour figures, one line item, no way for a reader to tell them apart. Now one row per written-down entry, naming the attorney and date, each computed from that entry's original_hours and original_amount.
The README's UTBMS section carried the same defect the skill did: A-series codes listed under "Task Codes", A103 and A104 transposed, A105 labeled Research (that is A102), and an A100 that is not an activity code. It closed by advising A-series codes for transactional work, which would have put activity codes in the task field -- the field e-billing platforms validate. Now split into task codes and activity codes with the correct labels, plus a note for anyone holding entries logged under the old guidance. Adds three sections the plugin needed and did not have: Data integrity, covering the register validator, what each exit code means, and the procedure when validation fails -- check the invoice records before repairing, because a corrupt register and an overbilled client are different problems with different remedies. Known limitations, stated plainly rather than discovered: session time is wall clock and will bill an idle window, the active matter is inferred from another plugin's state and can attribute a session to the wrong client, the duplicate check is date-level only, rounding always goes up, activity logging records filenames without paths, the hooks are PowerShell-only, and LEDES identifiers fall back to slugs until configured. Uninstall, because the hooks are copied out of the plugin cache to survive updates and therefore also survive removal. Left in place the Stop hook keeps blocking session close and pointing at a command that no longer exists. Also corrects the budget warning description: both tiers read the configured threshold at run time rather than being fixed at 75 and 90.
|
This has been open since May with no review, so I'd like to make it easy to resolve either way. Is claude-for-legal taking external plugin contributions right now? If it isn't, say so and I'll close this myself. If it is, and there's something you'd want changed before review, tell me and I'll make the change. I'd rather have an answer than an open PR. |
Summary
Adds
billing-legal, a plugin that captures the billable time attorneys spend inside Claude Code,runs it through an approval gate, and produces invoice exhibits plus LEDES 1998B exports for
corporate e-billing systems.
~/.claude/plugins/config/claude-for-legal/billing/, no bundled executablesclaude plugin validate --strictpasses with no warnings on both the plugin and the marketplacemanifest
What it does
Attorneys spend billable time working in Claude Code — reviewing contracts, drafting
correspondence, researching matters.
billing-legalcaptures that time, associates it with theright client and matter, gates it behind attorney approval, and produces the documents a billing
period closes with.
11 skills:
cold-start-interviewbilling-guardrailsbilling-statusbilling-summarybilling-reporttime-entryrate-cardwip-reviewinvoice-generateledes-exportcustomize1 agent (
billing-summary) and 3 hooks —UserPromptSubmitstarts a per-session timer,Stopblocks session close to prompt for an entry,PostToolUserecords a document audit trail.1 script.
scripts/register-read.ps1is the deterministic reader described below.Design decisions worth a reviewer's attention
wip-reviewis a hard gate.invoice-generatereadsstatus: approvedonly. Nothingreaches an invoice without an attorney approving it in that turn.
invoice-generate,time-entry, andwip-reviewcarrydisable-model-invocation: true.The model cannot decide to write or approve a financial record.
live in
original_hours/original_amountas data, so a LEDES export can computeLINE_ITEM_ADJUSTMENT_AMOUNTrather than parsing them out of the attorney's prose.scripts/register-read.ps1, whichconfirms the file exists, checks that every entry's
amountequalshours × rate, and returnstotals as JSON. A missing register exits 2 with empty stdout. A register whose arithmetic no
longer holds exits 3 with the failing entry and line number.
.sessions/[attorney-slug]_[session-id], so attorneys pointing at one shared path cannot consumeeach other's timers.
hooks.json. Claude Code loads that file directlyand the commands need the resolved data path, so cold-start writes them into
settings.jsonandcopies the scripts out of the plugin cache.
hooks.jsoncarries the templates as documentation.The platform correctly reports
Hooks (0).On the plugin-level
CLAUDE.mdCONTRIBUTING.mddescribes two instruction layers, the second being<plugin>/CLAUDE.md. Thisplugin ships the guardrails as a skill instead, because
claude plugin validatewarns that aroot
CLAUDE.mdis not loaded as project context and names a skill as the correct vehicle. Everyother plugin in the repo currently trips that warning; this one does not. Flagging it rather than
quietly deviating — happy to move it if the convention is intentional.
Testing
A full user-perspective session was run against a live install: cold start, five time entries
across two attorneys, approval, two invoices, two LEDES exports, a write-down, a write-off, and
adversarial cases. Fourteen scenarios, one failure, and it produced the most important fix in
the PR.
The failure. With
time-register.yamlmoved aside,billing-statusreturned a complete panel —WIP, budget totals, a five-row entry table — from a file that did not exist. It answered from
conversation context. No skill distinguished absent from empty; several handled "empty or
comment-only", which is the normal state of a fresh install and the opposite condition. Stale
figures presented as current are indistinguishable from correct ones, which is why nothing catches
them.
That is what
scripts/register-read.ps1is for, and re-running the same test now yields a refusalthat names the path and explicitly declines to reuse figures read minutes earlier.
Also found and fixed while testing:
billing-legalhad no entry in.claude-plugin/marketplace.json, so the plugin could not beinstalled at all. Found on the first install attempt; invisible in the diff.
A103/A104transposed andA105labeled Research. Codes chosen there were written to
task_codeand exported inLINE_ITEM_TASK_CODE, the field e-billing platforms validate. Verified against the ABAlitigation code set and Thomson Reuters Legal Tracker documentation.
LINE_ITEM_ACTIVITY_CODEwas pinned empty in the export, and no schema field held an activitycode.
customize, andread by nothing. Eight hardcoded sites across four files, plus an undocumented second tier.
LINE_ITEM_ADJUSTMENT_AMOUNTwas derived by pattern-matching the attorney's free-text write-downnote, falling back to
0.00when parsing failed — which was the normal path, so real write-downsexported as no discount at all.
--redoof cold-start would reset the invoice counter to001and reissue a number a clientalready had on file.
/billing:billing-status, a command that does not exist.INVOICE_DESCRIPTION— non-ASCII bytes in apipe-delimited interchange file.
rate-carddisclaimed it and pointed atcustomize, whichadvertised it and could not do it. Only
time-entryever created a client.Every one of those was found by exercising the plugin, not by reading it.
Verified end to end on the final build, from the bytes rather than from tool output: two-line
LEDES export, zero non-ASCII, 21 fields per row,
LINE_ITEM_ACTIVITY_CODEpopulated on both linesincluding one set through
wip-reviewon an entry that had none,LINE_ITEM_ADJUSTMENT_AMOUNTof55.00computed fromoriginal_amount − amount, and line totals reconciling toINVOICE_TOTAL.Not claimed: this has not been run against a real client matter or submitted to a live e-billing
portal. Known limitations are stated plainly in the README rather than left to be discovered —
session time is wall clock, the active matter is inferred from another plugin's state, the duplicate
check is date-level only, and the hooks are PowerShell-only.
Test plan
Commands register under the plugin name, so every skill is invoked as
/billing-legal:<skill>.cold-start-interviewon a fresh config — profile written, hooks registered, scaffold createdtime-register.yamlwithstatus: pendingand- id:at column 0wip-reviewapprove —status: approved, no reordering or rewriting of prior entriesinvoice-generate— exhibit created, entries →billed,budget_billedadvanced, counter incrementedledes-export— parses as LEDES 1998B, totals reconcile, activity codes and adjustments populated, zero non-ASCIIdisable-model-invocation— a natural-language request to log time did not write a recordwip-reviewrefuses to invoice unapproved workcold-start-interviewwithout--redorefuses against a populated installwithout a client engagement
Checklist
name,description,argument-hintdisable-model-invocation: true- id:at column 0).gitignorecovers registers and invoice files/billing-legal:namespace.ps1files and generated data files are pure ASCIIclaude plugin validate --strictpasses on the plugin and the marketplace manifestFollow-up, not in this PR
A hash chain over register entries (
prev_hash/hashper entry) would make tamperinglocalizable rather than merely detectable — the current validator proves the arithmetic no longer
holds, but not which write broke it. The register is append-only by design, which is the
precondition that makes a chain work. Happy to open it separately if the direction is welcome.
Porting the hooks and validator to bash/zsh is the other obvious gap. Everything except
hook-driven time capture already works cross-platform.