Skip to content

Add billing-legal plugin — time tracking and billing for AI-assisted legal work - #61

Open
emtcmca wants to merge 18 commits into
anthropics:mainfrom
emtcmca:add-billing-legal-plugin
Open

Add billing-legal plugin — time tracking and billing for AI-assisted legal work#61
emtcmca wants to merge 18 commits into
anthropics:mainfrom
emtcmca:add-billing-legal-plugin

Conversation

@emtcmca

@emtcmca emtcmca commented May 21, 2026

Copy link
Copy Markdown

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.

  • Plugin source: https://github.com/emtcmca/claude-for-legal-billing (MIT)
  • Follows the repo's conventions: SKILL.md instruction files, file-based config at
    ~/.claude/plugins/config/claude-for-legal/billing/, no bundled executables
  • claude plugin validate --strict passes with no warnings on both the plugin and the marketplace
    manifest

What it does

Attorneys spend billable time working in Claude Code — reviewing contracts, drafting
correspondence, researching matters. billing-legal captures that time, associates it with the
right client and matter, gates it behind attorney approval, and produces the documents a billing
period closes with.

11 skills:

Skill Description
cold-start-interview First-time setup — firm info, attorney profiles, rates, data location, hooks
billing-guardrails The rules every other skill operates under — ethics posture, approval gate, record discipline
billing-status Current WIP and budget; doubles as the end-of-session panel
billing-summary On-demand digest — outstanding WIP, stale entries, budget alerts
billing-report WIP dashboard, client history, attorney utilization, invoice review
time-entry Manual entry for work done outside a Claude session
rate-card Per-attorney rates, client overrides, billing arrangements, LEDES identifiers
wip-review Pre-billing review — approve, write down, write off, correct
invoice-generate Markdown invoice exhibit from approved entries
ledes-export LEDES 1998B export for client e-billing platforms
customize Change one setting without re-running cold-start

1 agent (billing-summary) and 3 hooksUserPromptSubmit starts a per-session timer,
Stop blocks session close to prompt for an entry, PostToolUse records a document audit trail.

1 script. scripts/register-read.ps1 is the deterministic reader described below.

Design decisions worth a reviewer's attention

  • wip-review is a hard gate. invoice-generate reads status: approved only. Nothing
    reaches an invoice without an attorney approving it in that turn.
  • invoice-generate, time-entry, and wip-review carry disable-model-invocation: true.
    The model cannot decide to write or approve a financial record.
  • The register is append-only. Write-offs zero the amount and keep the record. Original figures
    live in original_hours / original_amount as data, so a LEDES export can compute
    LINE_ITEM_ADJUSTMENT_AMOUNT rather than parsing them out of the attorney's prose.
  • Skills do not do arithmetic on the register. They call scripts/register-read.ps1, which
    confirms the file exists, checks that every entry's amount equals hours × rate, and returns
    totals 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.
  • Multi-attorney firms share a folder. Timer files are keyed
    .sessions/[attorney-slug]_[session-id], so attorneys pointing at one shared path cannot consume
    each other's timers.
  • Hooks are not registered in the plugin's hooks.json. Claude Code loads that file directly
    and the commands need the resolved data path, so cold-start writes them into settings.json and
    copies the scripts out of the plugin cache. hooks.json carries the templates as documentation.
    The platform correctly reports Hooks (0).

On the plugin-level CLAUDE.md

CONTRIBUTING.md describes two instruction layers, the second being <plugin>/CLAUDE.md. This
plugin ships the guardrails as a skill instead, because claude plugin validate warns that a
root CLAUDE.md is not loaded as project context and names a skill as the correct vehicle. Every
other 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.yaml moved aside, billing-status returned 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.ps1 is for, and re-running the same test now yields a refusal
that names the path and explicitly declines to reuse figures read minutes earlier.

Also found and fixed while testing:

  • billing-legal had no entry in .claude-plugin/marketplace.json, so the plugin could not be
    installed at all. Found on the first install attempt; invisible in the diff.
  • The task-code prompt offered UTBMS activity codes, with A103/A104 transposed and A105
    labeled Research. Codes chosen there were written to task_code and exported in
    LINE_ITEM_TASK_CODE, the field e-billing platforms validate. Verified against the ABA
    litigation code set and Thomson Reuters Legal Tracker documentation.
  • LINE_ITEM_ACTIVITY_CODE was pinned empty in the export, and no schema field held an activity
    code.
  • The configured budget warning threshold was collected at cold-start, confirmed by customize, and
    read by nothing. Eight hardcoded sites across four files, plus an undocumented second tier.
  • LINE_ITEM_ADJUSTMENT_AMOUNT was derived by pattern-matching the attorney's free-text write-down
    note, falling back to 0.00 when parsing failed — which was the normal path, so real write-downs
    exported as no discount at all.
  • A --redo of cold-start would reset the invoice counter to 001 and reissue a number a client
    already had on file.
  • The Stop hook's block message named /billing:billing-status, a command that does not exist.
  • The LEDES export emitted em dashes in INVOICE_DESCRIPTION — non-ASCII bytes in a
    pipe-delimited interchange file.
  • Client creation routed in a loop: rate-card disclaimed it and pointed at customize, which
    advertised it and could not do it. Only time-entry ever 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_CODE populated on both lines
