@litfamily/lithermes 1.0.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 (557) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/README_Ko-KR.md +188 -0
  4. package/assets/lithermes-plugin/NOTICE.md +46 -0
  5. package/assets/lithermes-plugin/README.md +390 -0
  6. package/assets/lithermes-plugin/__init__.py +589 -0
  7. package/assets/lithermes-plugin/auto_update.py +338 -0
  8. package/assets/lithermes-plugin/bounded_work.py +1120 -0
  9. package/assets/lithermes-plugin/bounded_work_tools.py +115 -0
  10. package/assets/lithermes-plugin/core.py +1813 -0
  11. package/assets/lithermes-plugin/core_commands.py +243 -0
  12. package/assets/lithermes-plugin/core_contexts.py +458 -0
  13. package/assets/lithermes-plugin/core_contract.py +456 -0
  14. package/assets/lithermes-plugin/core_plans.py +418 -0
  15. package/assets/lithermes-plugin/core_reader_args.py +34 -0
  16. package/assets/lithermes-plugin/core_recap_command.py +141 -0
  17. package/assets/lithermes-plugin/core_recap_state.py +175 -0
  18. package/assets/lithermes-plugin/core_review.py +198 -0
  19. package/assets/lithermes-plugin/core_routing.py +641 -0
  20. package/assets/lithermes-plugin/core_runs.py +283 -0
  21. package/assets/lithermes-plugin/core_runtime.py +251 -0
  22. package/assets/lithermes-plugin/core_workflow_commands.py +207 -0
  23. package/assets/lithermes-plugin/deliverable_hedge_guard.py +562 -0
  24. package/assets/lithermes-plugin/diagnostics.py +468 -0
  25. package/assets/lithermes-plugin/handoff.py +97 -0
  26. package/assets/lithermes-plugin/knowledge.py +920 -0
  27. package/assets/lithermes-plugin/knowledge_tools.py +130 -0
  28. package/assets/lithermes-plugin/lit_mark.py +140 -0
  29. package/assets/lithermes-plugin/lit_mark_rows.json +1363 -0
  30. package/assets/lithermes-plugin/litgoal/__init__.py +10 -0
  31. package/assets/lithermes-plugin/litgoal/cli.py +207 -0
  32. package/assets/lithermes-plugin/litgoal/hook.py +81 -0
  33. package/assets/lithermes-plugin/litgoal/model.py +203 -0
  34. package/assets/lithermes-plugin/litgoal/runtime.py +522 -0
  35. package/assets/lithermes-plugin/litgoal/store.py +367 -0
  36. package/assets/lithermes-plugin/litgoal/tools.py +300 -0
  37. package/assets/lithermes-plugin/output-styles/asd-ste100-ko.md +41 -0
  38. package/assets/lithermes-plugin/output-styles/asd-ste100.md +40 -0
  39. package/assets/lithermes-plugin/output-styles/eli5-ko.md +13 -0
  40. package/assets/lithermes-plugin/output-styles/eli5.md +11 -0
  41. package/assets/lithermes-plugin/output_styles.py +62 -0
  42. package/assets/lithermes-plugin/payload-version.json +2107 -0
  43. package/assets/lithermes-plugin/plugin.yaml +16 -0
  44. package/assets/lithermes-plugin/provider_metrics.py +161 -0
  45. package/assets/lithermes-plugin/redaction.py +148 -0
  46. package/assets/lithermes-plugin/rules/__init__.py +55 -0
  47. package/assets/lithermes-plugin/rules/bundled-rules/baseline-discipline.md +20 -0
  48. package/assets/lithermes-plugin/rules/bundled-rules/build-decision-gate.md +19 -0
  49. package/assets/lithermes-plugin/rules/bundled-rules/windows-git-bash.md +17 -0
  50. package/assets/lithermes-plugin/rules/constants.py +122 -0
  51. package/assets/lithermes-plugin/rules/discovery.py +276 -0
  52. package/assets/lithermes-plugin/rules/engine.py +447 -0
  53. package/assets/lithermes-plugin/rules/frontmatter.py +149 -0
  54. package/assets/lithermes-plugin/rules/globmatch.py +333 -0
  55. package/assets/lithermes-plugin/scientific_visualization.py +158 -0
  56. package/assets/lithermes-plugin/session_context.py +8 -0
  57. package/assets/lithermes-plugin/skill_loop.py +2044 -0
  58. package/assets/lithermes-plugin/skill_observer.py +1778 -0
  59. package/assets/lithermes-plugin/skills/autoconference/LICENSE +21 -0
  60. package/assets/lithermes-plugin/skills/autoconference/ORIGIN.json +11 -0
  61. package/assets/lithermes-plugin/skills/autoconference/SKILL.md +163 -0
  62. package/assets/lithermes-plugin/skills/autoconference/assets/conference_template.md +76 -0
  63. package/assets/lithermes-plugin/skills/autoconference/assets/report_template.md +53 -0
  64. package/assets/lithermes-plugin/skills/autoconference/assets/synthesis_template.md +39 -0
  65. package/assets/lithermes-plugin/skills/autoconference/dependencies/autoresearch.md +14 -0
  66. package/assets/lithermes-plugin/skills/autoconference/modes/analyze/SKILL.md +269 -0
  67. package/assets/lithermes-plugin/skills/autoconference/modes/core/SKILL.md +134 -0
  68. package/assets/lithermes-plugin/skills/autoconference/modes/core/convergence-guide.md +162 -0
  69. package/assets/lithermes-plugin/skills/autoconference/modes/core/crash-recovery.md +63 -0
  70. package/assets/lithermes-plugin/skills/autoconference/modes/debate/SKILL.md +383 -0
  71. package/assets/lithermes-plugin/skills/autoconference/modes/plan/SKILL.md +304 -0
  72. package/assets/lithermes-plugin/skills/autoconference/modes/resume/SKILL.md +82 -0
  73. package/assets/lithermes-plugin/skills/autoconference/modes/ship/SKILL.md +78 -0
  74. package/assets/lithermes-plugin/skills/autoconference/modes/survey/SKILL.md +376 -0
  75. package/assets/lithermes-plugin/skills/autoconference/references/agent-prompts.md +110 -0
  76. package/assets/lithermes-plugin/skills/autoconference/references/conference-protocol.md +90 -0
  77. package/assets/lithermes-plugin/skills/autoconference/references/core-principles.md +78 -0
  78. package/assets/lithermes-plugin/skills/autoconference/references/results-logging.md +165 -0
  79. package/assets/lithermes-plugin/skills/autoconference/references/visualization-guide.md +99 -0
  80. package/assets/lithermes-plugin/skills/autoconference/scripts/check_conference.sh +197 -0
  81. package/assets/lithermes-plugin/skills/autoconference/scripts/init_conference.py +489 -0
  82. package/assets/lithermes-plugin/skills/autoconference/scripts/style_presets.py +104 -0
  83. package/assets/lithermes-plugin/skills/autoconference/templates/code-performance.md +59 -0
  84. package/assets/lithermes-plugin/skills/autoconference/templates/debate-mode.md +50 -0
  85. package/assets/lithermes-plugin/skills/autoconference/templates/prompt-optimization.md +58 -0
  86. package/assets/lithermes-plugin/skills/autoconference/templates/quick-conference.md +48 -0
  87. package/assets/lithermes-plugin/skills/autoconference/templates/research-synthesis.md +56 -0
  88. package/assets/lithermes-plugin/skills/autoconference/templates/survey-mode.md +63 -0
  89. package/assets/lithermes-plugin/skills/autoresearch/LICENSE +21 -0
  90. package/assets/lithermes-plugin/skills/autoresearch/ORIGIN.json +11 -0
  91. package/assets/lithermes-plugin/skills/autoresearch/SKILL.md +175 -0
  92. package/assets/lithermes-plugin/skills/autoresearch/assets/report_template.md +50 -0
  93. package/assets/lithermes-plugin/skills/autoresearch/assets/research_template.md +38 -0
  94. package/assets/lithermes-plugin/skills/autoresearch/assets/results_template.tsv +2 -0
  95. package/assets/lithermes-plugin/skills/autoresearch/modes/core/SKILL.md +204 -0
  96. package/assets/lithermes-plugin/skills/autoresearch/modes/core/evaluator-contract.md +108 -0
  97. package/assets/lithermes-plugin/skills/autoresearch/modes/core/stuck-detection.md +102 -0
  98. package/assets/lithermes-plugin/skills/autoresearch/modes/debug/SKILL.md +240 -0
  99. package/assets/lithermes-plugin/skills/autoresearch/modes/debug/investigation-techniques.md +254 -0
  100. package/assets/lithermes-plugin/skills/autoresearch/modes/fix/SKILL.md +176 -0
  101. package/assets/lithermes-plugin/skills/autoresearch/modes/learn/SKILL.md +96 -0
  102. package/assets/lithermes-plugin/skills/autoresearch/modes/plan/SKILL.md +291 -0
  103. package/assets/lithermes-plugin/skills/autoresearch/modes/predict/SKILL.md +271 -0
  104. package/assets/lithermes-plugin/skills/autoresearch/modes/predict/persona-templates.md +227 -0
  105. package/assets/lithermes-plugin/skills/autoresearch/modes/reason/SKILL.md +174 -0
  106. package/assets/lithermes-plugin/skills/autoresearch/modes/scenario/SKILL.md +168 -0
  107. package/assets/lithermes-plugin/skills/autoresearch/modes/scenario/dimensions.md +173 -0
  108. package/assets/lithermes-plugin/skills/autoresearch/modes/security/SKILL.md +287 -0
  109. package/assets/lithermes-plugin/skills/autoresearch/modes/security/owasp-checklist.md +156 -0
  110. package/assets/lithermes-plugin/skills/autoresearch/modes/security/stride-model.md +191 -0
  111. package/assets/lithermes-plugin/skills/autoresearch/modes/ship/SKILL.md +99 -0
  112. package/assets/lithermes-plugin/skills/autoresearch/modes/ship/type-checklists.md +158 -0
  113. package/assets/lithermes-plugin/skills/autoresearch/references/core-principles.md +75 -0
  114. package/assets/lithermes-plugin/skills/autoresearch/references/results-logging.md +55 -0
  115. package/assets/lithermes-plugin/skills/autoresearch/references/visualization-guide.md +90 -0
  116. package/assets/lithermes-plugin/skills/autoresearch/scripts/check_progress.sh +139 -0
  117. package/assets/lithermes-plugin/skills/autoresearch/scripts/init_research.py +219 -0
  118. package/assets/lithermes-plugin/skills/autoresearch/scripts/style_presets.py +104 -0
  119. package/assets/lithermes-plugin/skills/browser-drive/SKILL.md +147 -0
  120. package/assets/lithermes-plugin/skills/browser-drive/scripts/capability_probe.py +528 -0
  121. package/assets/lithermes-plugin/skills/comment-checker/SKILL.md +162 -0
  122. package/assets/lithermes-plugin/skills/debugging/SKILL.md +271 -0
  123. package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +108 -0
  124. package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +122 -0
  125. package/assets/lithermes-plugin/skills/debugging/references/methodology/04-oracle-triple.md +136 -0
  126. package/assets/lithermes-plugin/skills/debugging/references/methodology/05-escalate.md +69 -0
  127. package/assets/lithermes-plugin/skills/debugging/references/methodology/06-fix.md +116 -0
  128. package/assets/lithermes-plugin/skills/debugging/references/methodology/08-qa.md +94 -0
  129. package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +164 -0
  130. package/assets/lithermes-plugin/skills/debugging/references/methodology/partial-runtime-evidence.md +229 -0
  131. package/assets/lithermes-plugin/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
  132. package/assets/lithermes-plugin/skills/debugging/references/runtimes/go.md +252 -0
  133. package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +484 -0
  134. package/assets/lithermes-plugin/skills/debugging/references/runtimes/node.md +260 -0
  135. package/assets/lithermes-plugin/skills/debugging/references/runtimes/python.md +248 -0
  136. package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +234 -0
  137. package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +212 -0
  138. package/assets/lithermes-plugin/skills/debugging/references/tools/playwright-cli.md +194 -0
  139. package/assets/lithermes-plugin/skills/debugging/references/tools/pwndbg.md +263 -0
  140. package/assets/lithermes-plugin/skills/debugging/references/tools/pwntools.md +265 -0
  141. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +419 -0
  142. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +49 -0
  143. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/.gitattributes +2 -0
  144. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/README.md +248 -0
  145. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/_INDEX.md +191 -0
  146. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/airbnb.md +393 -0
  147. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/airtable.md +92 -0
  148. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/apple.md +250 -0
  149. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/aside.md +209 -0
  150. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/binance.md +348 -0
  151. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/bmw.md +183 -0
  152. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/brutalist-skill.md +92 -0
  153. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/bugatti.md +271 -0
  154. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/cal.md +262 -0
  155. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/claude.md +315 -0
  156. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/clay.md +307 -0
  157. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/clickhouse.md +284 -0
  158. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/clone-from-url.md +65 -0
  159. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/cohere.md +269 -0
  160. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/coinbase.md +132 -0
  161. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/composio.md +310 -0
  162. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/cursor.md +312 -0
  163. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/design-system-architecture.md +244 -0
  164. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/elevenlabs.md +268 -0
  165. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/expo.md +284 -0
  166. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/ferrari.md +317 -0
  167. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/figma.md +223 -0
  168. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/framer.md +249 -0
  169. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/gpt-tasteskill.md +74 -0
  170. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/hashicorp.md +281 -0
  171. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/ibm.md +335 -0
  172. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/image-to-code-skill.md +1228 -0
  173. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/imagegen-brandkit.md +798 -0
  174. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/imagegen-frontend-mobile.md +1465 -0
  175. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/imagegen-frontend-web.md +987 -0
  176. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/intercom.md +149 -0
  177. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/kraken.md +128 -0
  178. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/lamborghini.md +291 -0
  179. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/layout-skill.md +107 -0
  180. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/lazyweb.md +77 -0
  181. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/linear.app.md +370 -0
  182. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/lovable.md +301 -0
  183. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mastercard.md +368 -0
  184. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/meta.md +369 -0
  185. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/minimalist-skill.md +85 -0
  186. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/minimax.md +260 -0
  187. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mintlify.md +329 -0
  188. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/miro.md +111 -0
  189. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mistral.ai.md +264 -0
  190. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mongodb.md +269 -0
  191. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/nike.md +366 -0
  192. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/notion.md +312 -0
  193. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/nvidia.md +296 -0
  194. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/ollama.md +270 -0
  195. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/opencode.ai.md +284 -0
  196. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/output-skill.md +49 -0
  197. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/pinterest.md +233 -0
  198. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/playstation.md +367 -0
  199. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/posthog.md +259 -0
  200. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/raycast.md +271 -0
  201. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/react-dev-tooling-skill.md +230 -0
  202. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/redesign-skill.md +178 -0
  203. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/renault.md +314 -0
  204. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/replicate.md +264 -0
  205. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/resend.md +306 -0
  206. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/revolut.md +188 -0
  207. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/runwayml.md +247 -0
  208. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/sanity.md +360 -0
  209. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/sentry.md +265 -0
  210. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/shopify.md +353 -0
  211. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/soft-skill.md +98 -0
  212. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/spacex.md +197 -0
  213. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/spotify.md +249 -0
  214. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/starbucks.md +583 -0
  215. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/stitch-design-example.md +121 -0
  216. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/stitch-skill.md +184 -0
  217. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/stripe.md +325 -0
  218. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/supabase.md +258 -0
  219. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/superhuman.md +255 -0
  220. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/taste-skill.md +1206 -0
  221. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/tesla.md +289 -0
  222. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/theverge.md +342 -0
  223. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/together.ai.md +266 -0
  224. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/uber.md +298 -0
  225. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/vercel.md +313 -0
  226. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/vodafone.md +426 -0
  227. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/voltagent.md +326 -0
  228. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/warp.md +256 -0
  229. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/webflow.md +95 -0
  230. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/wired.md +281 -0
  231. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/wise.md +176 -0
  232. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/x.ai.md +260 -0
  233. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/zapier.md +331 -0
  234. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/EVIDENCE.md +97 -0
  235. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/README.md +48 -0
  236. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/UPSTREAM.md +80 -0
  237. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-a-direction.md +64 -0
  238. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-b-execution.md +65 -0
  239. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-c-review.md +65 -0
  240. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-d-memory.md +83 -0
  241. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/orchestration.md +80 -0
  242. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/routing.md +79 -0
  243. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/LICENSE +21 -0
  244. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/accessibility-reviewer.md +83 -0
  245. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/content-writer.md +132 -0
  246. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-builder.md +109 -0
  247. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-critic.md +89 -0
  248. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-lead.md +113 -0
  249. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-scout.md +78 -0
  250. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-strategist.md +121 -0
  251. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/heuristic-evaluator.md +268 -0
  252. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/inspiration-scout.md +107 -0
  253. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/motion-designer.md +120 -0
  254. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/accessible-content/reference.md +101 -0
  255. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/adaptive-interfaces/reference.md +109 -0
  256. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/cognitive-accessibility/reference.md +107 -0
  257. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-debate/reference.md +199 -0
  258. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-debt-tracker/reference.md +174 -0
  259. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-handoff/reference.md +125 -0
  260. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-md/reference.md +106 -0
  261. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-retrospective/reference.md +266 -0
  262. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-review/reference.md +123 -0
  263. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-system-alignment/reference.md +120 -0
  264. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/designpowers-critique/reference.md +164 -0
  265. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/heuristic-evaluation/reference.md +85 -0
  266. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/inclusive-personas/reference.md +98 -0
  267. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/inspiration-scouting/reference.md +165 -0
  268. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/interaction-design/reference.md +122 -0
  269. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/motion-choreography/reference.md +81 -0
  270. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/research-planning/reference.md +96 -0
  271. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/responsive-patterns/reference.md +77 -0
  272. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/synthetic-user-testing/reference.md +192 -0
  273. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/taste-feedback/reference.md +165 -0
  274. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/taste-report/reference.md +78 -0
  275. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/token-architecture/reference.md +75 -0
  276. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/ui-composition/reference.md +117 -0
  277. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/usability-testing/reference.md +78 -0
  278. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/verification-before-shipping/reference.md +125 -0
  279. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/voice-and-tone/reference.md +79 -0
  280. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/writing-design-plans/reference.md +119 -0
  281. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/perfection/README.md +160 -0
  282. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/perfection/react-perf-tooling.md +127 -0
  283. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/README.md +659 -0
  284. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/charts.csv +26 -0
  285. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/colors.csv +162 -0
  286. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/icons.csv +106 -0
  287. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/landing.csv +35 -0
  288. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/products.csv +162 -0
  289. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/react-performance.csv +45 -0
  290. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/astro.csv +54 -0
  291. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/flutter.csv +53 -0
  292. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/html-tailwind.csv +56 -0
  293. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/jetpack-compose.csv +53 -0
  294. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nextjs.csv +53 -0
  295. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxt-ui.csv +51 -0
  296. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxtjs.csv +59 -0
  297. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/react-native.csv +52 -0
  298. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/react.csv +54 -0
  299. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/shadcn.csv +61 -0
  300. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/svelte.csv +54 -0
  301. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/swiftui.csv +51 -0
  302. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/vue.csv +50 -0
  303. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/styles.csv +85 -0
  304. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/typography.csv +74 -0
  305. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/ui-reasoning.csv +162 -0
  306. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/ux-guidelines.csv +100 -0
  307. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/web-interface.csv +31 -0
  308. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/scripts/core.py +262 -0
  309. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/scripts/design_system.py +1148 -0
  310. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/scripts/search.py +114 -0
  311. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/legal/ATTRIBUTION.md +217 -0
  312. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/legal/LICENSE +21 -0
  313. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/legal/LICENSE-Apache-2.0.txt +201 -0
  314. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/manifest.json +876 -0
  315. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/adaptive-layout.md +81 -0
  316. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/brand-and-imagery.md +84 -0
  317. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +457 -0
  318. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/composition.md +89 -0
  319. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/creative-directions.md +81 -0
  320. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/evidence-review.md +113 -0
  321. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/implementation-platforms.md +92 -0
  322. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/inclusive-interface.md +85 -0
  323. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/interaction-motion.md +87 -0
  324. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/operating-lanes.md +85 -0
  325. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/performance-delivery.md +85 -0
  326. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/product-direction.md +86 -0
  327. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/redesign-playbook.md +91 -0
  328. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/system-foundations.md +82 -0
  329. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/taste-direction.md +86 -0
  330. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/visual-language.md +85 -0
  331. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/visual-reconstruction.md +94 -0
  332. package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/LICENSE +21 -0
  333. package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/PROVENANCE.json +38 -0
  334. package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/THIRD-PARTY-NOTICE.txt +14 -0
  335. package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/design-intelligence.json +1 -0
  336. package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/import-manifest.json +1060 -0
  337. package/assets/lithermes-plugin/skills/frontend-ui-ux/schemas/design-contract-v1alpha1.schema.json +264 -0
  338. package/assets/lithermes-plugin/skills/frontend-ui-ux/schemas/design-contract-v1beta1.schema.json +268 -0
  339. package/assets/lithermes-plugin/skills/frontend-ui-ux/schemas/design-contract-v1beta2.schema.json +280 -0
  340. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design_contract_validation.py +996 -0
  341. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design_intelligence.py +128 -0
  342. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design_runtime.py +98 -0
  343. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/import_design_intelligence.py +56 -0
  344. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/source_import.py +144 -0
  345. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/source_manifest.py +65 -0
  346. package/assets/lithermes-plugin/skills/lit-burnoff/SKILL.md +448 -0
  347. package/assets/lithermes-plugin/skills/lit-burnoff-file/SKILL.md +254 -0
  348. package/assets/lithermes-plugin/skills/lit-code/SKILL.md +609 -0
  349. package/assets/lithermes-plugin/skills/lit-code/references/go/README.md +90 -0
  350. package/assets/lithermes-plugin/skills/lit-code/references/go/backend-stack.md +641 -0
  351. package/assets/lithermes-plugin/skills/lit-code/references/go/bootstrap.md +328 -0
  352. package/assets/lithermes-plugin/skills/lit-code/references/go/bubbletea-v2.md +359 -0
  353. package/assets/lithermes-plugin/skills/lit-code/references/go/cobra-stack.md +468 -0
  354. package/assets/lithermes-plugin/skills/lit-code/references/go/concurrency.md +362 -0
  355. package/assets/lithermes-plugin/skills/lit-code/references/go/data-modeling.md +329 -0
  356. package/assets/lithermes-plugin/skills/lit-code/references/go/error-handling.md +359 -0
  357. package/assets/lithermes-plugin/skills/lit-code/references/go/golangci-strict.md +236 -0
  358. package/assets/lithermes-plugin/skills/lit-code/references/go/grpc-connect.md +375 -0
  359. package/assets/lithermes-plugin/skills/lit-code/references/go/libraries.md +337 -0
  360. package/assets/lithermes-plugin/skills/lit-code/references/go/one-liners.md +202 -0
  361. package/assets/lithermes-plugin/skills/lit-code/references/go/sqlc-pgx.md +471 -0
  362. package/assets/lithermes-plugin/skills/lit-code/references/go/testing.md +467 -0
  363. package/assets/lithermes-plugin/skills/lit-code/references/go/type-patterns.md +298 -0
  364. package/assets/lithermes-plugin/skills/lit-code/references/python/README.md +314 -0
  365. package/assets/lithermes-plugin/skills/lit-code/references/python/async-anyio.md +442 -0
  366. package/assets/lithermes-plugin/skills/lit-code/references/python/data-modeling.md +233 -0
  367. package/assets/lithermes-plugin/skills/lit-code/references/python/data-processing.md +133 -0
  368. package/assets/lithermes-plugin/skills/lit-code/references/python/error-handling.md +218 -0
  369. package/assets/lithermes-plugin/skills/lit-code/references/python/fastapi-stack.md +316 -0
  370. package/assets/lithermes-plugin/skills/lit-code/references/python/httpx2-optimization.md +360 -0
  371. package/assets/lithermes-plugin/skills/lit-code/references/python/libraries.md +307 -0
  372. package/assets/lithermes-plugin/skills/lit-code/references/python/one-liners.md +268 -0
  373. package/assets/lithermes-plugin/skills/lit-code/references/python/orjson-stack.md +378 -0
  374. package/assets/lithermes-plugin/skills/lit-code/references/python/pydantic-ai.md +285 -0
  375. package/assets/lithermes-plugin/skills/lit-code/references/python/pyproject-strict.md +232 -0
  376. package/assets/lithermes-plugin/skills/lit-code/references/python/textual-tui.md +201 -0
  377. package/assets/lithermes-plugin/skills/lit-code/references/python/type-patterns.md +176 -0
  378. package/assets/lithermes-plugin/skills/lit-code/references/rust/README.md +317 -0
  379. package/assets/lithermes-plugin/skills/lit-code/references/rust/async-tokio.md +299 -0
  380. package/assets/lithermes-plugin/skills/lit-code/references/rust/axum-stack.md +467 -0
  381. package/assets/lithermes-plugin/skills/lit-code/references/rust/cargo-strict.md +317 -0
  382. package/assets/lithermes-plugin/skills/lit-code/references/rust/clap-stack.md +409 -0
  383. package/assets/lithermes-plugin/skills/lit-code/references/rust/concurrency.md +375 -0
  384. package/assets/lithermes-plugin/skills/lit-code/references/rust/libraries.md +439 -0
  385. package/assets/lithermes-plugin/skills/lit-code/references/rust/one-liners.md +291 -0
  386. package/assets/lithermes-plugin/skills/lit-code/references/rust/proptest-insta.md +429 -0
  387. package/assets/lithermes-plugin/skills/lit-code/references/rust/type-state.md +354 -0
  388. package/assets/lithermes-plugin/skills/lit-code/references/rust/unsafe-discipline.md +250 -0
  389. package/assets/lithermes-plugin/skills/lit-code/references/rust/zero-cost-safety.md +527 -0
  390. package/assets/lithermes-plugin/skills/lit-code/references/rust-ub/README.md +289 -0
  391. package/assets/lithermes-plugin/skills/lit-code/references/rust-ub/miri-sanitizers-loom.md +411 -0
  392. package/assets/lithermes-plugin/skills/lit-code/references/rust-ub/ub-taxonomy.md +269 -0
  393. package/assets/lithermes-plugin/skills/lit-code/references/typescript/README.md +195 -0
  394. package/assets/lithermes-plugin/skills/lit-code/references/typescript/backend-hono.md +672 -0
  395. package/assets/lithermes-plugin/skills/lit-code/references/typescript/bootstrap.md +199 -0
  396. package/assets/lithermes-plugin/skills/lit-code/references/typescript/data-modeling.md +202 -0
  397. package/assets/lithermes-plugin/skills/lit-code/references/typescript/error-handling.md +169 -0
  398. package/assets/lithermes-plugin/skills/lit-code/references/typescript/tsconfig-strict.md +152 -0
  399. package/assets/lithermes-plugin/skills/lit-code/references/typescript/type-patterns.md +196 -0
  400. package/assets/lithermes-plugin/skills/lit-code/scripts/go/check-no-excuse-rules.sh +173 -0
  401. package/assets/lithermes-plugin/skills/lit-code/scripts/go/new-project.py +138 -0
  402. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/.editorconfig +13 -0
  403. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/.golangci.yml +95 -0
  404. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/AGENTS.md.tmpl +24 -0
  405. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/README.md.tmpl +12 -0
  406. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/Taskfile.yml +40 -0
  407. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/ci.yml +37 -0
  408. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/config.go +24 -0
  409. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/gitignore +15 -0
  410. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/main.go.tmpl +22 -0
  411. package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/run.go +15 -0
  412. package/assets/lithermes-plugin/skills/lit-code/scripts/python/check-no-excuse-rules.py +687 -0
  413. package/assets/lithermes-plugin/skills/lit-code/scripts/python/new-project.py +172 -0
  414. package/assets/lithermes-plugin/skills/lit-code/scripts/python/new-script.py +116 -0
  415. package/assets/lithermes-plugin/skills/lit-code/scripts/rust/check-no-excuse-rules.py +296 -0
  416. package/assets/lithermes-plugin/skills/lit-code/scripts/rust/check-no-excuse-rules.sh +158 -0
  417. package/assets/lithermes-plugin/skills/lit-code/scripts/rust/new-project.py +175 -0
  418. package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/check-no-excuse-rules.ts +282 -0
  419. package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/new-project.ts +177 -0
  420. package/assets/lithermes-plugin/skills/lit-commit/SKILL.md +242 -0
  421. package/assets/lithermes-plugin/skills/lit-comprehend/SKILL.md +259 -0
  422. package/assets/lithermes-plugin/skills/lit-comprehend/assets/explainer-scaffold.html +123 -0
  423. package/assets/lithermes-plugin/skills/lit-comprehend/references/artifact-template.md +205 -0
  424. package/assets/lithermes-plugin/skills/lit-comprehend/references/micro-worlds.md +139 -0
  425. package/assets/lithermes-plugin/skills/lit-comprehend/scripts/verify-explainer.py +324 -0
  426. package/assets/lithermes-plugin/skills/lit-crucible/SKILL.md +315 -0
  427. package/assets/lithermes-plugin/skills/lit-handoff/ORIGIN.json +23 -0
  428. package/assets/lithermes-plugin/skills/lit-handoff/SKILL.md +145 -0
  429. package/assets/lithermes-plugin/skills/lit-init/SKILL.md +329 -0
  430. package/assets/lithermes-plugin/skills/lit-korean/SKILL.md +237 -0
  431. package/assets/lithermes-plugin/skills/lit-korean/references/quick-rules.md +24 -0
  432. package/assets/lithermes-plugin/skills/lit-korean/references/safety-checklist.md +23 -0
  433. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +596 -0
  434. package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +183 -0
  435. package/assets/lithermes-plugin/skills/lit-scientific-visualization/ORIGIN.json +32 -0
  436. package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +170 -0
  437. package/assets/lithermes-plugin/skills/litgoal/.gitkeep +0 -0
  438. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +372 -0
  439. package/assets/lithermes-plugin/skills/litresearch/ATTRIBUTION.md +50 -0
  440. package/assets/lithermes-plugin/skills/litresearch/SKILL.md +593 -0
  441. package/assets/lithermes-plugin/skills/litwork/SKILL.md +419 -0
  442. package/assets/lithermes-plugin/skills/lsp/SKILL.md +165 -0
  443. package/assets/lithermes-plugin/skills/lsp-setup/SKILL.md +265 -0
  444. package/assets/lithermes-plugin/skills/lsp-setup/references/bash/README.md +68 -0
  445. package/assets/lithermes-plugin/skills/lsp-setup/references/c-cpp/README.md +78 -0
  446. package/assets/lithermes-plugin/skills/lsp-setup/references/csharp/README.md +90 -0
  447. package/assets/lithermes-plugin/skills/lsp-setup/references/dart/README.md +59 -0
  448. package/assets/lithermes-plugin/skills/lsp-setup/references/elixir/README.md +61 -0
  449. package/assets/lithermes-plugin/skills/lsp-setup/references/go/README.md +63 -0
  450. package/assets/lithermes-plugin/skills/lsp-setup/references/haskell/README.md +69 -0
  451. package/assets/lithermes-plugin/skills/lsp-setup/references/java/README.md +70 -0
  452. package/assets/lithermes-plugin/skills/lsp-setup/references/julia/README.md +70 -0
  453. package/assets/lithermes-plugin/skills/lsp-setup/references/kotlin/README.md +72 -0
  454. package/assets/lithermes-plugin/skills/lsp-setup/references/lua/README.md +68 -0
  455. package/assets/lithermes-plugin/skills/lsp-setup/references/php/README.md +61 -0
  456. package/assets/lithermes-plugin/skills/lsp-setup/references/python/README.md +73 -0
  457. package/assets/lithermes-plugin/skills/lsp-setup/references/ruby/README.md +67 -0
  458. package/assets/lithermes-plugin/skills/lsp-setup/references/rust/README.md +65 -0
  459. package/assets/lithermes-plugin/skills/lsp-setup/references/swift/README.md +64 -0
  460. package/assets/lithermes-plugin/skills/lsp-setup/references/terraform/README.md +66 -0
  461. package/assets/lithermes-plugin/skills/lsp-setup/references/typescript/README.md +82 -0
  462. package/assets/lithermes-plugin/skills/lsp-setup/references/yaml/README.md +65 -0
  463. package/assets/lithermes-plugin/skills/lsp-setup/references/zig/README.md +59 -0
  464. package/assets/lithermes-plugin/skills/lsp-setup/scripts/detect-lsp.ts +221 -0
  465. package/assets/lithermes-plugin/skills/lsp-setup/scripts/lsp-server-table.ts +146 -0
  466. package/assets/lithermes-plugin/skills/lsp-setup/scripts/tsconfig.json +18 -0
  467. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +252 -0
  468. package/assets/lithermes-plugin/skills/refactor/SKILL.md +896 -0
  469. package/assets/lithermes-plugin/skills/review-work/SKILL.md +450 -0
  470. package/assets/lithermes-plugin/skills/rules/SKILL.md +194 -0
  471. package/assets/lithermes-plugin/skills/skill-observer/SKILL.md +190 -0
  472. package/assets/lithermes-plugin/skills/skill-observer/references/review-contract.md +77 -0
  473. package/assets/lithermes-plugin/skills/start-work/SKILL.md +550 -0
  474. package/assets/lithermes-plugin/skills/structural-search/SKILL.md +274 -0
  475. package/assets/lithermes-plugin/skills/visual-qa/SKILL.md +48 -0
  476. package/assets/lithermes-plugin/skills/visual-qa/references/capture-playbook.md +133 -0
  477. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +522 -0
  478. package/assets/lithermes-plugin/skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json +135 -0
  479. package/assets/lithermes-plugin/skills/visual-qa/schemas/evidence-manifest-v1beta1.schema.json +110 -0
  480. package/assets/lithermes-plugin/skills/visual-qa/schemas/review-receipt-v1alpha1.schema.json +49 -0
  481. package/assets/lithermes-plugin/skills/visual-qa/scripts/contract_checks.py +71 -0
  482. package/assets/lithermes-plugin/skills/visual-qa/scripts/evidence_contract.py +288 -0
  483. package/assets/lithermes-plugin/skills/visual-qa/scripts/png_runtime.py +379 -0
  484. package/assets/lithermes-plugin/skills/visual-qa/scripts/review_contract.py +151 -0
  485. package/assets/lithermes-plugin/skills/visual-qa/scripts/tier_checks.py +190 -0
  486. package/assets/lithermes-plugin/skills/visual-qa/scripts/tier_contract.py +103 -0
  487. package/assets/lithermes-plugin/skills/visual-qa/scripts/tui_runtime.py +170 -0
  488. package/assets/lithermes-plugin/skills/visual-qa/scripts/visual_contracts.py +31 -0
  489. package/assets/lithermes-plugin/skills/visual-qa/scripts/visual_qa.py +79 -0
  490. package/assets/lithermes-plugin/skills/wikify/LICENSE +21 -0
  491. package/assets/lithermes-plugin/skills/wikify/ORIGIN.json +12 -0
  492. package/assets/lithermes-plugin/skills/wikify/SKILL.md +192 -0
  493. package/assets/lithermes-plugin/skills/wikify/assets/home-template.md +44 -0
  494. package/assets/lithermes-plugin/skills/wikify/assets/maintenance-report-template.md +46 -0
  495. package/assets/lithermes-plugin/skills/wikify/assets/paper-source-note-template.md +138 -0
  496. package/assets/lithermes-plugin/skills/wikify/assets/source-note-template.md +45 -0
  497. package/assets/lithermes-plugin/skills/wikify/assets/wiki-rules-template.md +117 -0
  498. package/assets/lithermes-plugin/skills/wikify/modes/ingest/SKILL.md +24 -0
  499. package/assets/lithermes-plugin/skills/wikify/modes/init/SKILL.md +26 -0
  500. package/assets/lithermes-plugin/skills/wikify/modes/lint/SKILL.md +30 -0
  501. package/assets/lithermes-plugin/skills/wikify/modes/query/SKILL.md +22 -0
  502. package/assets/lithermes-plugin/skills/wikify/modes/save/SKILL.md +32 -0
  503. package/assets/lithermes-plugin/skills/wikify/references/full-contract.md +761 -0
  504. package/assets/lithermes-plugin/uiux_runtime_common.py +117 -0
  505. package/assets/lithermes-plugin/vendor/NOTICE.md +21 -0
  506. package/assets/lithermes-plugin/vendor/handoff/SKILL.md +199 -0
  507. package/assets/lithermes-plugin/vendor/handoff/evals/evals.json +154 -0
  508. package/assets/lithermes-plugin/vendor/handoff/examples/HANDOFF-example-generic-auth-refactor.md +97 -0
  509. package/assets/lithermes-plugin/vendor/handoff/templates/HANDOFF.md +121 -0
  510. package/assets/lithermes-plugin/vendor/licenses/022_handoff-MIT.txt +21 -0
  511. package/assets/lithermes-plugin/vendor/licenses/045_scientific-visualization-MIT.txt +21 -0
  512. package/assets/lithermes-plugin/vendor/provenance/022_handoff.md +16 -0
  513. package/assets/lithermes-plugin/vendor/provenance/045_scientific-visualization.md +18 -0
  514. package/assets/lithermes-plugin/vendor/scientific-visualization/SKILL.md +283 -0
  515. package/assets/lithermes-plugin/vendor/scientific-visualization/assets/color_palettes.py +197 -0
  516. package/assets/lithermes-plugin/vendor/scientific-visualization/assets/nature.mplstyle +75 -0
  517. package/assets/lithermes-plugin/vendor/scientific-visualization/assets/presentation.mplstyle +74 -0
  518. package/assets/lithermes-plugin/vendor/scientific-visualization/assets/publication.mplstyle +78 -0
  519. package/assets/lithermes-plugin/vendor/scientific-visualization/evals/evals.json +158 -0
  520. package/assets/lithermes-plugin/vendor/scientific-visualization/references/color_palettes.md +380 -0
  521. package/assets/lithermes-plugin/vendor/scientific-visualization/references/journal_requirements.md +359 -0
  522. package/assets/lithermes-plugin/vendor/scientific-visualization/references/matplotlib_examples.md +608 -0
  523. package/assets/lithermes-plugin/vendor/scientific-visualization/references/mdanalysis_martini_visualization.md +85 -0
  524. package/assets/lithermes-plugin/vendor/scientific-visualization/references/publication_guidelines.md +217 -0
  525. package/assets/lithermes-plugin/vendor/scientific-visualization/references/seaborn_for_publications.md +293 -0
  526. package/assets/lithermes-plugin/vendor/scientific-visualization/scripts/figure_export.py +238 -0
  527. package/assets/lithermes-plugin/vendor/scientific-visualization/scripts/style_presets.py +467 -0
  528. package/assets/lithermes-plugin/vendor/scientific-visualization/tests/test_figure_export.py +51 -0
  529. package/assets/lithermes-plugin/vendor/scientific-visualization/tests/test_style_presets.py +114 -0
  530. package/bin/lithermes.js +8 -0
  531. package/package.json +58 -0
  532. package/readme-assets/badge-license.svg +1 -0
  533. package/readme-assets/badge-version.svg +1 -0
  534. package/readme-assets/cover.webp +0 -0
  535. package/readme-assets/lithermes-clay-icon.png +0 -0
  536. package/readme-assets/lithermes-wordmark.svg +5 -0
  537. package/src/cli.js +396 -0
  538. package/src/lib/autoUpdate.js +4 -0
  539. package/src/lib/canonicalCorpus.js +436 -0
  540. package/src/lib/check.js +200 -0
  541. package/src/lib/config.js +229 -0
  542. package/src/lib/files.js +126 -0
  543. package/src/lib/hermesDiscovery.js +122 -0
  544. package/src/lib/hud.js +135 -0
  545. package/src/lib/install.js +559 -0
  546. package/src/lib/litMark.js +68 -0
  547. package/src/lib/modelConfig.js +431 -0
  548. package/src/lib/modelConfigBoundary.js +206 -0
  549. package/src/lib/modelPrompt.js +201 -0
  550. package/src/lib/modelRoutePolicy.js +379 -0
  551. package/src/lib/patch.js +153 -0
  552. package/src/lib/skillLoop.js +62 -0
  553. package/src/lib/skillPayload.js +306 -0
  554. package/src/lib/skins.js +274 -0
  555. package/src/lib/spinner.js +193 -0
  556. package/src/lib/updateNotifier.js +1133 -0
  557. package/src/lib/updateNotifierWorker.js +9 -0
@@ -0,0 +1,1778 @@
1
+ """Durable store for skill-improvement observations.
2
+
3
+ The capability's whole value rests on one restraint: it proposes, and something
4
+ with judgement decides. A tool that edited skills from its own inferences would
5
+ compound its mistakes silently, and the skill corpus would drift away from
6
+ anything a reviewer approved. So every record here carries ``applied: False``,
7
+ and nothing in this module opens a skill file for writing.
8
+
9
+ Observations are text the session did not author -- a user's correction quoted
10
+ back, a pattern noticed in a transcript. That text is stored as data: bounded,
11
+ single-line, credential-free, and never interpreted. An observation asking to
12
+ rewrite every skill is a curiosity to record, not an instruction to follow.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import errno
18
+ import json
19
+ import os
20
+ import re
21
+ import stat
22
+ import sys
23
+ import threading
24
+ import time
25
+ import unicodedata
26
+ from contextlib import contextmanager
27
+ from pathlib import Path
28
+ from typing import Any, Final
29
+
30
+ if __package__:
31
+ from .redaction import redact_text
32
+ else: # Standalone diagnostic/test imports retain their existing module entry.
33
+ from redaction import redact_text
34
+
35
+ try:
36
+ import fcntl
37
+ except (ImportError, ModuleNotFoundError): # pragma: no cover - Hermes targets POSIX hosts
38
+ fcntl = None
39
+
40
+ _SETUP_FLOCK = fcntl.flock if fcntl is not None else None
41
+
42
+ #: The three signals worth capturing. A fourth needs a contract revision, not a
43
+ #: free-text value.
44
+ OBSERVER_SIGNALS: Final = ("correction", "repetition", "coverage-gap")
45
+
46
+ _ALLOWED_FIELDS: Final = ("signal", "skill_id", "observed", "proposal")
47
+ _PERSISTED_FIELDS: Final = _ALLOWED_FIELDS + ("applied",)
48
+ _TEXT_CEILING: Final = 1000
49
+ MAX_LOG_BYTES: Final = 1024 * 1024
50
+ _LOCK_WAIT_SECONDS: Final = 0.5
51
+ _LOCK_POLL_SECONDS: Final = 0.01
52
+ _LOG_NAME: Final = "observations.jsonl"
53
+ _LOCK_NAME: Final = "observations.jsonl.lock"
54
+ _LOCK_STALE_SECONDS: Final = 5.0
55
+ _LOCK_METADATA_BYTES: Final = 4096
56
+ _STATE_COMPONENTS: Final = (".hermes", "lithermes", "skill-observer")
57
+ _PATH_SETUP_LOCK = threading.Lock()
58
+ _UNKNOWN_FIELD_MESSAGE: Final = "unknown observation field"
59
+ _SECRET_SCAN_BYTE_CEILING: Final = 64 * 1024
60
+ _COMMITTED_CLEANUP_CODE: Final = "OBSERVER_RECORD_COMMITTED_CLEANUP_FAILED"
61
+ _COMMITTED_CLEANUP_MESSAGE: Final = "the observation record was committed; read the log before retrying"
62
+ _UNKNOWN_STATE_CODE: Final = "OBSERVER_RECORD_STATE_UNKNOWN"
63
+ _UNKNOWN_STATE_MESSAGE: Final = (
64
+ "the observation record rollback failed; the record state is unknown; read the log before retrying"
65
+ )
66
+ _SKILL_ID: Final = re.compile(r"[a-z][a-z0-9-]{0,63}")
67
+ _CONTROL: Final = re.compile(r"[\x00-\x1f\x7f-\x9f]")
68
+
69
+
70
+ def _is_windows_runtime() -> bool:
71
+ return sys.platform == "win32"
72
+
73
+
74
+ def _uses_path_lock() -> bool:
75
+ return fcntl is None or _is_windows_runtime()
76
+
77
+
78
+ class SkillObserverError(Exception):
79
+ def __init__(self, code: str, message: str) -> None:
80
+ super().__init__(message)
81
+ self.code = code
82
+
83
+
84
+ def _committed_cleanup_error() -> SkillObserverError:
85
+ return SkillObserverError(_COMMITTED_CLEANUP_CODE, _COMMITTED_CLEANUP_MESSAGE)
86
+
87
+
88
+ def _unknown_state_error() -> SkillObserverError:
89
+ return SkillObserverError(_UNKNOWN_STATE_CODE, _UNKNOWN_STATE_MESSAGE)
90
+
91
+
92
+ def _is_unknown_state(error: BaseException | None) -> bool:
93
+ return isinstance(error, SkillObserverError) and error.code == _UNKNOWN_STATE_CODE
94
+
95
+
96
+ def _unsafe_text_error() -> SkillObserverError:
97
+ return SkillObserverError(
98
+ "OBSERVER_TEXT_UNSAFE",
99
+ "observation text contains incomplete or unsupported terminal escape data",
100
+ )
101
+
102
+
103
+ def _text_too_large_error() -> SkillObserverError:
104
+ return SkillObserverError(
105
+ "OBSERVER_TEXT_TOO_LARGE",
106
+ "observation text exceeds the secret-scan input limit",
107
+ )
108
+
109
+
110
+ def _consume_csi(value: str, index: int) -> int:
111
+ intermediate = False
112
+ while index < len(value):
113
+ code = ord(value[index])
114
+ if not intermediate and 0x30 <= code <= 0x3F:
115
+ index += 1
116
+ continue
117
+ if 0x20 <= code <= 0x2F:
118
+ intermediate = True
119
+ index += 1
120
+ continue
121
+ if 0x40 <= code <= 0x7E:
122
+ return index + 1
123
+ raise _unsafe_text_error()
124
+ raise _unsafe_text_error()
125
+
126
+
127
+ def _consume_osc(value: str, index: int) -> int:
128
+ while index < len(value):
129
+ character = value[index]
130
+ if character in ("\x07", "\x9c"):
131
+ return index + 1
132
+ if character == "\x1b":
133
+ if index + 1 < len(value) and value[index + 1] == "\\":
134
+ return index + 2
135
+ raise _unsafe_text_error()
136
+ index += 1
137
+ raise _unsafe_text_error()
138
+
139
+
140
+ def _normalization_surfaces(value: str) -> tuple[str, str, str]:
141
+ if len(value.encode("utf-8", "surrogatepass")) > _SECRET_SCAN_BYTE_CEILING:
142
+ raise _text_too_large_error()
143
+
144
+ stripped: list[str] = []
145
+ spaced: list[str] = []
146
+ index = 0
147
+ while index < len(value):
148
+ character = value[index]
149
+ if character == "\x1b":
150
+ if index + 1 >= len(value):
151
+ raise _unsafe_text_error()
152
+ introducer = value[index + 1]
153
+ if introducer == "[":
154
+ index = _consume_csi(value, index + 2)
155
+ elif introducer == "]":
156
+ index = _consume_osc(value, index + 2)
157
+ else:
158
+ raise _unsafe_text_error()
159
+ spaced.append(" ")
160
+ continue
161
+ if character == "\x9b":
162
+ index = _consume_csi(value, index + 1)
163
+ spaced.append(" ")
164
+ continue
165
+ if character == "\x9d":
166
+ index = _consume_osc(value, index + 1)
167
+ spaced.append(" ")
168
+ continue
169
+ code = ord(character)
170
+ if 0x80 <= code <= 0x9F:
171
+ raise _unsafe_text_error()
172
+ stripped.append(character)
173
+ if code <= 0x1F or 0x7F <= code <= 0x9F or unicodedata.category(character) == "Cf":
174
+ spaced.append(" ")
175
+ else:
176
+ spaced.append(character)
177
+ index += 1
178
+
179
+ normalized_stripped = unicodedata.normalize("NFKC", "".join(stripped))
180
+ normalized_spaced = unicodedata.normalize("NFKC", "".join(spaced))
181
+ stripped_surface = "".join(
182
+ character
183
+ for character in normalized_stripped
184
+ if not _CONTROL.fullmatch(character) and unicodedata.category(character) != "Cf"
185
+ )
186
+ spaced_surface = "".join(
187
+ " "
188
+ if _CONTROL.fullmatch(character) or unicodedata.category(character) == "Cf"
189
+ else character
190
+ for character in normalized_spaced
191
+ )
192
+ return value, stripped_surface, spaced_surface
193
+
194
+
195
+ def contains_secret(value: Any) -> bool:
196
+ """True when the value looks like it carries a credential.
197
+
198
+ This asks the harness's own redactor rather than carrying a second pattern
199
+ list: if redaction changes the text, a credential shape was present. Two
200
+ copies drift, and a stale copy fails open -- it persists the secret it was
201
+ written to catch.
202
+
203
+ The redactor also sees stripped and spaced text surfaces. ANSI escapes or
204
+ zero-width characters interleaved through a credential would otherwise
205
+ splice it apart so no detector pattern matches, and the fragments would
206
+ persist.
207
+ """
208
+ if not isinstance(value, str) or not value:
209
+ return False
210
+ return any(redact_text(surface) != surface for surface in _normalization_surfaces(value))
211
+
212
+
213
+ def observation_log_path(root: str | os.PathLike[str]) -> Path:
214
+ return Path(root).resolve() / ".hermes" / "lithermes" / "skill-observer" / "observations.jsonl"
215
+
216
+
217
+ def observation_lock_path(root: str | os.PathLike[str]) -> Path:
218
+ return observation_log_path(root).with_name(_LOCK_NAME)
219
+
220
+
221
+ def _reject_symlink_ancestors(root: str | os.PathLike[str]) -> None:
222
+ try:
223
+ absolute = Path(os.path.abspath(os.fspath(root)))
224
+ except (OSError, TypeError):
225
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace path is invalid") from None
226
+ current = Path(absolute.anchor)
227
+ for part in absolute.parts:
228
+ if part == absolute.anchor:
229
+ continue
230
+ current /= part
231
+ try:
232
+ value = os.lstat(current)
233
+ except FileNotFoundError:
234
+ break
235
+ except OSError:
236
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation workspace parent cannot be inspected") from None
237
+ if stat.S_ISLNK(value.st_mode) and not _is_platform_path_alias(current):
238
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation workspace parent is a symbolic link")
239
+
240
+
241
+ def _is_platform_path_alias(path: Path) -> bool:
242
+ if path.parent != Path("/"):
243
+ return False
244
+ try:
245
+ target = Path(os.path.realpath(path))
246
+ except OSError:
247
+ return False
248
+ return target.parent == Path("/private") and target.name == path.name
249
+
250
+
251
+ def _canonical_workspace(root: str | os.PathLike[str], *, missing_ok: bool = False) -> Path:
252
+ _reject_symlink_ancestors(root)
253
+ base = Path(root)
254
+ if base.is_symlink() and not _is_platform_path_alias(base):
255
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace is a symbolic link")
256
+ try:
257
+ resolved = base.resolve(strict=True)
258
+ except FileNotFoundError:
259
+ if missing_ok:
260
+ raise
261
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace is unavailable") from None
262
+ except (OSError, RuntimeError):
263
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace is unavailable") from None
264
+ try:
265
+ value = os.stat(resolved, follow_symlinks=False)
266
+ except OSError:
267
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace cannot be inspected") from None
268
+ if stat.S_ISLNK(value.st_mode) or not stat.S_ISDIR(value.st_mode):
269
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace is not a directory")
270
+ return resolved
271
+
272
+
273
+ def _directory_flags() -> int:
274
+ required = ("O_DIRECTORY", "O_NOFOLLOW")
275
+ if any(not hasattr(os, name) for name in required):
276
+ raise SkillObserverError("OBSERVER_PATH_UNSUPPORTED", "safe directory operations are unavailable")
277
+ return os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
278
+
279
+
280
+ def _same_identity(first: os.stat_result, second: os.stat_result) -> bool:
281
+ return (
282
+ first.st_dev == second.st_dev
283
+ and first.st_ino == second.st_ino
284
+ and first.st_mode == second.st_mode
285
+ )
286
+
287
+
288
+ def _birthtime(value: os.stat_result) -> int | float | None:
289
+ birthtime_ns = getattr(value, "st_birthtime_ns", None)
290
+ if birthtime_ns is not None:
291
+ return birthtime_ns
292
+ return getattr(value, "st_birthtime", None)
293
+
294
+
295
+ def _same_immutable_identity(first: os.stat_result, second: os.stat_result) -> bool:
296
+ """Bind a path-to-open/rollback check to creation metadata as well as dev+ino."""
297
+ if not (
298
+ first.st_dev == second.st_dev
299
+ and first.st_ino == second.st_ino
300
+ and first.st_mode == second.st_mode
301
+ ):
302
+ return False
303
+ first_ctime = getattr(first, "st_ctime_ns", None)
304
+ second_ctime = getattr(second, "st_ctime_ns", None)
305
+ if first_ctime is not None or second_ctime is not None:
306
+ if first_ctime is None or second_ctime is None or first_ctime != second_ctime:
307
+ return False
308
+ first_birthtime = _birthtime(first)
309
+ second_birthtime = _birthtime(second)
310
+ if first_birthtime is not None or second_birthtime is not None:
311
+ return first_birthtime is not None and first_birthtime == second_birthtime
312
+ return first_ctime is not None and first_ctime == second_ctime
313
+
314
+
315
+ def _same_creation_identity(first: os.stat_result, second: os.stat_result) -> bool:
316
+ if not (
317
+ first.st_dev == second.st_dev
318
+ and first.st_ino == second.st_ino
319
+ and first.st_mode == second.st_mode
320
+ ):
321
+ return False
322
+ first_birthtime = _birthtime(first)
323
+ second_birthtime = _birthtime(second)
324
+ if first_birthtime is not None or second_birthtime is not None:
325
+ return first_birthtime is not None and first_birthtime == second_birthtime
326
+ first_ctime = getattr(first, "st_ctime_ns", None)
327
+ second_ctime = getattr(second, "st_ctime_ns", None)
328
+ return first_ctime is not None and first_ctime == second_ctime
329
+
330
+
331
+ def _validate_directory(value: os.stat_result) -> None:
332
+ if stat.S_ISLNK(value.st_mode) or not stat.S_ISDIR(value.st_mode):
333
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent is not a regular directory")
334
+
335
+
336
+ def _validate_regular(value: os.stat_result, *, lock: bool = False) -> None:
337
+ if stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode):
338
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_UNSAFE"
339
+ raise SkillObserverError(code, "the observation path is not a regular file")
340
+ if value.st_nlink != 1:
341
+ code = "OBSERVER_LOCK_HARDLINK" if lock else "OBSERVER_LOG_HARDLINK"
342
+ raise SkillObserverError(code, "the observation path has multiple hard links")
343
+ if not _is_windows_runtime() and value.st_mode & 0o077:
344
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_UNSAFE"
345
+ raise SkillObserverError(code, "the observation path must not be group or world accessible")
346
+
347
+
348
+ def _stat_regular_at(directory_fd: int, name: str, *, lock: bool, missing_ok: bool) -> os.stat_result | None:
349
+ try:
350
+ value = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
351
+ except FileNotFoundError:
352
+ if missing_ok:
353
+ return None
354
+ raise
355
+ except OSError:
356
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_UNSAFE"
357
+ raise SkillObserverError(code, "the observation path cannot be inspected") from None
358
+ _validate_regular(value, lock=lock)
359
+ return value
360
+
361
+
362
+ def _remove_created_directory(
363
+ parent_fd: int,
364
+ name: str,
365
+ expected: os.stat_result | None,
366
+ *,
367
+ pinned_fd: int | None = None,
368
+ ) -> None:
369
+ if expected is None:
370
+ return
371
+ try:
372
+ current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
373
+ except FileNotFoundError:
374
+ return
375
+ except OSError:
376
+ raise SkillObserverError(
377
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent cannot be inspected for removal"
378
+ ) from None
379
+ _validate_directory(current)
380
+ try:
381
+ identity_matches = (
382
+ _same_immutable_identity(os.fstat(pinned_fd), current)
383
+ if pinned_fd is not None
384
+ else _same_creation_identity(expected, current)
385
+ )
386
+ except OSError:
387
+ raise SkillObserverError(
388
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent cannot be verified for removal"
389
+ ) from None
390
+ if not identity_matches:
391
+ raise SkillObserverError(
392
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent changed before removal"
393
+ )
394
+ try:
395
+ os.rmdir(name, dir_fd=parent_fd)
396
+ except OSError:
397
+ raise SkillObserverError(
398
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent could not be removed"
399
+ ) from None
400
+ try:
401
+ os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
402
+ except FileNotFoundError:
403
+ return
404
+ except OSError:
405
+ raise SkillObserverError(
406
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent could not be verified after removal"
407
+ ) from None
408
+ raise SkillObserverError(
409
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent remained after removal"
410
+ )
411
+
412
+
413
+ def _open_child_directory(
414
+ parent_fd: int,
415
+ name: str,
416
+ *,
417
+ create: bool,
418
+ ) -> tuple[int, os.stat_result, bool]:
419
+ created = False
420
+ try:
421
+ expected = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
422
+ except FileNotFoundError:
423
+ if not create:
424
+ raise
425
+ try:
426
+ os.mkdir(name, 0o700, dir_fd=parent_fd)
427
+ except FileExistsError:
428
+ pass
429
+ else:
430
+ created = True
431
+ expected = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
432
+ except OSError:
433
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent cannot be inspected") from None
434
+ _validate_directory(expected)
435
+ try:
436
+ parent_before = os.fstat(parent_fd)
437
+ except OSError:
438
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent cannot be verified safely") from None
439
+ _validate_directory(parent_before)
440
+ try:
441
+ descriptor = os.open(name, _directory_flags(), dir_fd=parent_fd)
442
+ except OSError:
443
+ if created:
444
+ _remove_created_directory(parent_fd, name, expected)
445
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent cannot be opened safely") from None
446
+ try:
447
+ actual = os.fstat(descriptor)
448
+ _validate_directory(actual)
449
+ parent_after = os.fstat(parent_fd)
450
+ _validate_directory(parent_after)
451
+ # A directory's ctime legitimately changes when entries below it are
452
+ # created or removed. The parent descriptor is the independent pin:
453
+ # if the child pathname was replaced, the parent's directory metadata
454
+ # changes even when Linux reuses the child's inode. Permit only the
455
+ # child-local ctime drift that leaves that parent pin unchanged.
456
+ if not _same_immutable_identity(parent_before, parent_after) or (
457
+ not _same_creation_identity(expected, actual) and not _same_identity(expected, actual)
458
+ ):
459
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during open")
460
+ return descriptor, expected, created
461
+ except Exception:
462
+ os.close(descriptor)
463
+ if created:
464
+ _remove_created_directory(parent_fd, name, expected)
465
+ raise
466
+
467
+
468
+ def _cleanup_created_directories(
469
+ created_components: list[tuple[int, str, os.stat_result]],
470
+ ) -> None:
471
+ for parent_fd, name, expected in reversed(created_components):
472
+ _remove_created_directory(parent_fd, name, expected)
473
+
474
+
475
+ def _verify_pinned_ancestry(
476
+ base: Path,
477
+ expected_components: tuple[os.stat_result, ...],
478
+ pinned_fds: tuple[int, ...] | None = None,
479
+ ) -> os.stat_result:
480
+ if pinned_fds is not None:
481
+ if len(pinned_fds) != len(expected_components):
482
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation parent ancestry is incomplete")
483
+ for index, descriptor in enumerate(pinned_fds):
484
+ path = base.joinpath(*_STATE_COMPONENTS[:index])
485
+ # A competing writer can create the log between these metadata reads.
486
+ # Retry once against the same descriptor; persistent drift fails closed.
487
+ for _ in range(2):
488
+ try:
489
+ current = os.stat(path, follow_symlinks=False)
490
+ actual = os.fstat(descriptor)
491
+ except FileNotFoundError:
492
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during setup") from None
493
+ except OSError:
494
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent cannot be verified safely") from None
495
+ _validate_directory(current)
496
+ _validate_directory(actual)
497
+ if _same_immutable_identity(current, actual):
498
+ break
499
+ else:
500
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during setup")
501
+ return os.fstat(pinned_fds[-1])
502
+
503
+ parent_fd: int | None = None
504
+ try:
505
+ parent_fd = os.open(base, _directory_flags())
506
+ actual_base = os.fstat(parent_fd)
507
+ _validate_directory(actual_base)
508
+ if not _same_creation_identity(expected_components[0], actual_base):
509
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during setup")
510
+ for index, name in enumerate(_STATE_COMPONENTS[: len(expected_components) - 1], start=1):
511
+ child_fd, observed, _ = _open_child_directory(parent_fd, name, create=False)
512
+ try:
513
+ actual = os.fstat(child_fd)
514
+ _validate_directory(actual)
515
+ if (
516
+ not _same_creation_identity(expected_components[index], observed)
517
+ or not _same_creation_identity(expected_components[index], actual)
518
+ ):
519
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during setup")
520
+ except Exception:
521
+ os.close(child_fd)
522
+ raise
523
+ os.close(parent_fd)
524
+ parent_fd = child_fd
525
+ return os.fstat(parent_fd)
526
+ except FileNotFoundError:
527
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during setup") from None
528
+ except OSError:
529
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent cannot be verified safely") from None
530
+ finally:
531
+ if parent_fd is not None:
532
+ os.close(parent_fd)
533
+
534
+
535
+ @contextmanager
536
+ def _state_directory(root: str | os.PathLike[str], *, create: bool, committed=None):
537
+ base = _canonical_workspace(root, missing_ok=not create)
538
+ expected_base = os.stat(base, follow_symlinks=False)
539
+ _validate_directory(expected_base)
540
+ try:
541
+ current_fd = os.open(base, _directory_flags())
542
+ except OSError:
543
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace cannot be opened safely") from None
544
+ workspace_fd: int | None = None
545
+ open_fds = [current_fd]
546
+ created_components: list[tuple[int, str, os.stat_result]] = []
547
+ setup_lock_held = False
548
+ setup_complete = False
549
+ pending_error: BaseException | None = None
550
+ try:
551
+ actual_base = os.fstat(current_fd)
552
+ if not _same_creation_identity(expected_base, actual_base):
553
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace changed during open")
554
+ try:
555
+ workspace_fd = os.dup(current_fd)
556
+ except OSError:
557
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace cannot be pinned safely") from None
558
+
559
+ def state_tree_exists() -> bool:
560
+ for index in range(1, len(_STATE_COMPONENTS) + 1):
561
+ try:
562
+ state_stat = os.stat(base.joinpath(*_STATE_COMPONENTS[:index]), follow_symlinks=False)
563
+ except OSError:
564
+ return False
565
+ if not stat.S_ISDIR(state_stat.st_mode):
566
+ return False
567
+ return True
568
+
569
+ needs_setup_lock = create and not state_tree_exists()
570
+ if needs_setup_lock and fcntl is not None and _SETUP_FLOCK is not None:
571
+ deadline = time.monotonic() + _LOCK_WAIT_SECONDS
572
+ while True:
573
+ try:
574
+ _SETUP_FLOCK(workspace_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
575
+ setup_lock_held = True
576
+ break
577
+ except OSError as error:
578
+ if error.errno not in (errno.EACCES, errno.EAGAIN):
579
+ raise SkillObserverError(
580
+ "OBSERVER_LOG_UNSAFE", "the observation workspace cannot be serialized safely"
581
+ ) from None
582
+ if state_tree_exists():
583
+ break
584
+ remaining = deadline - time.monotonic()
585
+ if remaining <= 0:
586
+ break
587
+ time.sleep(min(_LOCK_POLL_SECONDS, remaining))
588
+ expected_state = expected_base
589
+ expected_components = [expected_base]
590
+ for name in _STATE_COMPONENTS:
591
+ parent_fd = current_fd
592
+ child_fd, expected_state, created = _open_child_directory(parent_fd, name, create=create)
593
+ open_fds.append(child_fd)
594
+ expected_components[-1] = os.fstat(parent_fd)
595
+ current_fd = child_fd
596
+ expected_components.append(expected_state)
597
+ if created:
598
+ created_components.append((parent_fd, name, expected_state))
599
+ _verify_pinned_ancestry(base, tuple(expected_components), tuple(open_fds))
600
+ pinned_components = tuple(expected_components)
601
+ pinned_fds = tuple(open_fds)
602
+
603
+ def verify_state() -> None:
604
+ _verify_pinned_state(base, current_fd, pinned_components, pinned_fds)
605
+
606
+ verify_state()
607
+ setup_complete = True
608
+ if setup_lock_held:
609
+ try:
610
+ _SETUP_FLOCK(workspace_fd, fcntl.LOCK_UN)
611
+ except OSError:
612
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace lock could not be released") from None
613
+ setup_lock_held = False
614
+ try:
615
+ yield current_fd, verify_state, workspace_fd
616
+ except BaseException as error:
617
+ pending_error = error
618
+ try:
619
+ verify_state()
620
+ except Exception:
621
+ pass
622
+ raise
623
+ finally:
624
+ try:
625
+ if not setup_complete:
626
+ _cleanup_created_directories(created_components)
627
+ finally:
628
+ if setup_lock_held and workspace_fd is not None:
629
+ try:
630
+ _SETUP_FLOCK(workspace_fd, fcntl.LOCK_UN)
631
+ except OSError:
632
+ pass
633
+ close_error: OSError | None = None
634
+ for descriptor in reversed(open_fds):
635
+ try:
636
+ os.close(descriptor)
637
+ except OSError as error:
638
+ close_error = close_error or error
639
+ if workspace_fd is not None:
640
+ try:
641
+ os.close(workspace_fd)
642
+ except OSError as error:
643
+ close_error = close_error or error
644
+ if close_error is not None:
645
+ if committed is not None and committed():
646
+ raise _committed_cleanup_error() from None
647
+ if _is_unknown_state(pending_error):
648
+ raise pending_error
649
+ raise close_error
650
+
651
+
652
+ def _verify_pinned_state(
653
+ base: Path,
654
+ directory_fd: int,
655
+ expected_components: tuple[os.stat_result, ...],
656
+ pinned_fds: tuple[int, ...] | None = None,
657
+ ) -> None:
658
+ reopened = _verify_pinned_ancestry(base, expected_components, pinned_fds)
659
+ try:
660
+ pinned = os.fstat(directory_fd)
661
+ _validate_directory(pinned)
662
+ except OSError:
663
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during write") from None
664
+ if pinned_fds is None:
665
+ if not _same_creation_identity(expected_components[-1], reopened) or not _same_creation_identity(
666
+ reopened, pinned
667
+ ):
668
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during write")
669
+ elif directory_fd != pinned_fds[-1]:
670
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during write")
671
+
672
+
673
+ def _remove_created_path_directory(path: Path, expected: os.stat_result) -> None:
674
+ try:
675
+ current = os.stat(path, follow_symlinks=False)
676
+ except FileNotFoundError:
677
+ return
678
+ except OSError:
679
+ raise SkillObserverError(
680
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent cannot be inspected for removal"
681
+ ) from None
682
+ _validate_directory(current)
683
+ if not _same_creation_identity(expected, current):
684
+ raise SkillObserverError(
685
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent changed before removal"
686
+ )
687
+ try:
688
+ os.rmdir(path)
689
+ except OSError:
690
+ raise SkillObserverError(
691
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent could not be removed"
692
+ ) from None
693
+ try:
694
+ os.stat(path, follow_symlinks=False)
695
+ except FileNotFoundError:
696
+ return
697
+ except OSError:
698
+ raise SkillObserverError(
699
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent could not be verified after removal"
700
+ ) from None
701
+ raise SkillObserverError(
702
+ "OBSERVER_LOG_ROLLBACK_FAILED", "a new observation parent remained after removal"
703
+ )
704
+
705
+
706
+ @contextmanager
707
+ def _path_state_directory(root: str | os.PathLike[str], *, create: bool):
708
+ pin_directories = not _is_windows_runtime() and all(
709
+ hasattr(os, name) for name in ("O_DIRECTORY", "O_NOFOLLOW")
710
+ )
711
+ setup_lock_held = False
712
+ if pin_directories:
713
+ _PATH_SETUP_LOCK.acquire()
714
+ setup_lock_held = True
715
+
716
+ def release_setup_lock() -> None:
717
+ nonlocal setup_lock_held
718
+ if setup_lock_held:
719
+ _PATH_SETUP_LOCK.release()
720
+ setup_lock_held = False
721
+
722
+ try:
723
+ base = _canonical_workspace(root, missing_ok=not create)
724
+ try:
725
+ expected_base = os.stat(base, follow_symlinks=False)
726
+ except OSError:
727
+ raise SkillObserverError(
728
+ "OBSERVER_LOG_UNSAFE", "the observation workspace cannot be inspected"
729
+ ) from None
730
+ _validate_directory(expected_base)
731
+ except BaseException:
732
+ release_setup_lock()
733
+ raise
734
+ current = base
735
+ expected_paths: list[tuple[Path, os.stat_result]] = [(base, expected_base)]
736
+ created_components: list[tuple[Path, os.stat_result]] = []
737
+ created_pinned_components: list[tuple[int, str, os.stat_result, int]] = []
738
+ pinned_fds: list[int] = []
739
+ pinned_baselines: tuple[os.stat_result, ...] = ()
740
+ setup_complete = False
741
+
742
+ # POSIX hosts without advisory-lock support still have directory descriptors.
743
+ # Keep those descriptors pinned so directory ctime changes caused by creating
744
+ # the lock/log are compared against the live object, not a stale pre-write
745
+ # stat. Windows uses the path fallback because its directory-open flags and
746
+ # dir_fd APIs are not available.
747
+ if pin_directories:
748
+ try:
749
+ base_fd = os.open(base, _directory_flags())
750
+ actual_base = os.fstat(base_fd)
751
+ _validate_directory(actual_base)
752
+ if not _same_immutable_identity(expected_base, actual_base):
753
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation workspace changed during open")
754
+ except SkillObserverError:
755
+ try:
756
+ os.close(base_fd)
757
+ except (NameError, OSError):
758
+ pass
759
+ release_setup_lock()
760
+ raise
761
+ except OSError:
762
+ try:
763
+ os.close(base_fd)
764
+ except (NameError, OSError):
765
+ pass
766
+ release_setup_lock()
767
+ raise SkillObserverError(
768
+ "OBSERVER_LOG_UNSAFE", "the observation workspace cannot be opened safely"
769
+ ) from None
770
+ pinned_fds.append(base_fd)
771
+
772
+ def verify_state() -> None:
773
+ if pinned_fds:
774
+ if len(pinned_fds) != len(expected_paths) or len(pinned_baselines) != len(expected_paths):
775
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation parent ancestry is incomplete")
776
+ for index, (path, expected) in enumerate(expected_paths):
777
+ try:
778
+ actual = os.stat(path, follow_symlinks=False)
779
+ except OSError:
780
+ raise SkillObserverError(
781
+ "OBSERVER_LOG_UNSAFE", "an observation parent changed during write"
782
+ ) from None
783
+ _validate_directory(actual)
784
+ baseline = pinned_baselines[index]
785
+ _validate_directory(baseline)
786
+ if index < len(expected_paths) - 1:
787
+ # Ancestor ctime is stable after setup; a replacement of
788
+ # its child changes that parent entry and is rejected.
789
+ identity_matches = _same_immutable_identity(baseline, actual)
790
+ else:
791
+ # The leaf directory owns the lock/log, so its ctime is
792
+ # expected to move during a legitimate write.
793
+ expected_birthtime = _birthtime(expected)
794
+ actual_birthtime = _birthtime(actual)
795
+ birthtime_matches = (
796
+ (expected_birthtime is None and actual_birthtime is None)
797
+ or (expected_birthtime is not None and expected_birthtime == actual_birthtime)
798
+ )
799
+ identity_matches = _same_identity(expected, actual) and birthtime_matches
800
+ if not identity_matches:
801
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during write")
802
+ return
803
+ for path, expected in expected_paths:
804
+ try:
805
+ actual = os.stat(path, follow_symlinks=False)
806
+ except OSError:
807
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during write") from None
808
+ _validate_directory(actual)
809
+ if not _same_creation_identity(expected, actual):
810
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "an observation parent changed during write")
811
+
812
+ try:
813
+ for name in _STATE_COMPONENTS:
814
+ child = current / name
815
+ if pinned_fds:
816
+ parent_fd = pinned_fds[-1]
817
+ child_fd, expected, created = _open_child_directory(parent_fd, name, create=create)
818
+ pinned_fds.append(child_fd)
819
+ if created:
820
+ created_pinned_components.append((parent_fd, name, expected, child_fd))
821
+ else:
822
+ created = False
823
+ try:
824
+ expected = os.stat(child, follow_symlinks=False)
825
+ except FileNotFoundError:
826
+ if not create:
827
+ raise
828
+ try:
829
+ os.mkdir(child, 0o700)
830
+ except FileExistsError:
831
+ pass
832
+ else:
833
+ created = True
834
+ expected = os.stat(child, follow_symlinks=False)
835
+ except OSError:
836
+ raise SkillObserverError(
837
+ "OBSERVER_LOG_UNSAFE", "an observation parent cannot be inspected"
838
+ ) from None
839
+ _validate_directory(expected)
840
+ if created:
841
+ if not pinned_fds:
842
+ created_components.append((child, expected))
843
+ current = child
844
+ expected_paths.append((current, expected))
845
+ pinned_baselines = tuple(os.fstat(descriptor) for descriptor in pinned_fds)
846
+ for baseline in pinned_baselines:
847
+ _validate_directory(baseline)
848
+ verify_state()
849
+ setup_complete = True
850
+ release_setup_lock()
851
+ yield current, verify_state
852
+ finally:
853
+ try:
854
+ if not setup_complete:
855
+ if pinned_fds:
856
+ for parent_fd, name, expected, child_fd in reversed(created_pinned_components):
857
+ _remove_created_directory(parent_fd, name, expected, pinned_fd=child_fd)
858
+ else:
859
+ for path, expected in reversed(created_components):
860
+ _remove_created_path_directory(path, expected)
861
+ finally:
862
+ for descriptor in reversed(pinned_fds):
863
+ try:
864
+ os.close(descriptor)
865
+ except OSError:
866
+ pass
867
+ release_setup_lock()
868
+
869
+
870
+ def _path_regular_stat(path: Path, *, lock: bool, missing_ok: bool) -> os.stat_result | None:
871
+ try:
872
+ value = os.stat(path, follow_symlinks=False)
873
+ except FileNotFoundError:
874
+ if missing_ok:
875
+ return None
876
+ raise
877
+ except OSError:
878
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_UNSAFE"
879
+ raise SkillObserverError(code, "the observation path cannot be inspected") from None
880
+ _validate_regular(value, lock=lock)
881
+ return value
882
+
883
+
884
+ def _open_path_regular(
885
+ directory: Path,
886
+ name: str,
887
+ *,
888
+ lock: bool,
889
+ append: bool,
890
+ create: bool,
891
+ ) -> tuple[int, bool, os.stat_result]:
892
+ path = directory / name
893
+ expected = _path_regular_stat(path, lock=lock, missing_ok=True)
894
+ flags = os.O_RDWR | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
895
+ if append:
896
+ flags |= os.O_APPEND
897
+ if expected is None:
898
+ if not create:
899
+ raise FileNotFoundError(name)
900
+ flags |= os.O_CREAT | os.O_EXCL
901
+ try:
902
+ descriptor = os.open(path, flags, 0o600)
903
+ except FileExistsError:
904
+ if expected is None and create:
905
+ return _open_path_regular(directory, name, lock=lock, append=append, create=False)
906
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation path changed during creation") from None
907
+ except OSError:
908
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_UNSAFE"
909
+ raise SkillObserverError(code, "the observation path cannot be opened safely") from None
910
+ try:
911
+ actual = os.fstat(descriptor)
912
+ _validate_regular(actual, lock=lock)
913
+ current = _path_regular_stat(path, lock=lock, missing_ok=False)
914
+ if current is None or not _same_immutable_identity(actual, current):
915
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_CHANGED"
916
+ raise SkillObserverError(code, "the observation path changed during open")
917
+ if expected is not None and not _same_immutable_identity(expected, actual):
918
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_CHANGED"
919
+ raise SkillObserverError(code, "the observation path changed during open")
920
+ return descriptor, expected is None, actual
921
+ except BaseException:
922
+ os.close(descriptor)
923
+ raise
924
+
925
+
926
+ def _verify_path_log(directory: Path, descriptor: int, expected: os.stat_result) -> None:
927
+ try:
928
+ current = _path_regular_stat(directory / _LOG_NAME, lock=False, missing_ok=False)
929
+ actual = os.fstat(descriptor)
930
+ except FileNotFoundError:
931
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write") from None
932
+ _validate_regular(actual)
933
+ if current is None or not _same_identity(expected, actual) or not _same_identity(expected, current):
934
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write")
935
+
936
+
937
+ def _unlink_created_path_log(directory: Path, descriptor: int, expected: os.stat_result) -> None:
938
+ path = directory / _LOG_NAME
939
+ try:
940
+ current = _path_regular_stat(path, lock=False, missing_ok=False)
941
+ actual = os.fstat(descriptor)
942
+ _validate_regular(actual)
943
+ if current is None or not _same_identity(expected, actual) or not _same_identity(expected, current):
944
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the new observation log changed before removal")
945
+ os.unlink(path)
946
+ if _path_regular_stat(path, lock=False, missing_ok=True) is not None:
947
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the new observation log could not be removed")
948
+ except (OSError, SkillObserverError):
949
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the new observation log could not be removed") from None
950
+
951
+
952
+ def _read_path_lock(path: Path) -> tuple[os.stat_result, dict[str, Any]]:
953
+ before = _path_regular_stat(path, lock=True, missing_ok=False)
954
+ if before is None:
955
+ raise FileNotFoundError(path)
956
+ descriptor: int | None = None
957
+ try:
958
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
959
+ descriptor = os.open(path, flags)
960
+ actual = os.fstat(descriptor)
961
+ if actual.st_nlink == 0:
962
+ raise SkillObserverError("OBSERVER_LOCK_CHANGED", "the observation lock disappeared during read")
963
+ _validate_regular(actual, lock=True)
964
+ if not _same_immutable_identity(before, actual):
965
+ raise SkillObserverError("OBSERVER_LOCK_CHANGED", "the observation lock changed during read")
966
+ if actual.st_size > _LOCK_METADATA_BYTES:
967
+ raise SkillObserverError("OBSERVER_LOCK_MALFORMED", "the observation lock metadata is malformed")
968
+ os.lseek(descriptor, 0, os.SEEK_SET)
969
+ raw = os.read(descriptor, _LOCK_METADATA_BYTES + 1)
970
+ if len(raw) > _LOCK_METADATA_BYTES:
971
+ raise SkillObserverError("OBSERVER_LOCK_MALFORMED", "the observation lock metadata is malformed")
972
+ after = os.fstat(descriptor)
973
+ if after.st_nlink == 0:
974
+ raise SkillObserverError("OBSERVER_LOCK_CHANGED", "the observation lock disappeared during read")
975
+ _validate_regular(after, lock=True)
976
+ if not _same_immutable_identity(before, after) or after.st_size != actual.st_size:
977
+ raise SkillObserverError("OBSERVER_LOCK_CHANGED", "the observation lock changed during read")
978
+ except FileNotFoundError:
979
+ raise
980
+ except SkillObserverError:
981
+ raise
982
+ except (OSError, UnicodeDecodeError, ValueError, TypeError):
983
+ raise SkillObserverError("OBSERVER_LOCK_MALFORMED", "the observation lock metadata is malformed") from None
984
+ finally:
985
+ if descriptor is not None:
986
+ os.close(descriptor)
987
+ try:
988
+ owner = json.loads(raw.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys)
989
+ except (UnicodeDecodeError, ValueError, TypeError):
990
+ raise SkillObserverError("OBSERVER_LOCK_MALFORMED", "the observation lock metadata is malformed") from None
991
+ if (
992
+ not isinstance(owner, dict)
993
+ or set(owner) != {"pid", "token"}
994
+ or isinstance(owner.get("pid"), bool)
995
+ or not isinstance(owner.get("pid"), int)
996
+ or owner["pid"] <= 0
997
+ or not isinstance(owner.get("token"), str)
998
+ or not re.fullmatch(r"[0-9a-f]{32}", owner["token"])
999
+ ):
1000
+ raise SkillObserverError("OBSERVER_LOCK_MALFORMED", "the observation lock metadata is malformed")
1001
+ return before, owner
1002
+
1003
+
1004
+ def _lock_owner_is_alive(pid: int) -> bool:
1005
+ try:
1006
+ os.kill(pid, 0)
1007
+ except ProcessLookupError:
1008
+ return False
1009
+ except PermissionError:
1010
+ return True
1011
+ except OSError as error:
1012
+ if error.errno == errno.ESRCH or getattr(error, "winerror", None) in (87, 128):
1013
+ return False
1014
+ return True
1015
+ return True
1016
+
1017
+
1018
+ def _remove_path_lock(path: Path, expected: os.stat_result, token: str | None = None) -> bool:
1019
+ current = _path_regular_stat(path, lock=True, missing_ok=True)
1020
+ if current is None:
1021
+ return True
1022
+ if not _same_immutable_identity(expected, current):
1023
+ return False
1024
+ if token is not None:
1025
+ _, owner = _read_path_lock(path)
1026
+ if owner["token"] != token:
1027
+ return False
1028
+ try:
1029
+ os.unlink(path)
1030
+ except FileNotFoundError:
1031
+ return True
1032
+ except OSError:
1033
+ raise SkillObserverError("OBSERVER_LOCK_CLEANUP_FAILED", "the observation lock could not be removed") from None
1034
+ replacement = _path_regular_stat(path, lock=True, missing_ok=True)
1035
+ if replacement is None:
1036
+ return True
1037
+ if not _same_identity(expected, replacement):
1038
+ return True
1039
+ # Some filesystems can reuse an inode immediately after unlink. The token
1040
+ # disambiguates a valid replacement without ever removing that replacement;
1041
+ # an equal or unreadable token remains an unverifiable cleanup and fails closed.
1042
+ if token is None:
1043
+ return False
1044
+ try:
1045
+ _, owner = _read_path_lock(path)
1046
+ except FileNotFoundError:
1047
+ return True
1048
+ except SkillObserverError:
1049
+ return False
1050
+ return owner["token"] != token
1051
+
1052
+
1053
+ def _path_lock_is_stale(value: os.stat_result) -> bool:
1054
+ return time.time() - value.st_mtime >= _LOCK_STALE_SECONDS
1055
+
1056
+
1057
+ def _read_path_lock_after_creation_race(path: Path) -> tuple[os.stat_result, dict[str, Any]]:
1058
+ try:
1059
+ return _read_path_lock(path)
1060
+ except SkillObserverError as error:
1061
+ if error.code != "OBSERVER_LOCK_MALFORMED":
1062
+ raise
1063
+ time.sleep(_LOCK_POLL_SECONDS)
1064
+ try:
1065
+ return _read_path_lock(path)
1066
+ except SkillObserverError as retry_error:
1067
+ if retry_error.code == "OBSERVER_LOCK_MALFORMED":
1068
+ raise error
1069
+ raise
1070
+
1071
+
1072
+ def _write_path_lock(path: Path, token: str) -> tuple[int, os.stat_result]:
1073
+ metadata = json.dumps({"pid": os.getpid(), "token": token}, sort_keys=True).encode("utf-8")
1074
+ try:
1075
+ descriptor = os.open(
1076
+ path,
1077
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0),
1078
+ 0o600,
1079
+ )
1080
+ except FileExistsError:
1081
+ raise
1082
+ except OSError:
1083
+ raise SkillObserverError("OBSERVER_LOCK_FAILED", "the observation lock could not be created") from None
1084
+ try:
1085
+ written = os.write(descriptor, metadata)
1086
+ if written != len(metadata):
1087
+ raise SkillObserverError("OBSERVER_LOCK_FAILED", "the observation lock metadata was incomplete")
1088
+ os.fsync(descriptor)
1089
+ return descriptor, os.fstat(descriptor)
1090
+ except BaseException:
1091
+ try:
1092
+ os.close(descriptor)
1093
+ finally:
1094
+ try:
1095
+ os.unlink(path)
1096
+ except FileNotFoundError:
1097
+ pass
1098
+ raise
1099
+
1100
+
1101
+ @contextmanager
1102
+ def _path_writer_lock(directory: Path, *, verify=None, committed=None):
1103
+ path = directory / _LOCK_NAME
1104
+ token = os.urandom(16).hex()
1105
+ descriptor: int | None = None
1106
+ acquired = False
1107
+ pending_error: BaseException | None = None
1108
+ deadline = time.monotonic() + _LOCK_WAIT_SECONDS
1109
+ try:
1110
+ while True:
1111
+ if verify is not None:
1112
+ verify()
1113
+ try:
1114
+ descriptor, created = _write_path_lock(path, token)
1115
+ acquired = True
1116
+ _validate_regular(created, lock=True)
1117
+ if verify is not None:
1118
+ verify()
1119
+ break
1120
+ except FileExistsError:
1121
+ try:
1122
+ lock_stat, owner = _read_path_lock_after_creation_race(path)
1123
+ except FileNotFoundError:
1124
+ continue
1125
+ except SkillObserverError as error:
1126
+ if error.code == "OBSERVER_LOCK_CHANGED":
1127
+ continue
1128
+ raise
1129
+ if _path_lock_is_stale(lock_stat) and not _lock_owner_is_alive(owner["pid"]):
1130
+ if _remove_path_lock(path, lock_stat, owner["token"]):
1131
+ continue
1132
+ remaining = deadline - time.monotonic()
1133
+ if remaining <= 0:
1134
+ raise SkillObserverError("OBSERVER_LOCK_TIMEOUT", "the observation lock wait expired") from None
1135
+ time.sleep(min(_LOCK_POLL_SECONDS, remaining))
1136
+ try:
1137
+ yield
1138
+ except BaseException as error:
1139
+ pending_error = error
1140
+ raise
1141
+ finally:
1142
+ close_error: OSError | None = None
1143
+ if descriptor is not None:
1144
+ try:
1145
+ os.close(descriptor)
1146
+ except OSError as error:
1147
+ close_error = error
1148
+ if acquired:
1149
+ try:
1150
+ current = _path_regular_stat(path, lock=True, missing_ok=False)
1151
+ if current is None or not _remove_path_lock(path, current, token):
1152
+ raise SkillObserverError("OBSERVER_LOCK_CLEANUP_FAILED", "the observation lock changed before removal")
1153
+ except FileNotFoundError:
1154
+ raise SkillObserverError("OBSERVER_LOCK_CLEANUP_FAILED", "the observation lock disappeared before removal") from None
1155
+ except SkillObserverError:
1156
+ if committed is not None and committed():
1157
+ raise _committed_cleanup_error() from None
1158
+ if _is_unknown_state(pending_error):
1159
+ raise pending_error
1160
+ raise
1161
+ except OSError:
1162
+ if committed is not None and committed():
1163
+ raise _committed_cleanup_error() from None
1164
+ if _is_unknown_state(pending_error):
1165
+ raise pending_error
1166
+ raise SkillObserverError("OBSERVER_LOCK_CLEANUP_FAILED", "the observation lock could not be removed") from None
1167
+ if close_error is not None:
1168
+ if committed is not None and committed():
1169
+ raise _committed_cleanup_error() from None
1170
+ if _is_unknown_state(pending_error):
1171
+ raise pending_error
1172
+ raise close_error
1173
+
1174
+
1175
+ def _open_regular_at(
1176
+ directory_fd: int,
1177
+ name: str,
1178
+ *,
1179
+ lock: bool,
1180
+ append: bool,
1181
+ create: bool,
1182
+ ) -> tuple[int, bool]:
1183
+ expected = _stat_regular_at(directory_fd, name, lock=lock, missing_ok=True)
1184
+ flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
1185
+ if append:
1186
+ flags |= os.O_APPEND
1187
+ if expected is None:
1188
+ if not create:
1189
+ raise FileNotFoundError(name)
1190
+ flags |= os.O_CREAT | os.O_EXCL
1191
+ try:
1192
+ descriptor = os.open(name, flags, 0o600, dir_fd=directory_fd)
1193
+ except FileExistsError:
1194
+ if expected is None and create:
1195
+ return _open_regular_at(directory_fd, name, lock=lock, append=append, create=False)
1196
+ raise SkillObserverError("OBSERVER_LOG_UNSAFE", "the observation path changed during creation") from None
1197
+ except OSError:
1198
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_UNSAFE"
1199
+ raise SkillObserverError(code, "the observation path cannot be opened safely") from None
1200
+ try:
1201
+ actual = os.fstat(descriptor)
1202
+ _validate_regular(actual, lock=lock)
1203
+ current = _stat_regular_at(directory_fd, name, lock=lock, missing_ok=False)
1204
+ if current is None or not _same_immutable_identity(actual, current):
1205
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_CHANGED"
1206
+ raise SkillObserverError(code, "the observation path changed during open")
1207
+ if expected is not None and not _same_immutable_identity(expected, actual):
1208
+ code = "OBSERVER_LOCK_UNSAFE" if lock else "OBSERVER_LOG_CHANGED"
1209
+ raise SkillObserverError(code, "the observation path changed during open")
1210
+ return descriptor, expected is None
1211
+ except Exception:
1212
+ os.close(descriptor)
1213
+ raise
1214
+
1215
+
1216
+ def _read_descriptor(descriptor: int) -> bytes:
1217
+ before = os.fstat(descriptor)
1218
+ _validate_regular(before)
1219
+ if before.st_size > MAX_LOG_BYTES:
1220
+ raise SkillObserverError("OBSERVER_LOG_SIZE_EXCEEDED", "the observation log exceeds its size limit")
1221
+ os.lseek(descriptor, 0, os.SEEK_SET)
1222
+ remaining = before.st_size
1223
+ chunks: list[bytes] = []
1224
+ while remaining:
1225
+ chunk = os.read(descriptor, min(65536, remaining))
1226
+ if not chunk:
1227
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during read")
1228
+ chunks.append(chunk)
1229
+ remaining -= len(chunk)
1230
+ if os.read(descriptor, 1):
1231
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during read")
1232
+ after = os.fstat(descriptor)
1233
+ _validate_regular(after)
1234
+ if not _same_identity(before, after) or after.st_size != before.st_size:
1235
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during read")
1236
+ return b"".join(chunks)
1237
+
1238
+
1239
+ def _verify_log_path(directory_fd: int, descriptor: int, expected: os.stat_result) -> None:
1240
+ try:
1241
+ current = _stat_regular_at(directory_fd, _LOG_NAME, lock=False, missing_ok=False)
1242
+ actual = os.fstat(descriptor)
1243
+ except FileNotFoundError:
1244
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write") from None
1245
+ _validate_regular(actual)
1246
+ if current is None or not _same_identity(expected, actual) or not _same_identity(expected, current):
1247
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write")
1248
+
1249
+
1250
+ def _unlink_created_log(directory_fd: int, descriptor: int, expected: os.stat_result) -> None:
1251
+ try:
1252
+ current = _stat_regular_at(directory_fd, _LOG_NAME, lock=False, missing_ok=False)
1253
+ actual = os.fstat(descriptor)
1254
+ _validate_regular(actual)
1255
+ if current is None or not _same_identity(expected, actual) or not _same_identity(expected, current):
1256
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the new observation log changed before removal")
1257
+ os.unlink(_LOG_NAME, dir_fd=directory_fd)
1258
+ if _stat_regular_at(directory_fd, _LOG_NAME, lock=False, missing_ok=True) is not None:
1259
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the new observation log could not be removed")
1260
+ except (OSError, SkillObserverError):
1261
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the new observation log could not be removed") from None
1262
+
1263
+
1264
+ def _sync_log(descriptor: int) -> None:
1265
+ try:
1266
+ os.fsync(descriptor)
1267
+ except (AttributeError, OSError):
1268
+ raise SkillObserverError("OBSERVER_LOG_FSYNC_FAILED", "the observation log could not be synchronized") from None
1269
+
1270
+
1271
+ def _rollback_log(directory_fd: int, descriptor: int, expected: os.stat_result, prior_length: int) -> None:
1272
+ try:
1273
+ os.ftruncate(descriptor, prior_length)
1274
+ _sync_log(descriptor)
1275
+ _verify_log_path(directory_fd, descriptor, expected)
1276
+ except (AttributeError, OSError, SkillObserverError):
1277
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the observation log could not be rolled back") from None
1278
+
1279
+
1280
+ def _cleanup_log_failure(
1281
+ directory_fd: int,
1282
+ descriptor: int,
1283
+ expected: os.stat_result,
1284
+ prior_length: int,
1285
+ *,
1286
+ created: bool,
1287
+ write_attempted: bool,
1288
+ ) -> None:
1289
+ rollback_error: SkillObserverError | None = None
1290
+ if write_attempted:
1291
+ try:
1292
+ _rollback_log(directory_fd, descriptor, expected, prior_length)
1293
+ except SkillObserverError as error:
1294
+ rollback_error = error
1295
+ if rollback_error is not None:
1296
+ raise _unknown_state_error() from None
1297
+ if created:
1298
+ _unlink_created_log(directory_fd, descriptor, expected)
1299
+
1300
+
1301
+ def _rollback_path_log(directory: Path, descriptor: int, expected: os.stat_result, prior_length: int) -> None:
1302
+ try:
1303
+ os.ftruncate(descriptor, prior_length)
1304
+ _sync_log(descriptor)
1305
+ _verify_path_log(directory, descriptor, expected)
1306
+ except (AttributeError, OSError, SkillObserverError):
1307
+ raise SkillObserverError("OBSERVER_LOG_ROLLBACK_FAILED", "the observation log could not be rolled back") from None
1308
+
1309
+
1310
+ def _cleanup_path_log_failure(
1311
+ directory: Path,
1312
+ descriptor: int,
1313
+ expected: os.stat_result,
1314
+ prior_length: int,
1315
+ *,
1316
+ created: bool,
1317
+ write_attempted: bool,
1318
+ ) -> None:
1319
+ rollback_error: BaseException | None = None
1320
+ if write_attempted:
1321
+ try:
1322
+ _rollback_path_log(directory, descriptor, expected, prior_length)
1323
+ except BaseException as error:
1324
+ rollback_error = error
1325
+ if rollback_error is not None:
1326
+ raise _unknown_state_error() from None
1327
+ if created:
1328
+ _unlink_created_path_log(directory, descriptor, expected)
1329
+
1330
+
1331
+ def _validate_existing_jsonl(raw: bytes) -> None:
1332
+ try:
1333
+ text = raw.decode("utf-8")
1334
+ except UnicodeDecodeError:
1335
+ raise SkillObserverError("OBSERVER_LOG_MALFORMED", "the existing observation log is not valid UTF-8") from None
1336
+ if not text:
1337
+ return
1338
+ lines = text.split("\n")
1339
+ if text.endswith("\n"):
1340
+ lines.pop()
1341
+ for line in lines:
1342
+ try:
1343
+ if not line.strip():
1344
+ raise ValueError("blank JSONL record")
1345
+ record = json.loads(line, object_pairs_hook=_reject_duplicate_keys)
1346
+ _validate_observation(record, persisted=True)
1347
+ except (ValueError, SkillObserverError):
1348
+ raise SkillObserverError("OBSERVER_LOG_MALFORMED", "the existing observation log is malformed") from None
1349
+
1350
+
1351
+ @contextmanager
1352
+ def _writer_lock(workspace_fd: int, *, verify=None, committed=None):
1353
+ if fcntl is None:
1354
+ raise SkillObserverError("OBSERVER_LOCK_UNAVAILABLE", "bounded writer locking is unavailable")
1355
+ pending_error: BaseException | None = None
1356
+ try:
1357
+ descriptor = os.open(".", _directory_flags(), dir_fd=workspace_fd)
1358
+ except OSError:
1359
+ raise SkillObserverError("OBSERVER_LOCK_UNAVAILABLE", "workspace directory locking is unavailable") from None
1360
+ try:
1361
+ deadline = time.monotonic() + _LOCK_WAIT_SECONDS
1362
+ while True:
1363
+ try:
1364
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
1365
+ acquired = True
1366
+ break
1367
+ except OSError as error:
1368
+ if error.errno not in (errno.EACCES, errno.EAGAIN):
1369
+ raise SkillObserverError("OBSERVER_LOCK_FAILED", "the observation lock failed") from None
1370
+ remaining = deadline - time.monotonic()
1371
+ if remaining <= 0:
1372
+ raise SkillObserverError("OBSERVER_LOCK_TIMEOUT", "the observation lock wait expired") from None
1373
+ time.sleep(min(_LOCK_POLL_SECONDS, remaining))
1374
+ if verify is not None:
1375
+ verify()
1376
+ try:
1377
+ yield
1378
+ except BaseException as error:
1379
+ pending_error = error
1380
+ raise
1381
+ finally:
1382
+ try:
1383
+ os.close(descriptor)
1384
+ except OSError:
1385
+ if committed is not None and committed():
1386
+ raise _committed_cleanup_error() from None
1387
+ if _is_unknown_state(pending_error):
1388
+ raise pending_error
1389
+ raise
1390
+
1391
+
1392
+ def _record_observation_path(root: str | os.PathLike[str], record: dict, payload: bytes) -> dict:
1393
+ committed = False
1394
+ with _path_state_directory(root, create=True) as (directory, verify_state):
1395
+ descriptor: int | None = None
1396
+ opened: os.stat_result | None = None
1397
+ created = False
1398
+ prior_length = 0
1399
+ write_attempted = False
1400
+ primary_error: BaseException | None = None
1401
+ try:
1402
+ verify_state()
1403
+ with _path_writer_lock(directory, verify=verify_state, committed=lambda: committed):
1404
+ try:
1405
+ descriptor, created, opened = _open_path_regular(
1406
+ directory, _LOG_NAME, lock=False, append=True, create=True
1407
+ )
1408
+ existing = _read_descriptor(descriptor)
1409
+ prior_length = len(existing)
1410
+ _verify_path_log(directory, descriptor, opened)
1411
+ _validate_existing_jsonl(existing)
1412
+ separator = b"" if not existing or existing.endswith(b"\n") else b"\n"
1413
+ addition = separator + payload
1414
+ if len(existing) + len(addition) > MAX_LOG_BYTES:
1415
+ raise SkillObserverError(
1416
+ "OBSERVER_LOG_SIZE_EXCEEDED", "the new observation would exceed the log size limit"
1417
+ )
1418
+ verify_state()
1419
+ os.lseek(descriptor, 0, os.SEEK_END)
1420
+ write_attempted = True
1421
+ try:
1422
+ written = os.write(descriptor, addition)
1423
+ except OSError:
1424
+ raise SkillObserverError(
1425
+ "OBSERVER_LOG_WRITE_FAILED", "the observation record could not be written"
1426
+ ) from None
1427
+ if written != len(addition):
1428
+ raise SkillObserverError(
1429
+ "OBSERVER_LOG_WRITE_FAILED", "the observation record was not complete"
1430
+ )
1431
+ after = os.fstat(descriptor)
1432
+ _validate_regular(after)
1433
+ if after.st_size != len(existing) + len(addition):
1434
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write")
1435
+ _verify_path_log(directory, descriptor, after)
1436
+ _sync_log(descriptor)
1437
+ synced = os.fstat(descriptor)
1438
+ _validate_regular(synced)
1439
+ if synced.st_size != len(existing) + len(addition):
1440
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write")
1441
+ _verify_path_log(directory, descriptor, synced)
1442
+ verify_state()
1443
+ committed = True
1444
+ except SkillObserverError as error:
1445
+ primary_error = error
1446
+ if descriptor is not None and opened is not None:
1447
+ try:
1448
+ _cleanup_path_log_failure(
1449
+ directory,
1450
+ descriptor,
1451
+ opened,
1452
+ prior_length,
1453
+ created=created,
1454
+ write_attempted=write_attempted,
1455
+ )
1456
+ except SkillObserverError as cleanup_error:
1457
+ primary_error = cleanup_error
1458
+ raise
1459
+ raise
1460
+ except KeyboardInterrupt as error:
1461
+ primary_error = error
1462
+ if descriptor is not None and opened is not None:
1463
+ try:
1464
+ _cleanup_path_log_failure(
1465
+ directory,
1466
+ descriptor,
1467
+ opened,
1468
+ prior_length,
1469
+ created=created,
1470
+ write_attempted=write_attempted,
1471
+ )
1472
+ except SkillObserverError as cleanup_error:
1473
+ primary_error = cleanup_error
1474
+ raise
1475
+ raise
1476
+ except OSError as error:
1477
+ primary_error = error
1478
+ if descriptor is not None and opened is not None:
1479
+ try:
1480
+ _cleanup_path_log_failure(
1481
+ directory,
1482
+ descriptor,
1483
+ opened,
1484
+ prior_length,
1485
+ created=created,
1486
+ write_attempted=write_attempted,
1487
+ )
1488
+ except SkillObserverError as cleanup_error:
1489
+ primary_error = cleanup_error
1490
+ raise
1491
+ raise SkillObserverError(
1492
+ "OBSERVER_LOG_WRITE_FAILED", "the observation log could not be written"
1493
+ ) from None
1494
+ except BaseException as error:
1495
+ primary_error = error
1496
+ if descriptor is not None and opened is not None:
1497
+ try:
1498
+ _cleanup_path_log_failure(
1499
+ directory,
1500
+ descriptor,
1501
+ opened,
1502
+ prior_length,
1503
+ created=created,
1504
+ write_attempted=write_attempted,
1505
+ )
1506
+ except SkillObserverError as cleanup_error:
1507
+ primary_error = cleanup_error
1508
+ raise
1509
+ raise
1510
+ finally:
1511
+ if descriptor is not None:
1512
+ try:
1513
+ os.close(descriptor)
1514
+ except OSError:
1515
+ if committed:
1516
+ raise _committed_cleanup_error() from None
1517
+ if _is_unknown_state(primary_error):
1518
+ raise primary_error
1519
+ raise
1520
+ return record
1521
+
1522
+
1523
+ def _read_observations_path(root: str | os.PathLike[str]) -> list[dict]:
1524
+ try:
1525
+ with _path_state_directory(root, create=False) as (directory, verify_state):
1526
+ descriptor: int | None = None
1527
+ try:
1528
+ verify_state()
1529
+ with _path_writer_lock(directory, verify=verify_state):
1530
+ descriptor, _, opened = _open_path_regular(
1531
+ directory, _LOG_NAME, lock=False, append=False, create=False
1532
+ )
1533
+ raw = _read_descriptor(descriptor)
1534
+ _verify_path_log(directory, descriptor, opened)
1535
+ _validate_existing_jsonl(raw)
1536
+ finally:
1537
+ if descriptor is not None:
1538
+ os.close(descriptor)
1539
+ except FileNotFoundError:
1540
+ return []
1541
+ text = raw.decode("utf-8")
1542
+ records: list[dict] = []
1543
+ for line in text.split("\n"):
1544
+ if not line.strip():
1545
+ continue
1546
+ record = json.loads(line, object_pairs_hook=_reject_duplicate_keys)
1547
+ _validate_observation(record, persisted=True)
1548
+ records.append(record)
1549
+ return records
1550
+
1551
+
1552
+ def _flatten(value: str) -> str:
1553
+ """One bounded line. A newline would break the record format and is the
1554
+ simplest way to forge a second entry, so whitespace is collapsed."""
1555
+ return re.sub(r"\s+", " ", _CONTROL.sub(" ", value)).strip()[:_TEXT_CEILING]
1556
+
1557
+
1558
+ def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
1559
+ record = dict(pairs)
1560
+ if len(record) != len(pairs):
1561
+ raise ValueError("duplicate JSON object key")
1562
+ return record
1563
+
1564
+
1565
+ def _validate_observation(observation: Any, *, persisted: bool = False) -> None:
1566
+ if not isinstance(observation, dict):
1567
+ raise SkillObserverError("OBSERVER_SHAPE_INVALID", "an observation must be an object")
1568
+ allowed_fields = _PERSISTED_FIELDS if persisted else _ALLOWED_FIELDS
1569
+ unknown = sorted((field for field in observation if field not in allowed_fields), key=str)
1570
+ if unknown:
1571
+ raise SkillObserverError("OBSERVER_FIELD_UNKNOWN", _UNKNOWN_FIELD_MESSAGE)
1572
+ if persisted:
1573
+ missing = [field for field in _PERSISTED_FIELDS if field not in observation]
1574
+ if missing:
1575
+ raise SkillObserverError(
1576
+ "OBSERVER_FIELD_MISSING", "missing observation field: {0}".format(", ".join(missing))
1577
+ )
1578
+ if observation.get("signal") not in OBSERVER_SIGNALS:
1579
+ raise SkillObserverError(
1580
+ "OBSERVER_SIGNAL_UNKNOWN", "signal must be one of {0}".format(", ".join(OBSERVER_SIGNALS))
1581
+ )
1582
+ skill_id = observation.get("skill_id")
1583
+ if not isinstance(skill_id, str) or _SKILL_ID.fullmatch(skill_id) is None:
1584
+ raise SkillObserverError(
1585
+ "OBSERVER_SKILL_ID_INVALID", "skill_id must be a lowercase skill identifier"
1586
+ )
1587
+ for field in ("observed", "proposal"):
1588
+ value = observation.get(field)
1589
+ if not isinstance(value, str) or not value.strip():
1590
+ raise SkillObserverError("OBSERVER_TEXT_MISSING", f"{field} must be non-empty text")
1591
+ # The ingress filter must not invalidate records after its pattern set widens.
1592
+ if persisted:
1593
+ _normalization_surfaces(value)
1594
+ elif contains_secret(value):
1595
+ # The offending value is never echoed: an error message is one more place
1596
+ # a secret can land.
1597
+ raise SkillObserverError(
1598
+ "OBSERVER_SECRET_REFUSED",
1599
+ f"{field} looks like it carries a credential and was refused before any write",
1600
+ )
1601
+ flattened = _flatten(value)
1602
+ if not flattened:
1603
+ raise SkillObserverError("OBSERVER_TEXT_MISSING", f"{field} must contain usable text")
1604
+ if persisted and flattened != value:
1605
+ raise SkillObserverError("OBSERVER_TEXT_NONCANONICAL", f"{field} is not canonical text")
1606
+ if persisted and observation["applied"] is not False:
1607
+ raise SkillObserverError("OBSERVER_APPLIED_INVALID", "applied must be the boolean false")
1608
+
1609
+
1610
+ def record_observation(root: str | os.PathLike[str], observation: Any) -> dict:
1611
+ _validate_observation(observation)
1612
+
1613
+ record = {
1614
+ "signal": observation["signal"],
1615
+ "skill_id": observation["skill_id"],
1616
+ "observed": _flatten(observation["observed"]),
1617
+ "proposal": _flatten(observation["proposal"]),
1618
+ "applied": False,
1619
+ }
1620
+ payload = (json.dumps(record, sort_keys=True) + "\n").encode("utf-8")
1621
+ if len(payload) > MAX_LOG_BYTES:
1622
+ raise SkillObserverError("OBSERVER_LOG_SIZE_EXCEEDED", "the new observation exceeds the log size limit")
1623
+ if _uses_path_lock():
1624
+ return _record_observation_path(root, record, payload)
1625
+ committed = False
1626
+ with _state_directory(root, create=True, committed=lambda: committed) as (
1627
+ directory_fd,
1628
+ verify_state,
1629
+ workspace_fd,
1630
+ ):
1631
+ descriptor: int | None = None
1632
+ opened: os.stat_result | None = None
1633
+ created = False
1634
+ prior_length = 0
1635
+ write_attempted = False
1636
+ primary_error: BaseException | None = None
1637
+ try:
1638
+ verify_state()
1639
+ with _writer_lock(workspace_fd, verify=verify_state, committed=lambda: committed):
1640
+ try:
1641
+ descriptor, created = _open_regular_at(
1642
+ directory_fd, _LOG_NAME, lock=False, append=True, create=True
1643
+ )
1644
+ opened_candidate = os.fstat(descriptor)
1645
+ _validate_regular(opened_candidate)
1646
+ opened = opened_candidate
1647
+ existing = _read_descriptor(descriptor)
1648
+ prior_length = len(existing)
1649
+ _verify_log_path(directory_fd, descriptor, opened)
1650
+ _validate_existing_jsonl(existing)
1651
+ separator = b"" if not existing or existing.endswith(b"\n") else b"\n"
1652
+ addition = separator + payload
1653
+ if len(existing) + len(addition) > MAX_LOG_BYTES:
1654
+ raise SkillObserverError(
1655
+ "OBSERVER_LOG_SIZE_EXCEEDED", "the new observation would exceed the log size limit"
1656
+ )
1657
+ verify_state()
1658
+ os.lseek(descriptor, 0, os.SEEK_END)
1659
+ write_attempted = True
1660
+ try:
1661
+ written = os.write(descriptor, addition)
1662
+ except OSError:
1663
+ raise SkillObserverError(
1664
+ "OBSERVER_LOG_WRITE_FAILED", "the observation record could not be written"
1665
+ ) from None
1666
+ if written != len(addition):
1667
+ raise SkillObserverError(
1668
+ "OBSERVER_LOG_WRITE_FAILED", "the observation record was not complete"
1669
+ )
1670
+ after = os.fstat(descriptor)
1671
+ _validate_regular(after)
1672
+ if after.st_size != len(existing) + len(addition):
1673
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write")
1674
+ _verify_log_path(directory_fd, descriptor, after)
1675
+ _sync_log(descriptor)
1676
+ synced = os.fstat(descriptor)
1677
+ _validate_regular(synced)
1678
+ if synced.st_size != len(existing) + len(addition):
1679
+ raise SkillObserverError("OBSERVER_LOG_CHANGED", "the observation log changed during write")
1680
+ _verify_log_path(directory_fd, descriptor, synced)
1681
+ verify_state()
1682
+ committed = True
1683
+ except SkillObserverError as error:
1684
+ primary_error = error
1685
+ if not committed and descriptor is not None and opened is not None:
1686
+ try:
1687
+ _cleanup_log_failure(
1688
+ directory_fd,
1689
+ descriptor,
1690
+ opened,
1691
+ prior_length,
1692
+ created=created,
1693
+ write_attempted=write_attempted,
1694
+ )
1695
+ except SkillObserverError as cleanup_error:
1696
+ primary_error = cleanup_error
1697
+ raise
1698
+ raise
1699
+ except KeyboardInterrupt as error:
1700
+ primary_error = error
1701
+ if descriptor is not None and opened is not None:
1702
+ try:
1703
+ _cleanup_log_failure(
1704
+ directory_fd,
1705
+ descriptor,
1706
+ opened,
1707
+ prior_length,
1708
+ created=created,
1709
+ write_attempted=write_attempted,
1710
+ )
1711
+ except SkillObserverError as cleanup_error:
1712
+ primary_error = cleanup_error
1713
+ raise
1714
+ raise
1715
+ except OSError as error:
1716
+ primary_error = error
1717
+ if committed:
1718
+ raise _committed_cleanup_error() from None
1719
+ if not committed and descriptor is not None and opened is not None:
1720
+ try:
1721
+ _cleanup_log_failure(
1722
+ directory_fd,
1723
+ descriptor,
1724
+ opened,
1725
+ prior_length,
1726
+ created=created,
1727
+ write_attempted=write_attempted,
1728
+ )
1729
+ except SkillObserverError as cleanup_error:
1730
+ primary_error = cleanup_error
1731
+ raise
1732
+ raise SkillObserverError(
1733
+ "OBSERVER_LOG_WRITE_FAILED", "the observation log could not be written"
1734
+ ) from None
1735
+ finally:
1736
+ if descriptor is not None:
1737
+ try:
1738
+ os.close(descriptor)
1739
+ except OSError:
1740
+ if committed:
1741
+ raise _committed_cleanup_error() from None
1742
+ if _is_unknown_state(primary_error):
1743
+ raise primary_error
1744
+ raise
1745
+ return record
1746
+
1747
+
1748
+ def read_observations(root: str | os.PathLike[str]) -> list[dict]:
1749
+ if _uses_path_lock():
1750
+ return _read_observations_path(root)
1751
+ try:
1752
+ with _state_directory(root, create=False) as (directory_fd, verify_state, workspace_fd):
1753
+ descriptor: int | None = None
1754
+ try:
1755
+ verify_state()
1756
+ with _writer_lock(workspace_fd, verify=verify_state):
1757
+ descriptor, _ = _open_regular_at(
1758
+ directory_fd, _LOG_NAME, lock=False, append=False, create=False
1759
+ )
1760
+ opened = os.fstat(descriptor)
1761
+ _validate_regular(opened)
1762
+ raw = _read_descriptor(descriptor)
1763
+ _verify_log_path(directory_fd, descriptor, opened)
1764
+ _validate_existing_jsonl(raw)
1765
+ finally:
1766
+ if descriptor is not None:
1767
+ os.close(descriptor)
1768
+ except FileNotFoundError:
1769
+ return []
1770
+ text = raw.decode("utf-8")
1771
+ records: list[dict] = []
1772
+ for line in text.split("\n"):
1773
+ if not line.strip():
1774
+ continue
1775
+ record = json.loads(line, object_pairs_hook=_reject_duplicate_keys)
1776
+ _validate_observation(record, persisted=True)
1777
+ records.append(record)
1778
+ return records