bantamkit 0.35.8__tar.gz → 0.35.9__tar.gz

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 (237) hide show
  1. {bantamkit-0.35.8 → bantamkit-0.35.9}/PKG-INFO +3 -3
  2. {bantamkit-0.35.8 → bantamkit-0.35.9}/README.md +2 -2
  3. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/__init__.py +1 -1
  4. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/assets.py +29 -3
  5. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/hostinstall.py +43 -9
  6. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/mcpserver.py +101 -24
  7. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/mcpstdio.py +106 -4
  8. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/tokenledger.py +22 -1
  9. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/updatecheck.py +33 -4
  10. bantamkit-0.35.9/tests/test_assets.py +48 -0
  11. bantamkit-0.35.9/tests/test_cli_oneshot.py +113 -0
  12. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_hookadapter.py +40 -0
  13. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_hostinstall.py +75 -8
  14. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_hostinstall_hooks.py +27 -2
  15. bantamkit-0.35.9/tests/test_mcp_request_guard.py +103 -0
  16. bantamkit-0.35.9/tests/test_mcpstdio_frames.py +68 -0
  17. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_selfupdate.py +1 -1
  18. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_status_surface.py +4 -1
  19. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_tokenledger.py +89 -0
  20. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_updatecheck.py +32 -5
  21. {bantamkit-0.35.8 → bantamkit-0.35.9}/.gitignore +0 -0
  22. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/contracts/default.yaml +0 -0
  23. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/manifest.yaml +0 -0
  24. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/HISTORY.md +0 -0
  25. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/README.md +0 -0
  26. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/docs/architecture.md +0 -0
  27. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/docs/runbook.md +0 -0
  28. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/issues/142-settlement-timeout.md +0 -0
  29. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/patches/0009-retry-budget.patch +0 -0
  30. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/__init__.py +0 -0
  31. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/config.py +0 -0
  32. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/errors.py +0 -0
  33. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/posting.py +0 -0
  34. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/registry.py +0 -0
  35. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/report.py +0 -0
  36. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/retry.py +0 -0
  37. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/settle.py +0 -0
  38. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/src/ledger/validate.py +0 -0
  39. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/tests/test_posting.py +0 -0
  40. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/repo/tests/test_settle.py +0 -0
  41. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-error-contract.yaml +0 -0
  42. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-handler-map.yaml +0 -0
  43. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-patch-before-after.yaml +0 -0
  44. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-retry-attempts.yaml +0 -0
  45. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-settlement-config.yaml +0 -0
  46. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-symbol-home.yaml +0 -0
  47. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-trace-blame.yaml +0 -0
  48. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/devteam/tasks/dt-unread-key.yaml +0 -0
  49. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-large-in-137.yaml +0 -0
  50. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-large-in-359.yaml +0 -0
  51. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-large-in-372.yaml +0 -0
  52. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-large-out-11764.yaml +0 -0
  53. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-large-out-4137.yaml +0 -0
  54. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-large-out-8022.yaml +0 -0
  55. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-small-137.yaml +0 -0
  56. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-small-261.yaml +0 -0
  57. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/document/tasks/doc-small-388.yaml +0 -0
  58. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/fixtures/.gitkeep +0 -0
  59. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/fixtures/catalog.json +0 -0
  60. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/perturbations/task-completion.yaml +0 -0
  61. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/.gitkeep +0 -0
  62. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/extract-contact.yaml +0 -0
  63. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/extract-invoice.yaml +0 -0
  64. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/extract-order.yaml +0 -0
  65. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/extract-schedule.yaml +0 -0
  66. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/extract-versions.yaml +0 -0
  67. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/nav-prod-port.yaml +0 -0
  68. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/nav-release-bundle.yaml +0 -0
  69. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-audit-retention.yaml +0 -0
  70. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-cache-ttl.yaml +0 -0
  71. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-db-port.yaml +0 -0
  72. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-deploy.yaml +0 -0
  73. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-env-endpoint.yaml +0 -0
  74. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-oncall-rotation.yaml +0 -0
  75. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-oncall.yaml +0 -0
  76. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-org-quota.yaml +0 -0
  77. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/recall-owner.yaml +0 -0
  78. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/shop-basket-total.yaml +0 -0
  79. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/shop-cheapest.yaml +0 -0
  80. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/shop-compare.yaml +0 -0
  81. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/shop-gadget-value.yaml +0 -0
  82. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/shop-stock-total.yaml +0 -0
  83. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/evals/tasks/shop-total.yaml +0 -0
  84. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/pricing/default.json +0 -0
  85. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/profiles/default.yaml +0 -0
  86. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/profiles/patient.yaml +0 -0
  87. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/rubrics/.gitkeep +0 -0
  88. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/rubrics/code-quality.yaml +0 -0
  89. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/rubrics/grounded-completion.yaml +0 -0
  90. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/rubrics/task-completion.yaml +0 -0
  91. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/schemas/shiftwork-checkpoint.json +0 -0
  92. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/skills/.gitkeep +0 -0
  93. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/skills/file-graph.md +0 -0
  94. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/skills/memory.md +0 -0
  95. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/.gitkeep +0 -0
  96. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/bantamkit_read.json +0 -0
  97. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/bantamkit_status.json +0 -0
  98. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/build_identity.json +0 -0
  99. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/document_list.json +0 -0
  100. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/document_read.json +0 -0
  101. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/file_graph.json +0 -0
  102. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/memory_compact.json +0 -0
  103. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/memory_dream.json +0 -0
  104. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/memory_recall.json +0 -0
  105. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/memory_save.json +0 -0
  106. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/repo_map.json +0 -0
  107. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/shiftwork_clock_in.json +0 -0
  108. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/shiftwork_clock_out.json +0 -0
  109. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/shiftwork_plan.json +0 -0
  110. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/shiftwork_status.json +0 -0
  111. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/skill_audit.json +0 -0
  112. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/token_ledger.json +0 -0
  113. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/validate_json.json +0 -0
  114. {bantamkit-0.35.8 → bantamkit-0.35.9}/_assets/tools/work_plan.json +0 -0
  115. {bantamkit-0.35.8 → bantamkit-0.35.9}/hatch_build.py +0 -0
  116. {bantamkit-0.35.8 → bantamkit-0.35.9}/pyproject.toml +0 -0
  117. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/agent.py +0 -0
  118. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/budget.py +0 -0
  119. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/client.py +0 -0
  120. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/contract.py +0 -0
  121. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/criticreplay.py +0 -0
  122. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/critique.py +0 -0
  123. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/docmanifest.py +0 -0
  124. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/docread.py +0 -0
  125. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/evalrun.py +0 -0
  126. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/eventlog.py +0 -0
  127. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/filegraph.py +0 -0
  128. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/hookadapter.py +0 -0
  129. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/loopguard.py +0 -0
  130. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/mcpreport.py +0 -0
  131. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/__init__.py +0 -0
  132. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/__main__.py +0 -0
  133. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/component.py +0 -0
  134. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/divergence.py +0 -0
  135. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/dream.py +0 -0
  136. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/layers.py +0 -0
  137. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/memory/store.py +0 -0
  138. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/pdfread.py +0 -0
  139. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/pricing.py +0 -0
  140. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/profile.py +0 -0
  141. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/repomap.py +0 -0
  142. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/selfupdate.py +0 -0
  143. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/shiftwork.py +0 -0
  144. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/skillaudit.py +0 -0
  145. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/statusline.py +0 -0
  146. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/structured.py +0 -0
  147. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/textutil.py +0 -0
  148. {bantamkit-0.35.8 → bantamkit-0.35.9}/src/bantamkit/workplan.py +0 -0
  149. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/cli_exit_status_probe.py +0 -0
  150. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/conftest.py +0 -0
  151. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/bad-crc.docx +0 -0
  152. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/charref-4301-digits.html +0 -0
  153. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/charset-table.json +0 -0
  154. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/compression-method-9.docx +0 -0
  155. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/corrupt-deflate.docx +0 -0
  156. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/encrypted-member.docx +0 -0
  157. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/encrypted-mimetype.odt +0 -0
  158. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/eszett-cell-ref.xlsx +0 -0
  159. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/internal-dtd-entity.docx +0 -0
  160. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/rfc2231-charset.eml +0 -0
  161. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/rfc822-nested-twice.eml +0 -0
  162. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/unicode-digit-shared-string.xlsx +0 -0
  163. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/docread/x-uuencode.eml +0 -0
  164. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/f8404ab-perturbation-baseline.json +0 -0
  165. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/platform-assumption-baseline.json +0 -0
  166. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/data/served-tool-surface.json +0 -0
  167. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/docread_fixtures.py +0 -0
  168. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/perturbation_baseline_harness.py +0 -0
  169. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/rbp16_effect_probe.py +0 -0
  170. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/rbp18_payload_probe.py +0 -0
  171. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_adapter.py +0 -0
  172. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_agent.py +0 -0
  173. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_amendguard.py +0 -0
  174. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_bantamkit_gitignore.py +0 -0
  175. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_bantamkit_read_tool.py +0 -0
  176. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_budget.py +0 -0
  177. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_build_identity.py +0 -0
  178. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_client.py +0 -0
  179. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_compaction_corpus_survey.py +0 -0
  180. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_conformance.py +0 -0
  181. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_conformance_harness_resilience.py +0 -0
  182. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_conformance_suite_table_gate.py +0 -0
  183. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_contract_fanout.py +0 -0
  184. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_contract_patterns.py +0 -0
  185. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_contract_refs.py +0 -0
  186. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_criticreplay.py +0 -0
  187. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_critique.py +0 -0
  188. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_doc_commands_gate.py +0 -0
  189. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_docread.py +0 -0
  190. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_docread_ceilings.py +0 -0
  191. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_document_manifest_parity.py +0 -0
  192. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_document_setup.py +0 -0
  193. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_document_tasks.py +0 -0
  194. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_document_tools.py +0 -0
  195. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_encoding_gate.py +0 -0
  196. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_evalrun.py +0 -0
  197. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_eventlog.py +0 -0
  198. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_field_program_gates.py +0 -0
  199. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_field_programs.py +0 -0
  200. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_filegraph.py +0 -0
  201. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_install_shape.py +0 -0
  202. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_ladder_statistics.py +0 -0
  203. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_launcher_which.py +0 -0
  204. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_layers.py +0 -0
  205. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_loopguard.py +0 -0
  206. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_mcp_endpoint.py +0 -0
  207. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_mcpdrift.py +0 -0
  208. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_mcpreport.py +0 -0
  209. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_mcpserver.py +0 -0
  210. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory.py +0 -0
  211. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_compact_tool.py +0 -0
  212. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_component.py +0 -0
  213. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_divergence.py +0 -0
  214. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_dream.py +0 -0
  215. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_layers.py +0 -0
  216. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_store_tripwire.py +0 -0
  217. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_memory_strings.py +0 -0
  218. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_mutmatrix.py +0 -0
  219. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_nativeexport.py +0 -0
  220. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_newline_gate.py +0 -0
  221. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_packaging.py +0 -0
  222. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_pdfread.py +0 -0
  223. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_pinharness_ledger.py +0 -0
  224. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_platform_assumption_gate.py +0 -0
  225. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_pricing.py +0 -0
  226. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_readme_separation.py +0 -0
  227. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_repo_map_tool.py +0 -0
  228. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_repomap.py +0 -0
  229. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_served_tool_count_records.py +0 -0
  230. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_shiftwork.py +0 -0
  231. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_skillaudit.py +0 -0
  232. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_statusline.py +0 -0
  233. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_structured.py +0 -0
  234. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_thread_exception_gate.py +0 -0
  235. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_tool_manifest.py +0 -0
  236. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_version_agreement.py +0 -0
  237. {bantamkit-0.35.8 → bantamkit-0.35.9}/tests/test_workplan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bantamkit
3
- Version: 0.35.8
3
+ Version: 0.35.9
4
4
  Summary: Memory MCP server for Claude Code, Cursor, VS Code Copilot and Claude Desktop, plus a Python library that lifts small-model agents. Install once, run offline.
5
5
  Project-URL: Homepage, https://github.com/Ink01101011/bantamkit
6
6
  Project-URL: Repository, https://github.com/Ink01101011/bantamkit
@@ -103,9 +103,9 @@ CPU architecture and Python minor version** (some wheels, such as `pydantic_core
103
103
  one platform only), copy `wheels/` across, and install from it:
104
104
 
105
105
  ```bash
106
- python -m pip download "bantamkit[mcp]==0.35.8" -d wheels
106
+ python -m pip download "bantamkit[mcp]==0.35.9" -d wheels
107
107
  python -m venv <env>
108
- <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.8"
108
+ <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.9"
109
109
  <env>/bin/bantamkit-mcp --install cursor
110
110
  ```
111
111
 
@@ -74,9 +74,9 @@ CPU architecture and Python minor version** (some wheels, such as `pydantic_core
74
74
  one platform only), copy `wheels/` across, and install from it:
75
75
 
76
76
  ```bash
77
- python -m pip download "bantamkit[mcp]==0.35.8" -d wheels
77
+ python -m pip download "bantamkit[mcp]==0.35.9" -d wheels
78
78
  python -m venv <env>
79
- <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.8"
79
+ <env>/bin/pip install --no-index --find-links wheels "bantamkit[mcp]==0.35.9"
80
80
  <env>/bin/bantamkit-mcp --install cursor
81
81
  ```