including one set through wip-review on an entry that had none, LINE_ITEM_ADJUSTMENT_AMOUNT of
55.00 computed from original_amount − amount, and line totals reconciling to INVOICE_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-interview on a fresh config — profile written, hooks registered, scaffold created
  • Time entry lands in time-register.yaml with status: pending and - id: at column 0
  • wip-review approve — status: approved, no reordering or rewriting of prior entries
  • invoice-generate — exhibit created, entries → billed, budget_billed advanced, counter incremented
  • ledes-export — parses as LEDES 1998B, totals reconcile, activity codes and adjustments populated, zero non-ASCII
  • Multi-attorney — second attorney billed at their own rate, timer files keyed separately
  • disable-model-invocation — a natural-language request to log time did not write a record
  • wip-review refuses to invoice unapproved work
  • Write-off preserves the entry and its original figures
  • Budget threshold read from config, both tiers
  • cold-start-interview without --redo refuses against a populated install
  • Missing register — skills stop rather than report from memory
  • Corrupted register — arithmetic mismatch caught with entry ID and line number
  • Stop hook fires, names the correct command, activity log records with correct exclusions
  • Submission to a live corporate e-billing portal — not tested, and not something I can test
    without a client engagement

Checklist

  • Skill frontmatter includes name, description, argument-hint
  • Skills that write or approve financial records use disable-model-invocation: true
  • YAML append instructions use top-level list format (- id: at column 0)
  • Hook scripts exit 0 in all cases; Stop hook outputs valid JSON to stdout
  • No hardcoded paths, thresholds, or rates — all resolved from config at run time
  • No real billing data committed — .gitignore covers registers and invoice files
  • All skill invocations use the /billing-legal: namespace
  • Committed .ps1 files and generated data files are pure ASCII
  • claude plugin validate --strict passes on the plugin and the marketplace manifest
  • MIT license
  • CLA signed

Follow-up, not in this PR

A hash chain over register entries (prev_hash / hash per entry) would make tampering
localizable 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.

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@emtcmca

emtcmca commented May 21, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request May 21, 2026
@zeweihan

Copy link
Copy Markdown

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 ~/.claude/plugins/config/claude-for-legal/billing/ is a clean approach. One suggestion: consider adding export to standard legal billing formats (LEDES 1998B, UTBMS codes) which most law firms and corporate legal departments require for invoice submission. Even a basic LEDES CSV export would significantly increase adoption in mid-size firms.

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.

@emtcmca

emtcmca commented May 29, 2026

Copy link
Copy Markdown
Author

@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 /billing-legal:ledes-export skill generates a spec-compliant LEDES 1998B file from any issued invoice or by client/period. The pipe-delimited output maps directly from existing time register data: UTBMS task codes were already captured, so the main additions were timekeeper_id and timekeeper_classification on attorney profiles, and ledes_client_id on client profiles (all optional, with sensible fallbacks for solo practitioners who don't need them). Write-off entries are excluded from the output — zero-dollar lines tend to cause rejections in most e-billing portals. The cold-start interview now asks whether to enable LEDES export and sets a default timekeeper classification for the firm.

Activity audit trail — New hooks/activity-log.ps1 (PostToolUse hook) silently records Edit/Write/Read operations on non-billing files whenever a session timer is active. At session end, the billing panel surfaces the document list above the narrative prompt — so the attorney can write a specific narrative rather than reconstructing from memory. The log is stored in the time register entry as activity_log. When Activity log on invoice: enabled is set, invoice exhibits include a collapsed <details> audit trail section after each matter's time entries, giving billing reviewers the concrete per-session evidence you described. The hook stores only filenames (not full paths) so it's safe for shared billing data folders.

Both features are opt-in during cold-start setup or via /billing-legal:customize. Existing installs are unaffected until they re-run setup or add the new config keys manually.

…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>
@emtcmca

emtcmca commented Jul 12, 2026

Copy link
Copy Markdown
Author

Friendly nudge for maintainer review when someone has bandwidth 🙏

Quick status so this is easy to pick up:

  • CLA signed and green.
  • Mergeable with no conflicts against main; scope is contained entirely under external_plugins/billing-legal/.
  • I just pushed a commit bringing the branch up to v1.1.0, which implements the two suggestions from @zeweihan's review:
    • /billing-legal:ledes-export — spec-compliant LEDES 1998B export
    • hooks/activity-log.ps1 — document-level activity audit trail
    • (also folded in a few intervening metadata/namespace fixes and removed a machine-specific .claude/settings.local.json that shouldn't have shipped)

Happy to make any changes needed to fit registry conventions — just let me know. Thanks for taking a look!

emtcmca added 16 commits July 12, 2026 18:33
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.
@emtcmca

emtcmca commented Aug 20, 2026

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants