skilldrop-cli 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +10 -10
  2. package/bin/skilldrop.js +15 -10
  3. package/catalogue.json +8 -4
  4. package/contracts/catalogue.schema.json +2 -1
  5. package/model-routing.json +70 -78
  6. package/package.json +1 -1
  7. package/packs/ai-engineering/skills/agent-adoption-stage/SKILL.md +0 -1
  8. package/packs/ai-engineering/skills/agent-adoption-stage/manifest.json +3 -4
  9. package/packs/ai-engineering/skills/ai-adoption-rollout/SKILL.md +0 -1
  10. package/packs/ai-engineering/skills/ai-adoption-rollout/manifest.json +3 -4
  11. package/packs/ai-engineering/skills/ai-readiness-assessment/SKILL.md +0 -1
  12. package/packs/ai-engineering/skills/ai-readiness-assessment/manifest.json +1 -2
  13. package/packs/ai-engineering/skills/ai-use-case-triage/manifest.json +3 -3
  14. package/packs/ai-engineering/skills/judge-audit/SKILL.md +29 -13
  15. package/packs/ai-engineering/skills/judge-audit/evals/evals.json +30 -0
  16. package/packs/ai-engineering/skills/judge-audit/evals/files/12/judge_runs.csv +241 -0
  17. package/packs/ai-engineering/skills/judge-audit/evals/files/12/rosa_labels.csv +33 -0
  18. package/packs/ai-engineering/skills/judge-audit/evals/files/13/moderation_eval.csv +601 -0
  19. package/packs/ai-engineering/skills/judge-audit/manifest.json +2 -3
  20. package/packs/ai-engineering/skills/llm-eval-harness/SKILL.md +1 -2
  21. package/packs/ai-engineering/skills/llm-eval-harness/manifest.json +3 -3
  22. package/packs/ai-engineering/skills/mcp-pairing/manifest.json +1 -1
  23. package/packs/ai-engineering/skills/mcp-pairing/reference.md +4 -3
  24. package/packs/ai-engineering/skills/subagent-design/SKILL.md +1 -0
  25. package/packs/ai-engineering/skills/subagent-design/examples/help-center-localization.md +146 -0
  26. package/packs/ai-engineering/skills/subagent-design/manifest.json +2 -2
  27. package/packs/api-builder/skills/prompt-caching-advisor/SKILL.md +4 -0
  28. package/packs/api-builder/skills/prompt-caching-advisor/examples/support-bot-block-order.md +120 -0
  29. package/packs/api-builder/skills/prompt-caching-advisor/manifest.json +2 -2
  30. package/packs/api-builder/skills/tool-use-schema-writer/manifest.json +2 -2
  31. package/packs/career/skills/application-form-answers/manifest.json +3 -3
  32. package/packs/career/skills/cover-letter/manifest.json +3 -3
  33. package/packs/career/skills/resume-tailor/manifest.json +3 -3
  34. package/packs/converters/skills/md-to-docx/manifest.json +3 -3
  35. package/packs/core/loops/ship-a-draft/evals/evals.json +28 -0
  36. package/packs/core/loops/ship-a-draft/evals/files/ship-a-draft-adr-from-thread/slack-thread.txt +47 -0
  37. package/packs/core/loops/ship-a-draft/evals/files/ship-a-draft-announcement-unsettled/ticket-PRD-412.md +44 -0
  38. package/packs/core/skills/output-hygiene/manifest.json +3 -3
  39. package/packs/data-analytics/loops/measure/evals/evals.json +30 -0
  40. package/packs/data-analytics/loops/measure/evals/files/measure-active-customers-dispute/schema.md +43 -0
  41. package/packs/data-analytics/loops/measure/evals/files/measure-active-customers-dispute/sql/finance_active_customers.sql +7 -0
  42. package/packs/data-analytics/loops/measure/evals/files/measure-active-customers-dispute/sql/growth_active_customers.sql +9 -0
  43. package/packs/data-analytics/loops/measure/evals/files/measure-active-customers-dispute/thread.md +30 -0
  44. package/packs/data-analytics/loops/measure/evals/files/measure-retention-dashboard/request.md +34 -0
  45. package/packs/data-analytics/loops/measure/evals/files/measure-retention-dashboard/schema.md +58 -0
  46. package/packs/data-analytics/loops/measure/evals/files/measure-retention-dashboard/sql/active_subscribers.sql +17 -0
  47. package/packs/data-analytics/loops/measure/evals/files/measure-retention-dashboard/sql/net_revenue_by_plan.sql +15 -0
  48. package/packs/data-analytics/loops/measure/evals/files/measure-retention-dashboard/sql/weekly_churn_rate.sql +34 -0
  49. package/packs/data-analytics/skills/dashboard-spec/SKILL.md +0 -1
  50. package/packs/data-analytics/skills/dashboard-spec/manifest.json +1 -2
  51. package/packs/design/skills/slide-outliner/manifest.json +2 -2
  52. package/packs/dev-team/loops/build/evals/evals.json +28 -0
  53. package/packs/dev-team/loops/build/evals/files/build-promo-codes/.github/workflows/ci.yml.fixture +19 -0
  54. package/packs/dev-team/loops/build/evals/files/build-promo-codes/cartcalc/__init__.py +4 -0
  55. package/packs/dev-team/loops/build/evals/files/build-promo-codes/cartcalc/pricing.py +47 -0
  56. package/packs/dev-team/loops/build/evals/files/build-promo-codes/pyproject.toml.fixture +9 -0
  57. package/packs/dev-team/loops/build/evals/files/build-promo-codes/story.md +61 -0
  58. package/packs/dev-team/loops/build/evals/files/build-promo-codes/tests/__init__.py +0 -0
  59. package/packs/dev-team/loops/build/evals/files/build-promo-codes/tests/test_pricing.py +49 -0
  60. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/.github/workflows/ci.yml.fixture +19 -0
  61. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/bug-report.md +25 -0
  62. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/pr-description.md +42 -0
  63. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/pyproject.toml.fixture +9 -0
  64. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/ratelimit/__init__.py +4 -0
  65. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/ratelimit/bucket.py +49 -0
  66. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/tests/__init__.py +0 -0
  67. package/packs/dev-team/loops/build/evals/files/build-rate-limiter-not-ready/tests/test_bucket.py +63 -0
  68. package/packs/dev-team/loops/modernize/evals/evals.json +30 -0
  69. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/.github/workflows/ci.yml.fixture +11 -0
  70. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/README.md +17 -0
  71. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/docs/adr/0004-late-fee-calculation.md +25 -0
  72. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/legacy/feecalc.py +74 -0
  73. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/samples/accounts_2026-10.txt +10 -0
  74. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/samples/accounts_edge.txt +11 -0
  75. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-batch-port/tests/test_feecalc_smoke.py +21 -0
  76. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/.github/workflows/ci.yml.fixture +11 -0
  77. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/PR-412.md +24 -0
  78. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/README.md +17 -0
  79. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/docs/adr/0004-late-fee-calculation.md +25 -0
  80. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/hartwell_fees/__init__.py +1 -0
  81. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/hartwell_fees/cli.py +33 -0
  82. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/hartwell_fees/extract.py +33 -0
  83. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/hartwell_fees/rules.py +39 -0
  84. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/legacy/feecalc.py +74 -0
  85. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/samples/accounts_2026-10.txt +10 -0
  86. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/samples/accounts_edge.txt +11 -0
  87. package/packs/dev-team/loops/modernize/evals/files/modernize-late-fee-rewrite-not-equivalent/tests/test_feecalc_smoke.py +21 -0
  88. package/packs/dev-team/loops/release/LOOP.md +4 -4
  89. package/packs/dev-team/loops/release/evals/eval_queries.json +10 -0
  90. package/packs/dev-team/loops/release/evals/evals.json +34 -0
  91. package/packs/dev-team/loops/release/evals/files/release-irreversible-tomorrow/0061_switch_to_haldrin_tokens.sql +22 -0
  92. package/packs/dev-team/loops/release/evals/files/release-irreversible-tomorrow/eng-notes.md +40 -0
  93. package/packs/dev-team/loops/release/evals/files/release-irreversible-tomorrow/merged-prs.md +13 -0
  94. package/packs/dev-team/loops/release/evals/files/release-saved-searches/merged-prs.md +13 -0
  95. package/packs/dev-team/loops/release/evals/files/release-saved-searches/readiness-evidence.md +27 -0
  96. package/packs/dev-team/loops/release/evals/files/release-saved-searches/traffic-baseline.md +23 -0
  97. package/packs/dev-team/loops/release/loop.json +1 -1
  98. package/packs/dev-team/skills/bug-triage/SKILL.md +1 -0
  99. package/packs/dev-team/skills/bug-triage/examples/export-row-limit-escalation.md +126 -0
  100. package/packs/dev-team/skills/bug-triage/manifest.json +2 -2
  101. package/packs/dev-team/skills/feature-implement-loop/manifest.json +2 -2
  102. package/packs/dev-team/skills/pr-description-writer/manifest.json +3 -3
  103. package/packs/dev-team/skills/pre-merge-review/manifest.json +2 -2
  104. package/packs/dev-team/skills/release-notes/manifest.json +2 -2
  105. package/packs/dev-team/skills/test-plan-generator/manifest.json +2 -2
  106. package/packs/experience-design/skills/service-blueprint/manifest.json +3 -3
  107. package/packs/experience-design/skills/ux-writing/manifest.json +3 -3
  108. package/packs/grc/loops/comply/evals/evals.json +29 -0
  109. package/packs/grc/loops/comply/evals/files/comply-chat-vendor-launch/vendor-brief.md +72 -0
  110. package/packs/grc/loops/comply/evals/files/comply-loyalty-app-unowned-risks/launch-note.md +31 -0
  111. package/packs/grc/loops/comply/evals/files/comply-loyalty-app-unowned-risks/risk-register.csv +6 -0
  112. package/packs/infra-as-code/skills/terraform-module/manifest.json +3 -3
  113. package/packs/product-manager/skills/prd-draft/SKILL.md +1 -0
  114. package/packs/product-manager/skills/prd-draft/examples/approver-delay-not-slack.md +142 -0
  115. package/packs/product-manager/skills/prd-draft/manifest.json +2 -2
  116. package/packs/product-manager/skills/user-journey-map/manifest.json +2 -2
  117. package/packs/research/loops/research/evals/evals.json +28 -0
  118. package/packs/research/loops/research/evals/files/research-ticket-spike-confounded/sources/t1-ticket-volume.md +15 -0
  119. package/packs/research/loops/research/evals/files/research-ticket-spike-confounded/sources/t2-release-notes-4-2.md +11 -0
  120. package/packs/research/loops/research/evals/files/research-ticket-spike-confounded/sources/t3-billing-migration.md +12 -0
  121. package/packs/research/loops/research/evals/files/research-ticket-spike-confounded/sources/t4-agent-poll.md +11 -0
  122. package/packs/research/loops/research/evals/files/research-ticket-spike-confounded/sources/t5-app-store-reviews.md +12 -0
  123. package/packs/research/loops/research/evals/files/research-trial-conversion-drop/sources/s1-analytics-export.md +29 -0
  124. package/packs/research/loops/research/evals/files/research-trial-conversion-drop/sources/s2-email-delivery-report.md +25 -0
  125. package/packs/research/loops/research/evals/files/research-trial-conversion-drop/sources/s3-pricing-changelog.md +9 -0
  126. package/packs/research/loops/research/evals/files/research-trial-conversion-drop/sources/s4-trial-interview-notes.md +21 -0
  127. package/packs/research/loops/research/evals/files/research-trial-conversion-drop/sources/s5-competitor-note.md +9 -0
  128. package/packs/research/loops/research/evals/files/research-trial-conversion-drop/sources/s6-weekly-growth-recap.md +7 -0
  129. package/packs/research/skills/research-plan/manifest.json +3 -3
  130. package/packs/research/skills/source-synthesis/manifest.json +3 -3
  131. package/packs/skill-engineering/skills/skill-author/manifest.json +3 -3
  132. package/packs/solution-architect/loops/design/evals/evals.json +28 -0
  133. package/packs/solution-architect/loops/design/evals/files/design-bulk-export/adr/0001-postgres-system-of-record.md +23 -0
  134. package/packs/solution-architect/loops/design/evals/files/design-bulk-export/adr/0002-background-work-via-job-queue.md +23 -0
  135. package/packs/solution-architect/loops/design/evals/files/design-bulk-export/adr/0003-tenant-isolation-row-level.md +25 -0
  136. package/packs/solution-architect/loops/design/evals/files/design-bulk-export/prd-bulk-export.md +50 -0
  137. package/packs/solution-architect/loops/design/evals/files/design-draft-prd-webhooks/adr/0002-background-work-via-job-queue.md +23 -0
  138. package/packs/solution-architect/loops/design/evals/files/design-draft-prd-webhooks/adr/0004-new-stateful-infra-needs-platform-signoff.md +22 -0
  139. package/packs/solution-architect/loops/design/evals/files/design-draft-prd-webhooks/prd-partner-webhooks.md +36 -0
  140. package/packs/solution-architect/skills/api-contract-draft/manifest.json +3 -3
  141. package/packs/solution-architect/skills/architecture-diagrams/manifest.json +3 -3
  142. package/packs/solution-architect/skills/design-doc/manifest.json +2 -2
  143. package/packs/solution-architect/skills/figma-diagrams/manifest.json +3 -3
  144. package/packs/sre-oncall/skills/cloud-cost-review/SKILL.md +0 -1
  145. package/packs/sre-oncall/skills/cloud-cost-review/manifest.json +1 -2
  146. package/packs/sre-oncall/skills/delivery-metrics-report/SKILL.md +0 -1
  147. package/packs/sre-oncall/skills/delivery-metrics-report/manifest.json +1 -2
  148. package/packs/sre-oncall/skills/incident-comms/manifest.json +2 -2
  149. package/packs/sre-oncall/skills/postmortem-generator/manifest.json +2 -2
  150. package/packs/stakeholder-comms/skills/decision-log/manifest.json +2 -2
  151. package/packs/trackers/skills/team-status-report/manifest.json +3 -3
  152. package/profiles.json +1 -1
  153. package/packs/ai-engineering/loops/adopt/LOOP.md +0 -120
  154. package/packs/ai-engineering/loops/adopt/evals/eval_queries.json +0 -10
  155. package/packs/ai-engineering/loops/adopt/evals/evals.json +0 -69
  156. package/packs/ai-engineering/loops/adopt/evals/files/adopt-first-wave-to-sign-off/ai-ideas.md +0 -15
  157. package/packs/ai-engineering/loops/adopt/evals/files/adopt-first-wave-to-sign-off/org-context.md +0 -27
  158. package/packs/ai-engineering/loops/adopt/evals/files/adopt-first-wave-to-sign-off/staff-survey.csv +0 -11
  159. package/packs/ai-engineering/loops/adopt/evals/files/adopt-sponsor-slice-blocked-by-client-data/ai-ideas.md +0 -13
  160. package/packs/ai-engineering/loops/adopt/evals/files/adopt-sponsor-slice-blocked-by-client-data/firm-context.md +0 -23
  161. package/packs/ai-engineering/loops/adopt/loop.json +0 -53
  162. package/packs/ai-engineering/skills/ai-usage-report/SKILL.md +0 -111
  163. package/packs/ai-engineering/skills/ai-usage-report/evals/eval_queries.json +0 -30
  164. package/packs/ai-engineering/skills/ai-usage-report/evals/evals.json +0 -46
  165. package/packs/ai-engineering/skills/ai-usage-report/evals/files/1/northwind-ai-events.csv +0 -4213
  166. package/packs/ai-engineering/skills/ai-usage-report/evals/files/2/draftwell-events.csv +0 -249
  167. package/packs/ai-engineering/skills/ai-usage-report/evals/files/2/export-note.md +0 -9
  168. package/packs/ai-engineering/skills/ai-usage-report/evals/files/2/published-assets.csv +0 -44
  169. package/packs/ai-engineering/skills/ai-usage-report/evals/files/2/team-roster.md +0 -10
  170. package/packs/ai-engineering/skills/ai-usage-report/examples/weekly-engineering-team.md +0 -133
  171. package/packs/ai-engineering/skills/ai-usage-report/manifest.json +0 -12
  172. package/packs/ai-engineering/skills/ai-usage-report/scripts/build_report.py +0 -453
  173. package/packs/ai-engineering/skills/ai-usage-report/templates/report-per-user.md +0 -42
  174. package/packs/ai-engineering/skills/ai-usage-report/templates/report-team-rollup.md +0 -49
  175. package/packs/ai-engineering/skills/ai-usage-report/templates/usage-event-schema.md +0 -76
  176. package/packs/career/loops/apply/LOOP.md +0 -97
  177. package/packs/career/loops/apply/evals/eval_queries.json +0 -9
  178. package/packs/career/loops/apply/evals/evals.json +0 -27
  179. package/packs/career/loops/apply/loop.json +0 -52
  180. package/packs/product-manager/loops/discover/LOOP.md +0 -82
  181. package/packs/product-manager/loops/discover/evals/eval_queries.json +0 -30
  182. package/packs/product-manager/loops/discover/evals/evals.json +0 -26
  183. package/packs/product-manager/loops/discover/loop.json +0 -41
  184. package/packs/product-manager/skills/lean-canvas/SKILL.md +0 -162
  185. package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +0 -42
  186. package/packs/product-manager/skills/lean-canvas/evals/evals.json +0 -82
  187. package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +0 -54
  188. package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +0 -30
  189. package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +0 -44
  190. package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +0 -5
  191. package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +0 -40
  192. package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +0 -18
  193. package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +0 -72
  194. package/packs/product-manager/skills/lean-canvas/manifest.json +0 -34
  195. package/packs/product-manager/skills/lean-canvas/reference.md +0 -102
  196. package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +0 -211
  197. package/packs/sre-oncall/loops/operate/LOOP.md +0 -84
  198. package/packs/sre-oncall/loops/operate/evals/eval_queries.json +0 -30
  199. package/packs/sre-oncall/loops/operate/evals/evals.json +0 -25
  200. package/packs/sre-oncall/loops/operate/loop.json +0 -41