82
82
 
@@ -29,4 +29,4 @@ from bantamkit.structured import StructuredOutputError, extract_json, structured
29
29
  # file as its dynamic version source, so the wheel's metadata and the string the MCP
30
30
  # server advertises are the same committed bytes, and neither is a function of when
31
31
  # someone last ran `pip`.
32
- __version__ = "0.35.8"
32
+ __version__ = "0.35.9"
@@ -26,6 +26,32 @@ def assets_root() -> Path:
26
26
  raise AssetNotFound("no assets directory found; set BANTAMKIT_ASSETS")
27
27
 
28
28
 
29
+ def _asset_path(kind: str, name: str, ext: str) -> Path:
30
+ """job66/#145: the one path every loader below builds, and the containment check on it.
31
+
32
+ `name` is a BARE asset name (`memory`, `shiftwork-checkpoint`, `memory_recall`) and
33
+ nothing else. Before this, each loader joined `name` under its directory verbatim, so a
34
+ caller passing user input could read any `.json`/`.md` on disk through `..`. A name
35
+ holding a path separator (`/` or `\\`, either platform's), `..`, a drive colon or a NUL
36
+ is refused BEFORE any filesystem access, and the joined path is then checked LEXICALLY
37
+ (`os.path.abspath`, not `resolve()`: a pack may be a symlink farm, and the check is about
38
+ the name) to sit directly inside `<root>/<kind>s/`. On Windows `Path("tools") / "C:x"`
39
+ is drive-relative and leaves the pack, which is what the colon refusal and the lexical
40
+ check close. The refusal is an `AssetNotFound`, so the MCP resource handler's existing
41
+ `unknown skill asset: <name>` answer is unchanged. `runtime-ts/src/assets.ts::assetPath`
42
+ raises the same sentence.
43
+ """
44
+ directory = assets_root() / f"{kind}s"
45
+ path = directory / f"{name}{ext}"
46
+ if (
47
+ any(c in name for c in "/\\:\0")
48
+ or ".." in name
49
+ or os.path.dirname(os.path.abspath(path)) != os.path.abspath(directory)
50
+ ):
51
+ raise AssetNotFound(f"{kind} asset name is not a bare name: {name}")
52
+ return path
53
+
54
+
29
55
  def load_tool_asset(name: str) -> dict:
30
56
  """Return one tool asset VERBATIM — every key, including the ones no surface uses.
31
57
 
@@ -34,7 +60,7 @@ def load_tool_asset(name: str) -> dict:
34
60
  register this at all?) and `output_schema` (what do I advertise as the return shape?)
35
61
  and neither belongs on the agent-facing `Tool`.
36
62
  """
37
- path = assets_root() / "tools" / f"{name}.json"
63
+ path = _asset_path("tool", name, ".json")
38
64
  if not path.exists():
39
65
  raise AssetNotFound(f"tool asset not found: {path}")
40
66
  return json.loads(path.read_text(encoding="utf-8"))
@@ -54,14 +80,14 @@ def load_tool(name: str) -> Tool:
54
80
 
55
81
  def load_schema(name: str) -> dict:
56
82
  """Return a JSON Schema asset (parsed) — e.g. the shift-work checkpoint contract."""
57
- path = assets_root() / "schemas" / f"{name}.json"
83
+ path = _asset_path("schema", name, ".json")
58
84
  if not path.exists():
59
85
  raise AssetNotFound(f"schema asset not found: {path}")
60
86
  return json.loads(path.read_text(encoding="utf-8"))
61
87
 
62
88
 
63
89
  def load_skill(name: str) -> str:
64
- path = assets_root() / "skills" / f"{name}.md"
90
+ path = _asset_path("skill", name, ".md")
65
91
  if not path.exists():
66
92
  raise AssetNotFound(f"skill asset not found: {path}")
67
93
  return path.read_text(encoding="utf-8")
@@ -40,6 +40,7 @@ import json
40
40
  import math
41
41
  import os
42
42
  import platform
43
+ import re
43
44
  import shlex
44
45
  import shutil
45
46
  import subprocess
@@ -219,13 +220,7 @@ def _write_config(path: Path, data: dict) -> None:
219
220
  # API keys in per-server `env` blocks, and a user who chmod'ed theirs to 0600 had it come
220
221
  # back 0644 because a fresh temp file gets the process umask. Measured on both runtimes.
221
222
  mode = target.stat().st_mode & 0o7777 if target.exists() else None
222
- # `ensure_ascii` is left at its DEFAULT, which escapes non-ASCII as `\uXXXX`. That is not
223
- # a style choice: `runtime-ts`'s `dumpJson` reproduces `json.dumps` including this, and a
224
- # home directory with a non-ASCII name — a Thai or Japanese Windows username, say — would
225
- # otherwise make the two runtimes write different bytes for the same install. Escaped
226
- # JSON is still JSON and every host parses it; two runtimes disagreeing is the thing that
227
- # costs something.
228
- payload = json.dumps(data, indent=2) + "\n"
223
+ payload = _dump_host_json(data, _detected_indent(target)) + "\n"
229
224
  try:
230
225
  tmp.write_text(payload, encoding="utf-8")
231
226
  if mode is not None:
@@ -236,6 +231,45 @@ def _write_config(path: Path, data: dict) -> None:
236
231
  raise InstallError(f"cannot write {path}: {exc}") from None
237
232
 
238
233
 
234
+ def _detected_indent(path: Path) -> str:
235
+ """The indent unit the file already uses: a tab, or N spaces; two for a new file (#118).
236
+
237
+ The unit is read off the FIRST line that starts with whitespace and has something after
238
+ it. A JSON string cannot hold a raw newline, so every line start is structural and that
239
+ line is a depth-one member. A file with no such line (absent, empty, all on one line)
240
+ gets two spaces, which is what this writer always used. `runtime-ts`'s `detectedIndent`
241
+ applies the same rule to the same text.
242
+ """
243
+ try:
244
+ text = path.read_text(encoding="utf-8")
245
+ except (OSError, UnicodeDecodeError):
246
+ return " "
247
+ for line in text.split("\n"):
248
+ if line[:1] not in (" ", "\t") or not line.strip(" \t\r"):
249
+ continue
250
+ if line[0] == "\t":
251
+ return "\t"
252
+ return " " * (len(line) - len(line.lstrip(" ")))
253
+ return " "
254
+
255
+
256
+ # A lone surrogate is all `ensure_ascii=False` leaves unencodable: `"\ud800"` in the host file
257
+ # decodes to one, and UTF-8 cannot carry it. It goes back out as the escape it came in as.
258
+ _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
259
+
260
+
261
+ def _dump_host_json(data: dict, indent: str) -> str:
262
+ """The host file's text: the user's indent unit, non-ASCII as literal UTF-8 (#118).
263
+
264
+ `ensure_ascii=False` escapes only `"`, `\\` and C0 controls; everything else, DEL and
265
+ U+2028 included, is written as itself, so `"ไทย 🎉"` stays `"ไทย 🎉"` rather than coming
266
+ back as `\\u0e44...`. `runtime-ts` reaches the same bytes from `dumpJson`'s ASCII output
267
+ (`hostJsonText`), and `tools/conformance/suites/install.mjs` compares the two.
268
+ """
269
+ text = json.dumps(data, indent=indent, ensure_ascii=False)
270
+ return _LONE_SURROGATE.sub(lambda m: f"\\u{ord(m.group()):04x}", text)
271
+
272
+
239
273
  def _backup_target(path: Path) -> Path:
240
274
  """The name the next backup of `path` gets: the first one that is not taken.
241
275
 
@@ -743,8 +777,8 @@ def install_hooks(
743
777
  5. dated backup, then one atomic write.
744
778
 
745
779
  The write itself is `_write_config` — the same temp-file replace, the same carried mode,
746
- the same `indent=2` and default `ensure_ascii` — so the bytes this leaves and the bytes
747
- `--install` leaves are produced by one function (RULING Q3.5).
780
+ the same detected indent and literal non-ASCII through `_dump_host_json` (#118) — so the
781
+ bytes this leaves and the bytes `--install` leaves are produced by one function (RULING Q3.5).
748
782
  """
749
783
  command, extra = this_command()
750
784
  rendered = hook_command(command, extra)
@@ -1405,6 +1405,23 @@ def build_server(memory: Memory, log: EventLog | None = None) -> Any:
1405
1405
  notice = _notice()
1406
1406
  return {**answer, "bantamkit_degraded": notice} if notice else answer
1407
1407
 
1408
+ def _noted_json(document: str) -> str:
1409
+ """A reply that is ONE JSON DOCUMENT rendered as text (`skill_audit`, `token_ledger`),
1410
+ plus the footer if there is one, as a `bantamkit_degraded` key LAST in it (#124).
1411
+
1412
+ `_noted` would append a sentence after the closing brace and the reply would stop
1413
+ parsing. The key is spliced into the text rather than the document being re-parsed,
1414
+ so every byte of a degraded reply before the key is the healthy reply's: `as_json`
1415
+ is `json.dumps(dict, indent=2, ensure_ascii=False)` of a non-empty dict, so it always
1416
+ ends `\\n}`, and the key goes in at that two-space indent, where `json.dumps` would
1417
+ have put it.
1418
+ """
1419
+ notice = _notice()
1420
+ if not notice:
1421
+ return document
1422
+ key = json.dumps(notice, ensure_ascii=False)
1423
+ return f'{document[:-2]},\n "bantamkit_degraded": {key}\n}}'
1424
+
1408
1425
  def memory_save(
1409
1426
  type: str, name: str, description: str, body: str, links: list[str] | None = None
1410
1427
  ) -> str:
@@ -1772,7 +1789,7 @@ def build_server(memory: Memory, log: EventLog | None = None) -> Any:
1772
1789
  "omissions": len(result.omissions),
1773
1790
  },
1774
1791
  )
1775
- return _noted(result.as_json())
1792
+ return _noted_json(result.as_json())
1776
1793
 
1777
1794
  def repo_map(
1778
1795
  root: str,
@@ -1903,7 +1920,7 @@ def build_server(memory: Memory, log: EventLog | None = None) -> Any:
1903
1920
  "sessions": len(result.sessions),
1904
1921
  },
1905
1922
  )
1906
- return _noted(result.as_json())
1923
+ return _noted_json(result.as_json())
1907
1924
 
1908
1925
  # The served surface, in one place, read out of the asset pack. Adding a tool here
1909
1926
  # without an asset raises AssetNotFound at startup — the manifest cannot drift behind
@@ -2214,8 +2231,50 @@ def _build_parser() -> argparse.ArgumentParser:
2214
2231
  return parser
2215
2232
 
2216
2233
 
2234
+ #: job66/#139: the flags that each ACT and then exit, in registration order. At most one of
2235
+ #: them may be given. WAS: `_dispatch` ran the first in registration order and dropped the
2236
+ #: rest at exit 0, so `--install cursor --assets-root` printed the pack root, wrote no host
2237
+ #: config, and told the script that called it nothing.
2238
+ _ONE_SHOT_DESTS = (
2239
+ "assets_root",
2240
+ "hook",
2241
+ "mcp_report",
2242
+ "statusline",
2243
+ "update",
2244
+ "install",
2245
+ "install_hooks",
2246
+ "remove_hooks",
2247
+ )
2248
+
2249
+
2250
+ def _refuse_stacked_one_shots(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
2251
+ """Two one-shot flags together are a USAGE error: the usage block, one sentence, exit 2.
2252
+
2253
+ The sentence is argparse's own mutually-exclusive wording, but this is NOT
2254
+ `add_mutually_exclusive_group()`, and the reason is measured: the eight flags are not
2255
+ contiguous in the parser (`--k`, `--index-budget` and `--force` sit between them), and
2256
+ CPython 3.12/3.13 leave a non-contiguous group out of the usage line while 3.14 brackets
2257
+ it as `[--assets-root | --hook | ...]`. A real group would make `-h` depend on the
2258
+ interpreter's minor version, and both `-h` outputs are compared byte for byte with the
2259
+ port's. So the check runs after the parse, over the same actions, and names the two
2260
+ EARLIEST-REGISTERED flags given -- deterministic, and the same order
2261
+ `runtime-ts/src/cli.ts` names them in.
2262
+ """
2263
+ given = [
2264
+ action
2265
+ for action in parser._actions
2266
+ if action.dest in _ONE_SHOT_DESTS and getattr(args, action.dest) not in (None, False)
2267
+ ]
2268
+ if len(given) > 1:
2269
+ first, second = ("/".join(a.option_strings) for a in given[:2])
2270
+ parser.error(f"argument {second}: not allowed with argument {first}")
2271
+
2272
+
2217
2273
  def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
2218
- return _build_parser().parse_args(argv)
2274
+ parser = _build_parser()
2275
+ args = parser.parse_args(argv)
2276
+ _refuse_stacked_one_shots(parser, args)
2277
+ return args
2219
2278
 
2220
2279
 
2221
2280
  def _typed_bare_at_a_terminal(
@@ -2381,10 +2440,11 @@ def _check_store_flag(raw: str) -> None:
2381
2440
  def _run_hook(args: argparse.Namespace) -> None:
2382
2441
  """`--hook`: one JSON event on stdin, at most one JSON object on stdout, EXIT 0 ALWAYS.
2383
2442
 
2384
- DISPATCH ORDER IS REGISTRATION ORDER, which is the rule the flags around it already
2385
- follow and the reason `--mcp-report --install cursor` prints a report and writes
2386
- nothing. `--hook` is registered directly after `--index-budget`, so it is checked
2387
- directly after `--assets-root` -- the same order `runtime-ts/src/cli.ts` checks it in.
2443
+ ALONE ON ITS COMMAND LINE (job66/#139). `--hook` beside another one-shot flag is a usage
2444
+ error at exit 2, and `--store`/`--start`/`--yes`/`--force` beside it are refused at exit
2445
+ 1 before this runs. WAS: dispatch order was registration order, so `--mcp-report
2446
+ --install cursor` printed a report and silently wrote nothing, and `--hook --store X`
2447
+ ignored X.
2388
2448
 
2389
2449
  EXIT 0 ALWAYS, AND THAT IS THE CONTRACT, not a convenience. A hook that exits non-zero
2390
2450
  or lets a traceback reach stderr is rendered by the host as an error on the USER'S
@@ -2719,9 +2779,16 @@ def main() -> None:
2719
2779
  Written through `sys.stderr.buffer`, like every other refusal in this file, so the line
2720
2780
  ends LF on Windows too and cannot drift from the port's `process.stderr.write`.
2721
2781
  """
2722
- if MCPServer is None:
2723
- raise SystemExit(_INSTALL_HINT)
2724
2782
  args = _parse_args()
2783
+ # job66/#173: the SDK check sits AFTER the parse and is skipped for the arms that never
2784
+ # build a server. `--hook`, `--statusline` and `--mcp-report` are registered with hosts
2785
+ # and run on EVERY event; they import nothing from `mcp`, so a plain `pip install
2786
+ # bantamkit` must run them (docs/hooks.md: the hook "exits 0 always"). WAS: the check ran
2787
+ # before `_parse_args()`, so all three exited 1 with the install hint on every event.
2788
+ if MCPServer is None and not (
2789
+ args.assets_root or args.hook or args.mcp_report or args.statusline
2790
+ ):
2791
+ raise SystemExit(_INSTALL_HINT)
2725
2792
  try:
2726
2793
  _dispatch(args)
2727
2794
  except BantamError as e:
@@ -2731,6 +2798,29 @@ def main() -> None:
2731
2798
 
2732
2799
 
2733
2800
  def _dispatch(args: argparse.Namespace) -> None:
2801
+ # job66/#139: THE MODIFIERS ARE CHECKED BEFORE ANY FLAG ACTS. `_parse_args` already
2802
+ # refuses two one-shot flags together; what is left is a modifier given to a flag that
2803
+ # does not read it. WAS: these two refusals sat after every acting arm, so `--hook --yes`,
2804
+ # `--hook --force` and `--statusline --force` ran the first flag and dropped the modifier
2805
+ # at exit 0 while `--force` alone was refused. Same sentences, same exit 1, earlier.
2806
+ #
2807
+ # `--force` alone is a typo with a plausible reading -- somebody meant to install and
2808
+ # dropped the flag that says where. Refusing names the missing half instead of running
2809
+ # something that ignores it.
2810
+ if args.force and not args.install:
2811
+ raise SystemExit("--force is only meaningful with --install")
2812
+ # The same reading, and the same refusal, for the consent flag. The sentence names BOTH
2813
+ # flags since the ruling of 2026-09-20: naming only one would send an operator who
2814
+ # mistyped `--remove-hooks` looking for a flag they already had.
2815
+ if args.yes and not (args.install_hooks or args.remove_hooks):
2816
+ raise SystemExit("--yes is only meaningful with --install-hooks or --remove-hooks")
2817
+ # REFUSED, NOT HONOURED (job66/#139). The hook binds the store the winning MCP
2818
+ # registration pins, resolved from the cwd the HOST put in the event -- `_run_hook` never
2819
+ # reads `args` -- so `--hook --store X` used to inject nothing from X and say nothing.
2820
+ # Honouring it would give a hook registration a second, competing way to name a store.
2821
+ for flag in ("store", "start"):
2822
+ if args.hook and getattr(args, flag) is not None:
2823
+ raise SystemExit(f"--{flag} is not meaningful with --hook")
2734
2824
  # Before `_build_memory`, which touches the filesystem, and before the server exists at
2735
2825
  # all -- the Node arm returns from `main` here too, ahead of `new RawStdioTransport()`.
2736
2826
  if args.assets_root:
@@ -2751,27 +2841,14 @@ def _dispatch(args: argparse.Namespace) -> None:
2751
2841
  if args.install:
2752
2842
  _run_install(args)
2753
2843
  return
2754
- # REGISTRATION ORDER AGAIN, which is what makes `--mcp-report --install-hooks` print a
2755
- # report and write nothing, exactly as `--mcp-report --install cursor` already does.
2844
+ # At most one of these arms can be reached (job66/#139: `_parse_args` refuses a second
2845
+ # one-shot flag), so the order below no longer decides what a stacked argv does.
2756
2846
  if args.install_hooks:
2757
2847
  _run_install_hooks(args)
2758
2848
  return
2759
2849
  if args.remove_hooks:
2760
2850
  _run_remove_hooks(args)
2761
2851
  return
2762
- # `--force` alone is a typo with a plausible reading -- somebody meant to install and
2763
- # dropped the flag that says where. Refusing names the missing half instead of starting
2764
- # a server that ignores it.
2765
- if args.force:
2766
- raise SystemExit("--force is only meaningful with --install")
2767
- # The same reading, and the same refusal, for the consent flag: `--yes` on its own is
2768
- # somebody who meant to install or remove hooks and dropped the flag that says which. It
2769
- # is checked AFTER `--force` because that is registration order, and both are checked
2770
- # after every flag that acts, so `--install-hooks --yes` never reaches either of them. The
2771
- # sentence names BOTH flags since the ruling of 2026-09-20: naming only one would send an
2772
- # operator who mistyped `--remove-hooks` looking for a flag they already had.
2773
- if args.yes:
2774
- raise SystemExit("--yes is only meaningful with --install-hooks or --remove-hooks")
2775
2852
  # A PERSON TYPED IT. `_typed_bare_at_a_terminal` carries the whole argument; what
2776
2853
  # belongs here is only that this sits BEFORE `_build_memory`, which is what creates a
2777
2854
  # store. Somebody who typed a command to see what it does has not asked for a
@@ -17,6 +17,23 @@ for a tool this server serves, whose arguments hold one. Every other line goes t
17
17
  byte for byte, as before -- including a lone surrogate anywhere else in a frame, which the SDK
18
18
  still drops (the malformed-frame class, not this one).
19
19
 
20
+ TWO MORE FRAMES THE SDK DROPPED ARE ANSWERED (job66/#148). A line that is not JSON, or JSON
21
+ that is not a JSON-RPC message (a batch array, a bare number, a request with no `jsonrpc`), went
22
+ the same way: the reader's parse failed, the exception went to the session, nothing was sent.
23
+ JSON-RPC 2.0 section 5.1 prescribes an answer with `id: null`, so the wrapper now asks the SDK's
24
+ own adapter first and, where the SDK would drop the line, answers `-32700 Parse error` (the
25
+ standard library's `json` cannot read it either) or `-32600 Invalid Request` (it can). The
26
+ port writes the same two frames from `runtime-ts/src/mcp/transport.ts`; the `wire` suite
27
+ compares them. A blank line still goes to the SDK and is still dropped, as the port skips it.
28
+
29
+ A HOST THAT CLOSED ITS READ END ENDS THE SESSION QUIETLY (job66/#181). Measured before this
30
+ change: a 3 MB answer written to a closed stdout raised `BrokenPipeError` out of the SDK's writer,
31
+ and the process then HUNG until stdin closed -- the reader is a blocking `readline` on an anyio
32
+ worker thread, which a cancelled task group must wait for -- and only then died, exit 1, with an
33
+ exception-group traceback. Stdin is now read on a DAEMON thread, so a cancelled reader is
34
+ abandoned rather than awaited, and the broken pipe is caught: exit 0, nothing on stderr, the
35
+ same as the port and the same as stdin EOF.
36
+
20
37
  PRIVATE SDK NAMES, ON PURPOSE. `_claim_fd`, `_open_stdin_diversion`, `_UnownedTextWrapper`
21
38
  and `MCPServer._lowlevel_server` are not public API. They are what `stdio_server()` and
22
39
  `run_stdio_async()` themselves use, the SDK is upper-capped in `runtime-py/pyproject.toml`
@@ -26,13 +43,14 @@ child a handler spawns. A rename inside the cap fails at import or at first use,
26
43
 
27
44
  from __future__ import annotations
28
45
 
46
+ import asyncio
29
47
  import json
30
48
  import re
31
49
  import sys
50
+ import threading
32
51
  from collections.abc import AsyncIterator
33
52
  from typing import Any
34
53
 
35
- import anyio
36
54
  import mcp_types as types
37
55
  from mcp.server import stdio as _sdk_stdio
38
56
  from mcp.shared.message import SessionMessage
@@ -110,8 +128,87 @@ def refusal_for(line: str, tools: frozenset[str]) -> types.JSONRPCResponse | Non
110
128
  )
111
129
 
112
130
 
131
+ def _no_constant(name: str) -> Any:
132
+ raise ValueError(f"not JSON: {name}")
133
+
134
+
135
+ def malformed_frame_error(line: str) -> types.JSONRPCError | None:
136
+ """The `id: null` error for a line the SDK's reader would drop, or `None` (#148).
137
+
138
+ `-32700` when the line is not JSON at all -- `NaN` and `Infinity` included, which
139
+ `json.loads` would otherwise accept and JSON does not -- and `-32600` when it is JSON
140
+ but not a message the SDK's own adapter accepts. A blank line is `None`: it is skipped,
141
+ not answered, on both runtimes.
142
+ """
143
+ if not line.strip():
144
+ return None
145
+ # pydantic's parser reads `NaN` and `Infinity` where JSON has none; the port's
146
+ # `JSON.parse` does not. Only a line spelling one can hold one, so only that line pays.
147
+ if ("NaN" in line or "Infinity" in line) and not _is_strict_json(line):
148
+ return _error(-32700, "Parse error")
149
+ try:
150
+ types.jsonrpc_message_adapter.validate_json(line, by_name=False)
151
+ except Exception: # noqa: BLE001 - the SDK's reader catches exactly this broadly
152
+ pass
153
+ else:
154
+ return None
155
+ # A lone surrogate lands here too: `json` reads it, pydantic's parser does not.
156
+ if _is_strict_json(line):
157
+ return _error(-32600, "Invalid Request")
158
+ return _error(-32700, "Parse error")
159
+
160
+
161
+ def _is_strict_json(line: str) -> bool:
162
+ try:
163
+ json.loads(line, parse_constant=_no_constant)
164
+ except ValueError:
165
+ return False
166
+ return True
167
+
168
+
169
+ def _error(code: int, message: str) -> types.JSONRPCError:
170
+ error = types.ErrorData(code=code, message=message)
171
+ return types.JSONRPCError(jsonrpc="2.0", id=None, error=error)
172
+
173
+
174
+ class _DaemonLines:
175
+ """The lines of a text stream, read on a DAEMON thread (#181).
176
+
177
+ `anyio.wrap_file` reads on a worker thread the task group has to wait for, so a session
178
+ whose stdout broke could not end until stdin did. A daemon thread is abandoned instead.
179
+ One line is read ahead at most: the thread blocks until the loop has taken the last one.
180
+ """
181
+
182
+ def __init__(self, text: Any) -> None:
183
+ self._text = text
184
+
185
+ def __aiter__(self) -> AsyncIterator[str]:
186
+ return self._lines()
187
+
188
+ async def _lines(self) -> AsyncIterator[str]:
189
+ loop = asyncio.get_running_loop()
190
+ queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=1)
191
+
192
+ def pump() -> None:
193
+ try:
194
+ for line in self._text:
195
+ asyncio.run_coroutine_threadsafe(queue.put(line), loop).result()
196
+ except (OSError, ValueError, RuntimeError):
197
+ pass # the fd was released or the loop is gone: nobody is reading any more
198
+ finally:
199
+ try:
200
+ asyncio.run_coroutine_threadsafe(queue.put(None), loop)
201
+ except RuntimeError:
202
+ pass
203
+
204
+ threading.Thread(target=pump, name="bantamkit-stdin", daemon=True).start()
205
+ while (line := await queue.get()) is not None:
206
+ yield line
207
+
208
+
113
209
  class _Lines:
114
- """The SDK's stdin, with the lone-surrogate `tools/call` frames answered and withheld."""
210
+ """The SDK's stdin, with the lone-surrogate `tools/call` frames answered and withheld,
211
+ and the frames the SDK would drop answered `-32700`/`-32600` (#148)."""
115
212
 
116
213
  def __init__(self, inner: Any, tools: frozenset[str]) -> None:
117
214
  self._inner = inner
@@ -123,7 +220,9 @@ class _Lines:
123
220
 
124
221
  async def _lines(self) -> AsyncIterator[str]:
125
222
  async for line in self._inner:
126
- response = refusal_for(line, self._tools) if self.write_stream is not None else None
223
+ response: types.JSONRPCResponse | types.JSONRPCError | None = None
224
+ if self.write_stream is not None:
225
+ response = refusal_for(line, self._tools) or malformed_frame_error(line)
127
226
  if response is None:
128
227
  yield line
129
228
  else:
@@ -136,11 +235,14 @@ async def serve_stdio(server: Any) -> None:
136
235
  buffer, release = _sdk_stdio._claim_fd(0, sys.stdin, "rb", _sdk_stdio._open_stdin_diversion)
137
236
  try:
138
237
  text = _sdk_stdio._UnownedTextWrapper(buffer, encoding="utf-8", errors="replace")
139
- lines = _Lines(anyio.wrap_file(text), tools)
238
+ lines = _Lines(_DaemonLines(text), tools)
140
239
  async with _sdk_stdio.stdio_server(stdin=lines) as (read_stream, write_stream): # type: ignore[arg-type]
141
240
  lines.write_stream = write_stream
142
241
  lowlevel = server._lowlevel_server
143
242
  await lowlevel.run(read_stream, write_stream, lowlevel.create_initialization_options())
243
+ except* (BrokenPipeError, ConnectionResetError):
244
+ # The host closed its read end (#181): the session is over, exactly as on stdin EOF.
245
+ pass
144
246
  finally:
145
247
  if release is not None:
146
248
  release()
@@ -69,6 +69,16 @@ THE PARITY HAZARDS, EACH PINNED
69
69
  same measured hole.
70
70
  - **A bare `Infinity`.** `json.loads` accepts the token and `JSON.parse` throws. The line
71
71
  decoder closes it, so a transcript carrying one is `unparsed-line` on both sides.
72
+ - **A leading UTF-8 BOM** (#150). `read_text(encoding="utf-8")` keeps U+FEFF and `json.loads`
73
+ refuses it, while a default `TextDecoder` strips it from the FIRST line only. The port decodes
74
+ with `ignoreBOM: true`, so a BOM is `unparsed-line` on both sides, on any line. Tolerating it
75
+ instead was the other choice and was not taken: the host never writes one, RFC 8259 forbids
76
+ adding one, and a line that is not what the host writes is exactly what an omission names.
77
+ - **Directory symlinks** (#151). `Path.is_dir()` and `statSync` both FOLLOW a link, so a link
78
+ to an ancestor was walked until the OS refused it (ELOOP, ~32 levels on macOS) and the one
79
+ transcript beneath it was counted once per level. A symlinked directory is now neither
80
+ descended into nor read, on both sides -- `skill_audit`'s rule (`os.walk`'s
81
+ `followlinks=False`). A symlink to a FILE is still read: it cannot loop.
72
82
 
73
83
  THE COST IS THE REFUSAL, AND THAT IS THE NORMAL ANSWER
74
84
  ------------------------------------------------------
@@ -261,6 +271,12 @@ class _Accumulator:
261
271
  self.tokens = {cls: 0 for cls in TOKEN_CLASSES}
262
272
 
263
273
 
274
+ def _is_junction(path: Path) -> bool:
275
+ """`Path.is_junction()` where it exists (3.12+); before it, no junction is named."""
276
+ probe = getattr(path, "is_junction", None)
277
+ return bool(probe()) if probe is not None else False
278
+
279
+
264
280
  def _walk(base: Path) -> list[tuple[Path, str]]:
265
281
  """Every `*.jsonl` under `base`, in one deterministic order, each with the name it is called by.
266
282
 
@@ -285,7 +301,12 @@ def _walk(base: Path) -> list[tuple[Path, str]]:
285
301
  child = directory / name
286
302
  rel = f"{prefix}{name}"
287
303
  if child.is_dir():
288
- visit(child, f"{rel}/")
304
+ # #151: a symlinked directory is not a transcript tree. `is_dir()` follows the
305
+ # link, so a link to a directory lands in this arm and is skipped here: neither
306
+ # walked nor read. Node's `lstatSync` calls a Windows junction a symbolic link,
307
+ # so a junction is skipped too where this Python can name one.
308
+ if not (child.is_symlink() or _is_junction(child)):
309
+ visit(child, f"{rel}/")
289
310
  elif name.endswith(TRANSCRIPT_SUFFIX):
290
311
  found.append((child, rel))
291
312
 
@@ -119,6 +119,7 @@ from __future__ import annotations
119
119
 
120
120
  import json
121
121
  import re
122
+ import sys
122
123
  from dataclasses import dataclass
123
124
  from pathlib import Path
124
125
  from typing import Any
@@ -151,6 +152,7 @@ __all__ = [
151
152
  "decide",
152
153
  "update_status",
153
154
  "update_line",
155
+ "update_command",
154
156
  ]
155
157
 
156
158
  #: The key of the record THIS runtime reads. Divergent by construction: `runtime-ts` reads
@@ -173,9 +175,16 @@ UPDATE_NEVER = "update: never checked."
173
175
 
174
176
  #: The whole point of the feature. `{latest}` and `{installed}` are BOTH here so the reader
175
177
  #: can falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
178
+ #:
179
+ #: `{command}` is `update_command()` (job66/#171). WAS: `` `{program} --update` ``, the bare
180
+ #: command, which a pip venv or an npm kept install puts on nobody's PATH -- so the sentence
181
+ #: answered `command not found` from a GUI-launched host's shell. The TEMPLATE is still
182
+ #: byte-identical across runtimes; what each runtime fills into `{command}` is its own
183
+ #: interpreter and entry point (the `docs/porting.md` row *the update command names each
184
+ #: runtime's own interpreter*).
176
185
  UPDATE_AVAILABLE = (
177
186
  "update: {program} {installed} is running; the package index has {latest} — run "
178
- "`{program} --update`, then reconnect the host."
187
+ "`{command}`, then reconnect the host."
179
188
  )
180
189
 
181
190
  #: Names the date because the record may be months old.
@@ -347,11 +356,24 @@ def _checked_date(record: dict[str, Any], key: str) -> str | None:
347
356
  return found.group(1) if found else None
348
357
 
349
358
 
359
+ def update_command() -> str:
360
+ """The command `UPDATE_AVAILABLE` tells the operator to run: THIS interpreter, absolute.
361
+
362
+ job66/#171. `sys.executable`, double-quoted, then `-m bantamkit.mcpserver --update` --
363
+ the interpreter that is running this server, so the command needs no PATH (a host
364
+ launched from a GUI gets `/usr/bin:/bin`) and runs the same install that answered. The
365
+ quotes hold a home directory with a space in it. The port fills in `process.execPath`
366
+ and its own `cli.js` instead.
367
+ """
368
+ return f'"{sys.executable}" -m bantamkit.mcpserver --update'
369
+
370
+
350
371
  def decide(
351
372
  installed: str,
352
373
  source: str,
353
374
  record: dict[str, Any] | None,
354
375
  key: str = KEY,
376
+ command: str | None = None,
355
377
  ) -> UpdateStatus:
356
378
  """The five-state decision, pure: no filesystem, no clock, no network, no TTL.
357
379
 
@@ -370,7 +392,12 @@ def decide(
370
392
  if order < 0:
371
393
  return UpdateStatus(
372
394
  STATE_AVAILABLE,
373
- UPDATE_AVAILABLE.format(program=PROGRAM, installed=installed, latest=latest),
395
+ UPDATE_AVAILABLE.format(
396
+ program=PROGRAM,
397
+ installed=installed,
398
+ latest=latest,
399
+ command=update_command() if command is None else command,
400
+ ),
374
401
  )
375
402
  if order == 0:
376
403
  return UpdateStatus(
@@ -387,16 +414,18 @@ def update_status(
387
414
  installed: str,
388
415
  key: str = KEY,
389
416
  path: Path | None = None,
417
+ command: str | None = None,
390
418
  ) -> UpdateStatus:
391
419
  """Read the record and decide. The whole reader, in one call, for `bantamkit_status`."""
392
420
  source, record = load_record(path)
393
- return decide(installed, source, record, key)
421
+ return decide(installed, source, record, key, command)
394
422
 
395
423
 
396
424
  def update_line(
397
425
  installed: str,
398
426
  key: str = KEY,
399
427
  path: Path | None = None,
428
+ command: str | None = None,
400
429
  ) -> str:
401
430
  """Exactly one of the five sentences. Always a line: there is no silent state here."""
402
- return update_status(installed, key, path).line
431
+ return update_status(installed, key, path, command).line