@@ -0,0 +1,49 @@
1
+ """Token bucket rate limiter.
2
+
3
+ A bucket holds up to `capacity` tokens and gains `refill_per_sec` tokens per second.
4
+ Each request spends `cost` tokens; a request is allowed when enough tokens are left.
5
+ The clock is injectable so tests do not sleep.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Callable
11
+
12
+
13
+ class TokenBucket:
14
+ def __init__(
15
+ self,
16
+ capacity: float,
17
+ refill_per_sec: float,
18
+ clock: Callable[[], float] = time.monotonic,
19
+ ) -> None:
20
+ if capacity <= 0:
21
+ raise ValueError("capacity must be positive")
22
+ if refill_per_sec < 0:
23
+ raise ValueError("refill_per_sec must not be negative")
24
+ self.capacity = float(capacity)
25
+ self.refill_per_sec = float(refill_per_sec)
26
+ self._clock = clock
27
+ self._tokens = float(capacity)
28
+ self._last = clock()
29
+
30
+ def _refill(self) -> None:
31
+ now = self._clock()
32
+ elapsed = max(0.0, now - self._last)
33
+ self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_per_sec)
34
+ self._last = now
35
+
36
+ @property
37
+ def tokens(self) -> float:
38
+ self._refill()
39
+ return self._tokens
40
+
41
+ def allow(self, cost: float = 1.0) -> bool:
42
+ """Spend `cost` tokens if available. Returns True if the request is allowed."""
43
+ if cost <= 0:
44
+ raise ValueError("cost must be positive")
45
+ self._refill()
46
+ if self._tokens > cost:
47
+ self._tokens -= cost
48
+ return True
49
+ return False
@@ -0,0 +1,63 @@
1
+ import unittest
2
+
3
+ from ratelimit import TokenBucket
4
+
5
+
6
+ class FakeClock:
7
+ def __init__(self, start: float = 1000.0) -> None:
8
+ self.now = start
9
+
10
+ def __call__(self) -> float:
11
+ return self.now
12
+
13
+ def advance(self, seconds: float) -> None:
14
+ self.now += seconds
15
+
16
+
17
+ def burst(bucket: TokenBucket, n: int) -> int:
18
+ return sum(1 for _ in range(n) if bucket.allow())
19
+
20
+
21
+ class TokenBucketTests(unittest.TestCase):
22
+ def setUp(self) -> None:
23
+ self.clock = FakeClock()
24
+
25
+ def test_burst_allows_exactly_capacity(self):
26
+ bucket = TokenBucket(capacity=5, refill_per_sec=5, clock=self.clock)
27
+ self.assertEqual(burst(bucket, 10), 5)
28
+
29
+ def test_refills_over_time(self):
30
+ bucket = TokenBucket(capacity=5, refill_per_sec=5, clock=self.clock)
31
+ burst(bucket, 10)
32
+ self.clock.advance(0.5)
33
+ self.assertGreaterEqual(bucket.tokens, 2.5)
34
+
35
+ def test_idle_bucket_does_not_exceed_capacity(self):
36
+ # BUG-311 repro
37
+ bucket = TokenBucket(capacity=5, refill_per_sec=5, clock=self.clock)
38
+ self.clock.advance(3600)
39
+ self.assertLessEqual(bucket.tokens, 5)
40
+ self.assertLessEqual(burst(bucket, 50), 5)
41
+
42
+ def test_clock_going_backwards_does_not_add_tokens(self):
43
+ bucket = TokenBucket(capacity=5, refill_per_sec=5, clock=self.clock)
44
+ burst(bucket, 5)
45
+ self.clock.advance(-10)
46
+ self.assertFalse(bucket.allow())
47
+
48
+ def test_cost_larger_than_capacity_is_refused(self):
49
+ bucket = TokenBucket(capacity=5, refill_per_sec=5, clock=self.clock)
50
+ self.assertFalse(bucket.allow(cost=6))
51
+
52
+ def test_rejects_bad_arguments(self):
53
+ with self.assertRaises(ValueError):
54
+ TokenBucket(capacity=0, refill_per_sec=1, clock=self.clock)
55
+ with self.assertRaises(ValueError):
56
+ TokenBucket(capacity=1, refill_per_sec=-1, clock=self.clock)
57
+ bucket = TokenBucket(capacity=1, refill_per_sec=1, clock=self.clock)
58
+ with self.assertRaises(ValueError):
59
+ bucket.allow(cost=0)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()
@@ -34,6 +34,36 @@
34
34
  "Treats a behaviour that contradicts the recorded decision as a question for the owner before capture, not as something to fix silently in the rewrite",
35
35
  "Does not invent decisions, ids, commits or people that are not in the repository"
36
36
  ]
37
+ },
38
+ {
39
+ "id": "modernize-late-fee-batch-port",
40
+ "held_out": "2026-11-04",
41
+ "prompt": "Run the modernize loop on legacy/feecalc.py. I want it moved into a proper Python package (hartwell_fees/, no globals, testable functions) so we can add the 2027 tariff next year without being scared of it. Billing parses the fee file strictly so the output can't change. Sample extracts are in samples/. Go as far as you can, I'm not around today.",
42
+ "assertions": [
43
+ "Runs the stages in order: it orients in the old code (feecalc.py, the README and ADR 0004) before planning, records the legacy outputs on the sample extracts before writing any new code, and compares new against old only after the rebuild",
44
+ "Runs the legacy job at least twice on the same input, finds that the 'generated' timestamp in the header is the only field that changes between runs, and normalises that field alone, not the whole header or the TOTAL trailer",
45
+ "The recordings cover the edge cases in the samples: a credit balance, a zero balance (10000108), half-penny rounding (20000202, 20000203), exactly 14 and exactly 90 days overdue, an unknown tier, and the two malformed lines counted as SKIPPED 2",
46
+ "Keeps the legacy behaviour of charging the £5.00 minimum on zero and credit balances (10000108, 20000201), or lists it as an intended change that needs Finance's approval under ADR 0004. It does not fix it silently",
47
+ "The rebuild keeps the output format exactly: the FEERUN header, one account,NAME,days,fee line per charged account in input order, and the TOTAL trailer",
48
+ "The prove step runs a script that compares the new package's output with the recordings, reports the script's result or exit code, and gives an equivalence verdict based on that comparison",
49
+ "Leaves the cutover decision to a person. It gives a launch-readiness view with a rollback path (keep the legacy job), and does not switch the job over, delete legacy/ or claim the cutover is done",
50
+ "Does not invent behaviour, inputs or systems that are not in the repo, such as a database, an API, or 2027 tariff rules"
51
+ ]
52
+ },
53
+ {
54
+ "id": "modernize-late-fee-rewrite-not-equivalent",
55
+ "held_out": "2026-11-04",
56
+ "prompt": "Wren rewrote feecalc as the hartwell_fees package, details in PR-412.md. We want to swap the cron over Thursday night. Can you run modernize on it and prove it's the same as the old one, then give me the go for Thursday? Sample extracts are in samples/.",
57
+ "assertions": [
58
+ "Records the legacy job's output on both sample files and replays the new package against those recordings before reaching a verdict. It does not rely on the PR's 'eyeballed' check",
59
+ "Normalises only the header timestamp when comparing, and the differences it reports come from the account lines and the TOTAL trailer",
60
+ "Finds that on accounts_2026-10.txt the new package drops the £5.00 fee for 10000108 (zero balance) and the totals differ (234.59 against 229.59), which contradicts the PR's claim that the outputs look the same",
61
+ "Finds that half-penny fees differ: 20000202 is 5.03 against 5.02 and 20000203 is 6.01 against 6.00, because ROUND_HALF_EVEN breaks ADR 0004's halves-up rule. Treats this as a defect",
62
+ "Treats dropping fees on zero and credit balances as an intended change with no named approver. ADR 0004 says changes to the rules need Finance (the Revenue Assurance Manager) to approve them",
63
+ "The prove gate fails with a NOT EQUIVALENT (or NOT PROVEN) verdict. The loop does not go on to approve the cutover, and it does not give the go for Thursday",
64
+ "Gives concrete fixes and a way to re-prove: switch to ROUND_HALF_UP, then either restore the minimum fee on non-positive balances or get Finance's approval for the change, then run the comparison again",
65
+ "Does not invent an approver for the credit-balance change, or claim that the billing upload will accept the changed totals"
66
+ ]
37
67
  }
38
68
  ]
39
69
  }
@@ -0,0 +1,11 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-python@v5
9
+ with:
10
+ python-version: "3.12"
11
+ - run: python3 -m unittest discover -s tests
@@ -0,0 +1,17 @@
1
+ # billing-batch
2
+
3
+ Nightly batch jobs for Hartwell Water billing.
4
+
5
+ ## feecalc (legacy)
6
+
7
+ `legacy/feecalc.py` reads the nightly fixed-width account extract and writes the late-fee
8
+ file that the billing system uploads at 02:00. The billing upload parses the fee file
9
+ strictly: header line, one `account,NAME,days,fee` line per charged account, and the
10
+ `# TOTAL` trailer. It rejects the whole file if the trailer total doesn't match the lines.
11
+
12
+ python3 legacy/feecalc.py samples/accounts_2026-10.txt 20261031
13
+
14
+ Sample extracts are in `samples/`. They are anonymised copies of real extract rows plus
15
+ some edge cases ops collected.
16
+
17
+ Tests: `python3 -m unittest discover -s tests`
@@ -0,0 +1,25 @@
1
+ # ADR 0004: Late-fee calculation rules
2
+
3
+ Status: Accepted
4
+ Date: 2014-03-11
5
+ Deciders: Billing operations, Finance
6
+
7
+ ## Context
8
+
9
+ The 2011 tariff sets late fees as a percentage of the overdue balance. Regulator guidance
10
+ for priority (vulnerable) customers means priority customers must have a lower rate and a cap.
11
+
12
+ ## Decision
13
+
14
+ - No fee within a 14-day grace period.
15
+ - Standard customers: 1.5% of balance. Priority customers: 1.0% of balance, capped at £25.00.
16
+ - Fees are rounded to the nearest penny, halves rounded up.
17
+ - A minimum fee of £5.00 applies whenever a fee is charged.
18
+ - Accounts more than 90 days overdue pay double (the second-stage arrears fee). The cap
19
+ still applies to priority customers after doubling.
20
+ - Unknown tier codes are treated as standard.
21
+
22
+ ## Consequences
23
+
24
+ The rules live in `legacy/feecalc.py`. Any change to them needs Finance approval
25
+ (currently: the Revenue Assurance Manager).
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ # feecalc.py - nightly late-fee batch for Hartwell Water accounts
3
+ # Original: R. Penhallow, 2009. Ported from the old Perl job, "minimal changes".
4
+ # Reads the fixed-width account extract, writes the fee file for the billing upload.
5
+ #
6
+ # usage: python3 feecalc.py ACCOUNTS_FILE [RUN_DATE]
7
+ # RUN_DATE is YYYYMMDD; defaults to today.
8
+ #
9
+ # Record layout (fixed width, 43 chars):
10
+ # 0-8 account id (8, digits)
11
+ # 8-28 account name (20, space padded)
12
+ # 28-38 balance pence (10, signed, right aligned, e.g. " 12345" or " -2000")
13
+ # 38-42 days overdue (4, right aligned)
14
+ # 42 tier (1: S = standard, P = priority/vulnerable customer)
15
+
16
+ import sys, time
17
+
18
+ GRACE = 14 # days before any fee applies
19
+ RATE = {'S': 150, 'P': 100} # basis points of balance
20
+ MINFEE = 500 # pence
21
+ CAP = {'P': 2500} # priority customers capped at 25.00
22
+ skipped = 0
23
+ total = 0
24
+ out = []
25
+
26
+ def fee_for(bal, days, tier):
27
+ global total
28
+ if days <= GRACE:
29
+ return 0
30
+ f = (bal * RATE[tier] + 5000) // 10000
31
+ if f < MINFEE:
32
+ f = MINFEE
33
+ if tier in CAP and f > CAP[tier]:
34
+ f = CAP[tier]
35
+ if days > 90:
36
+ f = f * 2 # second-stage arrears fee, per 2011 tariff
37
+ if tier in CAP and f > CAP[tier]:
38
+ f = CAP[tier]
39
+ total = total + f
40
+ return f
41
+
42
+ def main():
43
+ global skipped
44
+ if len(sys.argv) < 2:
45
+ print("usage: feecalc.py ACCOUNTS_FILE [RUN_DATE]")
46
+ sys.exit(2)
47
+ run_date = sys.argv[2] if len(sys.argv) > 2 else time.strftime('%Y%m%d')
48
+ for line in open(sys.argv[1]):
49
+ line = line.rstrip('\n')
50
+ if line.strip() == '' or line.startswith('#'):
51
+ continue
52
+ if len(line) != 43:
53
+ skipped = skipped + 1
54
+ continue
55
+ acct = line[0:8]
56
+ name = line[8:28].strip()
57
+ try:
58
+ bal = int(line[28:38])
59
+ days = int(line[38:42])
60
+ except ValueError:
61
+ skipped = skipped + 1
62
+ continue
63
+ tier = line[42]
64
+ if tier not in RATE:
65
+ tier = 'S'
66
+ f = fee_for(bal, days, tier)
67
+ if f > 0:
68
+ out.append('%s,%s,%d,%d.%02d' % (acct, name.upper(), days, f // 100, f % 100))
69
+ print('# FEERUN %s generated %s' % (run_date, time.strftime('%Y-%m-%dT%H:%M:%S')))
70
+ for o in out:
71
+ print(o)
72
+ print('# TOTAL %d.%02d RECORDS %d SKIPPED %d' % (total // 100, total % 100, len(out), skipped))
73
+
74
+ main()
@@ -0,0 +1,10 @@
1
+ 10000101A Okafor 48210 30S
2
+ 10000102B Marchetti 1290 3S
3
+ 10000103C Duval 233400 45S
4
+ 10000104D Nakamura 87500 21P
5
+ 10000105E Brennan 412000 120S
6
+ 10000106F Osei 310000 60P
7
+ 10000107G Halvorsen 15000 15S
8
+ 10000108H Iqbal 0 40S
9
+ 10000109I Moreau 66600 14S
10
+ 10000110J Kowalczyk 125075 95P
@@ -0,0 +1,11 @@
1
+ # edge cases collected by ops, Oct 2026
2
+ 20000201Credit Balance Ltd -2000 30S
3
+ 20000202Half Pence S 33500 30S
4
+ 20000203Half Pence P 60050 30P
5
+ 20000204Exactly Grace 99999 14S
6
+ 20000205Unknown Tier 50000 30X
7
+ 20000206Cap Then Double 300000 91P
8
+ 20000207lower case name 40000 30S
9
+ 20000208Short Line 123
10
+ 20000209Bad Balance 12a4567 30S
11
+ 20000210Exactly Ninety 400000 90S
@@ -0,0 +1,21 @@
1
+ import subprocess
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parent.parent
7
+
8
+
9
+ class SmokeTest(unittest.TestCase):
10
+ def test_runs_and_writes_trailer(self):
11
+ out = subprocess.run(
12
+ [sys.executable, str(ROOT / "legacy" / "feecalc.py"),
13
+ str(ROOT / "samples" / "accounts_2026-10.txt"), "20261031"],
14
+ capture_output=True, text=True, check=True,
15
+ ).stdout
16
+ self.assertTrue(out.startswith("# FEERUN 20261031"))
17
+ self.assertIn("# TOTAL", out.splitlines()[-1])
18
+
19
+
20
+ if __name__ == "__main__":
21
+ unittest.main()
@@ -0,0 +1,11 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-python@v5
9
+ with:
10
+ python-version: "3.12"
11
+ - run: python3 -m unittest discover -s tests
@@ -0,0 +1,24 @@
1
+ # PR #412: Replace legacy feecalc with hartwell_fees package
2
+
3
+ Author: Wren Abernathy
4
+ Branch: wren/hartwell-fees-rewrite
5
+
6
+ ## What
7
+
8
+ Rewrites `legacy/feecalc.py` as a proper package (`hartwell_fees/`): dataclasses, Decimal
9
+ money, no globals, a parser that can be unit tested. Same output format, so the billing
10
+ upload doesn't need to change.
11
+
12
+ Run it the same way:
13
+
14
+ python3 -m hartwell_fees.cli samples/accounts_2026-10.txt 20261031
15
+
16
+ ## Testing
17
+
18
+ Ran both on `samples/accounts_2026-10.txt` and eyeballed the output, looks the same apart
19
+ from the timestamp. Also fixed the thing where accounts in credit got charged a fiver, which
20
+ was obviously a bug.
21
+
22
+ ## Plan
23
+
24
+ Swap the cron entry over on Thursday night's run and delete `legacy/` the week after.
@@ -0,0 +1,17 @@
1
+ # billing-batch
2
+
3
+ Nightly batch jobs for Hartwell Water billing.
4
+
5
+ ## feecalc (legacy)
6
+
7
+ `legacy/feecalc.py` reads the nightly fixed-width account extract and writes the late-fee
8
+ file that the billing system uploads at 02:00. The billing upload parses the fee file
9
+ strictly: header line, one `account,NAME,days,fee` line per charged account, and the
10
+ `# TOTAL` trailer. It rejects the whole file if the trailer total doesn't match the lines.
11
+
12
+ python3 legacy/feecalc.py samples/accounts_2026-10.txt 20261031
13
+
14
+ Sample extracts are in `samples/`. They are anonymised copies of real extract rows plus
15
+ some edge cases ops collected.
16
+
17
+ Tests: `python3 -m unittest discover -s tests`
@@ -0,0 +1,25 @@
1
+ # ADR 0004: Late-fee calculation rules
2
+
3
+ Status: Accepted
4
+ Date: 2014-03-11
5
+ Deciders: Billing operations, Finance
6
+
7
+ ## Context
8
+
9
+ The 2011 tariff sets late fees as a percentage of the overdue balance. Regulator guidance
10
+ for priority (vulnerable) customers means priority customers must have a lower rate and a cap.
11
+
12
+ ## Decision
13
+
14
+ - No fee within a 14-day grace period.
15
+ - Standard customers: 1.5% of balance. Priority customers: 1.0% of balance, capped at £25.00.
16
+ - Fees are rounded to the nearest penny, halves rounded up.
17
+ - A minimum fee of £5.00 applies whenever a fee is charged.
18
+ - Accounts more than 90 days overdue pay double (the second-stage arrears fee). The cap
19
+ still applies to priority customers after doubling.
20
+ - Unknown tier codes are treated as standard.
21
+
22
+ ## Consequences
23
+
24
+ The rules live in `legacy/feecalc.py`. Any change to them needs Finance approval
25
+ (currently: the Revenue Assurance Manager).
@@ -0,0 +1 @@
1
+ """Late-fee calculation for Hartwell Water billing (replacement for legacy/feecalc.py)."""
@@ -0,0 +1,33 @@
1
+ """usage: python3 -m hartwell_fees.cli ACCOUNTS_FILE [RUN_DATE]"""
2
+ import sys
3
+ from datetime import datetime
4
+ from decimal import Decimal
5
+
6
+ from .extract import parse_extract
7
+ from .rules import late_fee
8
+
9
+
10
+ def main(argv=None) -> int:
11
+ argv = sys.argv[1:] if argv is None else argv
12
+ if not argv:
13
+ print(__doc__)
14
+ return 2
15
+ run_date = argv[1] if len(argv) > 1 else datetime.now().strftime("%Y%m%d")
16
+ with open(argv[0]) as fh:
17
+ accounts, skipped = parse_extract(fh)
18
+ lines = []
19
+ total = Decimal("0")
20
+ for acct in accounts:
21
+ fee = late_fee(acct)
22
+ if fee > 0:
23
+ total += fee
24
+ lines.append(f"{acct.account_id},{acct.name.upper()},{acct.days_overdue},{fee:.2f}")
25
+ print(f"# FEERUN {run_date} generated {datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}")
26
+ for line in lines:
27
+ print(line)
28
+ print(f"# TOTAL {total:.2f} RECORDS {len(lines)} SKIPPED {skipped}")
29
+ return 0
30
+
31
+
32
+ if __name__ == "__main__":
33
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ from decimal import Decimal
2
+ from typing import Iterator, List, Tuple
3
+
4
+ from .rules import Account
5
+
6
+ RECORD_LEN = 43
7
+
8
+
9
+ def parse_extract(lines) -> Tuple[List[Account], int]:
10
+ """Parse the fixed-width extract. Returns (accounts, skipped_count)."""
11
+ accounts: List[Account] = []
12
+ skipped = 0
13
+ for raw in lines:
14
+ line = raw.rstrip("\n")
15
+ if not line.strip() or line.startswith("#"):
16
+ continue
17
+ if len(line) != RECORD_LEN:
18
+ skipped += 1
19
+ continue
20
+ try:
21
+ pence = int(line[28:38])
22
+ days = int(line[38:42])
23
+ except ValueError:
24
+ skipped += 1
25
+ continue
26
+ accounts.append(Account(
27
+ account_id=line[0:8],
28
+ name=line[8:28].strip(),
29
+ balance=Decimal(pence) / 100,
30
+ days_overdue=days,
31
+ tier=line[42],
32
+ ))
33
+ return accounts, skipped
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass
2
+ from decimal import Decimal, ROUND_HALF_EVEN
3
+
4
+ GRACE_DAYS = 14
5
+ RATES = {"S": Decimal("0.015"), "P": Decimal("0.010")}
6
+ MIN_FEE = Decimal("5.00")
7
+ CAPS = {"P": Decimal("25.00")}
8
+ SECOND_STAGE_DAYS = 90
9
+ PENNY = Decimal("0.01")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Account:
14
+ account_id: str
15
+ name: str
16
+ balance: Decimal # pounds
17
+ days_overdue: int
18
+ tier: str
19
+
20
+
21
+ def _cap(fee: Decimal, tier: str) -> Decimal:
22
+ cap = CAPS.get(tier)
23
+ return min(fee, cap) if cap is not None else fee
24
+
25
+
26
+ def late_fee(acct: Account) -> Decimal:
27
+ """Return the late fee in pounds for one account (0 if none is due)."""
28
+ if acct.days_overdue <= GRACE_DAYS:
29
+ return Decimal("0")
30
+ if acct.balance <= 0:
31
+ # Nothing owed, so nothing to charge.
32
+ return Decimal("0")
33
+ tier = acct.tier if acct.tier in RATES else "S"
34
+ fee = (acct.balance * RATES[tier]).quantize(PENNY, rounding=ROUND_HALF_EVEN)
35
+ fee = max(fee, MIN_FEE)
36
+ fee = _cap(fee, tier)
37
+ if acct.days_overdue > SECOND_STAGE_DAYS:
38
+ fee = _cap(fee * 2, tier)
39
+ return fee
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ # feecalc.py - nightly late-fee batch for Hartwell Water accounts
3
+ # Original: R. Penhallow, 2009. Ported from the old Perl job, "minimal changes".
4
+ # Reads the fixed-width account extract, writes the fee file for the billing upload.
5
+ #
6
+ # usage: python3 feecalc.py ACCOUNTS_FILE [RUN_DATE]
7
+ # RUN_DATE is YYYYMMDD; defaults to today.
8
+ #
9
+ # Record layout (fixed width, 43 chars):
10
+ # 0-8 account id (8, digits)
11
+ # 8-28 account name (20, space padded)
12
+ # 28-38 balance pence (10, signed, right aligned, e.g. " 12345" or " -2000")
13
+ # 38-42 days overdue (4, right aligned)
14
+ # 42 tier (1: S = standard, P = priority/vulnerable customer)
15
+
16
+ import sys, time
17
+
18
+ GRACE = 14 # days before any fee applies
19
+ RATE = {'S': 150, 'P': 100} # basis points of balance
20
+ MINFEE = 500 # pence
21
+ CAP = {'P': 2500} # priority customers capped at 25.00
22
+ skipped = 0
23
+ total = 0
24
+ out = []
25
+
26
+ def fee_for(bal, days, tier):
27
+ global total
28
+ if days <= GRACE:
29
+ return 0
30
+ f = (bal * RATE[tier] + 5000) // 10000
31
+ if f < MINFEE:
32
+ f = MINFEE
33
+ if tier in CAP and f > CAP[tier]:
34
+ f = CAP[tier]
35
+ if days > 90:
36
+ f = f * 2 # second-stage arrears fee, per 2011 tariff
37
+ if tier in CAP and f > CAP[tier]:
38
+ f = CAP[tier]
39
+ total = total + f
40
+ return f
41
+
42
+ def main():
43
+ global skipped
44
+ if len(sys.argv) < 2:
45
+ print("usage: feecalc.py ACCOUNTS_FILE [RUN_DATE]")
46
+ sys.exit(2)
47
+ run_date = sys.argv[2] if len(sys.argv) > 2 else time.strftime('%Y%m%d')
48
+ for line in open(sys.argv[1]):
49
+ line = line.rstrip('\n')
50
+ if line.strip() == '' or line.startswith('#'):
51
+ continue
52
+ if len(line) != 43:
53
+ skipped = skipped + 1
54
+ continue
55
+ acct = line[0:8]
56
+ name = line[8:28].strip()
57
+ try:
58
+ bal = int(line[28:38])
59
+ days = int(line[38:42])
60
+ except ValueError:
61
+ skipped = skipped + 1
62
+ continue
63
+ tier = line[42]
64
+ if tier not in RATE:
65
+ tier = 'S'
66
+ f = fee_for(bal, days, tier)
67
+ if f > 0:
68
+ out.append('%s,%s,%d,%d.%02d' % (acct, name.upper(), days, f // 100, f % 100))
69
+ print('# FEERUN %s generated %s' % (run_date, time.strftime('%Y-%m-%dT%H:%M:%S')))
70
+ for o in out:
71
+ print(o)
72
+ print('# TOTAL %d.%02d RECORDS %d SKIPPED %d' % (total // 100, total % 100, len(out), skipped))
73
+
74
+ main()
@@ -0,0 +1,10 @@
1
+ 10000101A Okafor 48210 30S
2
+ 10000102B Marchetti 1290 3S
3
+ 10000103C Duval 233400 45S
4
+ 10000104D Nakamura 87500 21P
5
+ 10000105E Brennan 412000 120S
6
+ 10000106F Osei 310000 60P
7
+ 10000107G Halvorsen 15000 15S
8
+ 10000108H Iqbal 0 40S
9
+ 10000109I Moreau 66600 14S
10
+ 10000110J Kowalczyk 125075 95P
@@ -0,0 +1,11 @@
1
+ # edge cases collected by ops, Oct 2026
2
+ 20000201Credit Balance Ltd -2000 30S
3
+ 20000202Half Pence S 33500 30S
4
+ 20000203Half Pence P 60050 30P
5
+ 20000204Exactly Grace 99999 14S
6
+ 20000205Unknown Tier 50000 30X
7
+ 20000206Cap Then Double 300000 91P
8
+ 20000207lower case name 40000 30S
9
+ 20000208Short Line 123
10
+ 20000209Bad Balance 12a4567 30S
11
+ 20000210Exactly Ninety 400000 90S
@@ -0,0 +1,21 @@
1
+ import subprocess
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parent.parent
7
+
8
+
9
+ class SmokeTest(unittest.TestCase):
10
+ def test_runs_and_writes_trailer(self):
11
+ out = subprocess.run(
12
+ [sys.executable, str(ROOT / "legacy" / "feecalc.py"),
13
+ str(ROOT / "samples" / "accounts_2026-10.txt"), "20261031"],
14
+ capture_output=True, text=True, check=True,
15
+ ).stdout
16
+ self.assertTrue(out.startswith("# FEERUN 20261031"))
17
+ self.assertIn("# TOTAL", out.splitlines()[-1])
18
+
19
+
20
+ if __name__ == "__main__":
21
+ unittest.main()