@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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/README_Ko-KR.md +188 -0
- package/assets/lithermes-plugin/NOTICE.md +46 -0
- package/assets/lithermes-plugin/README.md +390 -0
- package/assets/lithermes-plugin/__init__.py +589 -0
- package/assets/lithermes-plugin/auto_update.py +338 -0
- package/assets/lithermes-plugin/bounded_work.py +1120 -0
- package/assets/lithermes-plugin/bounded_work_tools.py +115 -0
- package/assets/lithermes-plugin/core.py +1813 -0
- package/assets/lithermes-plugin/core_commands.py +243 -0
- package/assets/lithermes-plugin/core_contexts.py +458 -0
- package/assets/lithermes-plugin/core_contract.py +456 -0
- package/assets/lithermes-plugin/core_plans.py +418 -0
- package/assets/lithermes-plugin/core_reader_args.py +34 -0
- package/assets/lithermes-plugin/core_recap_command.py +141 -0
- package/assets/lithermes-plugin/core_recap_state.py +175 -0
- package/assets/lithermes-plugin/core_review.py +198 -0
- package/assets/lithermes-plugin/core_routing.py +641 -0
- package/assets/lithermes-plugin/core_runs.py +283 -0
- package/assets/lithermes-plugin/core_runtime.py +251 -0
- package/assets/lithermes-plugin/core_workflow_commands.py +207 -0
- package/assets/lithermes-plugin/deliverable_hedge_guard.py +562 -0
- package/assets/lithermes-plugin/diagnostics.py +468 -0
- package/assets/lithermes-plugin/handoff.py +97 -0
- package/assets/lithermes-plugin/knowledge.py +920 -0
- package/assets/lithermes-plugin/knowledge_tools.py +130 -0
- package/assets/lithermes-plugin/lit_mark.py +140 -0
- package/assets/lithermes-plugin/lit_mark_rows.json +1363 -0
- package/assets/lithermes-plugin/litgoal/__init__.py +10 -0
- package/assets/lithermes-plugin/litgoal/cli.py +207 -0
- package/assets/lithermes-plugin/litgoal/hook.py +81 -0
- package/assets/lithermes-plugin/litgoal/model.py +203 -0
- package/assets/lithermes-plugin/litgoal/runtime.py +522 -0
- package/assets/lithermes-plugin/litgoal/store.py +367 -0
- package/assets/lithermes-plugin/litgoal/tools.py +300 -0
- package/assets/lithermes-plugin/output-styles/asd-ste100-ko.md +41 -0
- package/assets/lithermes-plugin/output-styles/asd-ste100.md +40 -0
- package/assets/lithermes-plugin/output-styles/eli5-ko.md +13 -0
- package/assets/lithermes-plugin/output-styles/eli5.md +11 -0
- package/assets/lithermes-plugin/output_styles.py +62 -0
- package/assets/lithermes-plugin/payload-version.json +2107 -0
- package/assets/lithermes-plugin/plugin.yaml +16 -0
- package/assets/lithermes-plugin/provider_metrics.py +161 -0
- package/assets/lithermes-plugin/redaction.py +148 -0
- package/assets/lithermes-plugin/rules/__init__.py +55 -0
- package/assets/lithermes-plugin/rules/bundled-rules/baseline-discipline.md +20 -0
- package/assets/lithermes-plugin/rules/bundled-rules/build-decision-gate.md +19 -0
- package/assets/lithermes-plugin/rules/bundled-rules/windows-git-bash.md +17 -0
- package/assets/lithermes-plugin/rules/constants.py +122 -0
- package/assets/lithermes-plugin/rules/discovery.py +276 -0
- package/assets/lithermes-plugin/rules/engine.py +447 -0
- package/assets/lithermes-plugin/rules/frontmatter.py +149 -0
- package/assets/lithermes-plugin/rules/globmatch.py +333 -0
- package/assets/lithermes-plugin/scientific_visualization.py +158 -0
- package/assets/lithermes-plugin/session_context.py +8 -0
- package/assets/lithermes-plugin/skill_loop.py +2044 -0
- package/assets/lithermes-plugin/skill_observer.py +1778 -0
- package/assets/lithermes-plugin/skills/autoconference/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/autoconference/ORIGIN.json +11 -0
- package/assets/lithermes-plugin/skills/autoconference/SKILL.md +163 -0
- package/assets/lithermes-plugin/skills/autoconference/assets/conference_template.md +76 -0
- package/assets/lithermes-plugin/skills/autoconference/assets/report_template.md +53 -0
- package/assets/lithermes-plugin/skills/autoconference/assets/synthesis_template.md +39 -0
- package/assets/lithermes-plugin/skills/autoconference/dependencies/autoresearch.md +14 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/analyze/SKILL.md +269 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/core/SKILL.md +134 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/core/convergence-guide.md +162 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/core/crash-recovery.md +63 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/debate/SKILL.md +383 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/plan/SKILL.md +304 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/resume/SKILL.md +82 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/ship/SKILL.md +78 -0
- package/assets/lithermes-plugin/skills/autoconference/modes/survey/SKILL.md +376 -0
- package/assets/lithermes-plugin/skills/autoconference/references/agent-prompts.md +110 -0
- package/assets/lithermes-plugin/skills/autoconference/references/conference-protocol.md +90 -0
- package/assets/lithermes-plugin/skills/autoconference/references/core-principles.md +78 -0
- package/assets/lithermes-plugin/skills/autoconference/references/results-logging.md +165 -0
- package/assets/lithermes-plugin/skills/autoconference/references/visualization-guide.md +99 -0
- package/assets/lithermes-plugin/skills/autoconference/scripts/check_conference.sh +197 -0
- package/assets/lithermes-plugin/skills/autoconference/scripts/init_conference.py +489 -0
- package/assets/lithermes-plugin/skills/autoconference/scripts/style_presets.py +104 -0
- package/assets/lithermes-plugin/skills/autoconference/templates/code-performance.md +59 -0
- package/assets/lithermes-plugin/skills/autoconference/templates/debate-mode.md +50 -0
- package/assets/lithermes-plugin/skills/autoconference/templates/prompt-optimization.md +58 -0
- package/assets/lithermes-plugin/skills/autoconference/templates/quick-conference.md +48 -0
- package/assets/lithermes-plugin/skills/autoconference/templates/research-synthesis.md +56 -0
- package/assets/lithermes-plugin/skills/autoconference/templates/survey-mode.md +63 -0
- package/assets/lithermes-plugin/skills/autoresearch/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/autoresearch/ORIGIN.json +11 -0
- package/assets/lithermes-plugin/skills/autoresearch/SKILL.md +175 -0
- package/assets/lithermes-plugin/skills/autoresearch/assets/report_template.md +50 -0
- package/assets/lithermes-plugin/skills/autoresearch/assets/research_template.md +38 -0
- package/assets/lithermes-plugin/skills/autoresearch/assets/results_template.tsv +2 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/core/SKILL.md +204 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/core/evaluator-contract.md +108 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/core/stuck-detection.md +102 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/debug/SKILL.md +240 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/debug/investigation-techniques.md +254 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/fix/SKILL.md +176 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/learn/SKILL.md +96 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/plan/SKILL.md +291 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/predict/SKILL.md +271 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/predict/persona-templates.md +227 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/reason/SKILL.md +174 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/scenario/SKILL.md +168 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/scenario/dimensions.md +173 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/security/SKILL.md +287 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/security/owasp-checklist.md +156 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/security/stride-model.md +191 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/ship/SKILL.md +99 -0
- package/assets/lithermes-plugin/skills/autoresearch/modes/ship/type-checklists.md +158 -0
- package/assets/lithermes-plugin/skills/autoresearch/references/core-principles.md +75 -0
- package/assets/lithermes-plugin/skills/autoresearch/references/results-logging.md +55 -0
- package/assets/lithermes-plugin/skills/autoresearch/references/visualization-guide.md +90 -0
- package/assets/lithermes-plugin/skills/autoresearch/scripts/check_progress.sh +139 -0
- package/assets/lithermes-plugin/skills/autoresearch/scripts/init_research.py +219 -0
- package/assets/lithermes-plugin/skills/autoresearch/scripts/style_presets.py +104 -0
- package/assets/lithermes-plugin/skills/browser-drive/SKILL.md +147 -0
- package/assets/lithermes-plugin/skills/browser-drive/scripts/capability_probe.py +528 -0
- package/assets/lithermes-plugin/skills/comment-checker/SKILL.md +162 -0
- package/assets/lithermes-plugin/skills/debugging/SKILL.md +271 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +108 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +122 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/04-oracle-triple.md +136 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/05-escalate.md +69 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/06-fix.md +116 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/08-qa.md +94 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +164 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/partial-runtime-evidence.md +229 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/go.md +252 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +484 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/node.md +260 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/python.md +248 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +234 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +212 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/playwright-cli.md +194 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/pwndbg.md +263 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/pwntools.md +265 -0
- package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +419 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +49 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/.gitattributes +2 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/README.md +248 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/_INDEX.md +191 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/airbnb.md +393 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/airtable.md +92 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/apple.md +250 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/aside.md +209 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/binance.md +348 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/bmw.md +183 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/brutalist-skill.md +92 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/bugatti.md +271 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/cal.md +262 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/claude.md +315 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/clay.md +307 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/clickhouse.md +284 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/clone-from-url.md +65 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/cohere.md +269 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/coinbase.md +132 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/composio.md +310 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/cursor.md +312 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/design-system-architecture.md +244 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/elevenlabs.md +268 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/expo.md +284 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/ferrari.md +317 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/figma.md +223 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/framer.md +249 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/gpt-tasteskill.md +74 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/hashicorp.md +281 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/ibm.md +335 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/image-to-code-skill.md +1228 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/imagegen-brandkit.md +798 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/imagegen-frontend-mobile.md +1465 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/imagegen-frontend-web.md +987 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/intercom.md +149 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/kraken.md +128 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/lamborghini.md +291 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/layout-skill.md +107 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/lazyweb.md +77 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/linear.app.md +370 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/lovable.md +301 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mastercard.md +368 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/meta.md +369 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/minimalist-skill.md +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/minimax.md +260 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mintlify.md +329 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/miro.md +111 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mistral.ai.md +264 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/mongodb.md +269 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/nike.md +366 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/notion.md +312 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/nvidia.md +296 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/ollama.md +270 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/opencode.ai.md +284 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/output-skill.md +49 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/pinterest.md +233 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/playstation.md +367 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/posthog.md +259 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/raycast.md +271 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/react-dev-tooling-skill.md +230 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/redesign-skill.md +178 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/renault.md +314 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/replicate.md +264 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/resend.md +306 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/revolut.md +188 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/runwayml.md +247 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/sanity.md +360 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/sentry.md +265 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/shopify.md +353 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/soft-skill.md +98 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/spacex.md +197 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/spotify.md +249 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/starbucks.md +583 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/stitch-design-example.md +121 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/stitch-skill.md +184 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/stripe.md +325 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/supabase.md +258 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/superhuman.md +255 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/taste-skill.md +1206 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/tesla.md +289 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/theverge.md +342 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/together.ai.md +266 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/uber.md +298 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/vercel.md +313 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/vodafone.md +426 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/voltagent.md +326 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/warp.md +256 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/webflow.md +95 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/wired.md +281 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/wise.md +176 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/x.ai.md +260 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/design/zapier.md +331 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/EVIDENCE.md +97 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/README.md +48 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/UPSTREAM.md +80 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-a-direction.md +64 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-b-execution.md +65 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-c-review.md +65 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/lane-d-memory.md +83 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/orchestration.md +80 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/routing.md +79 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/accessibility-reviewer.md +83 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/content-writer.md +132 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-builder.md +109 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-critic.md +89 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-lead.md +113 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-scout.md +78 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/design-strategist.md +121 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/heuristic-evaluator.md +268 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/inspiration-scout.md +107 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/agents/motion-designer.md +120 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/accessible-content/reference.md +101 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/adaptive-interfaces/reference.md +109 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/cognitive-accessibility/reference.md +107 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-debate/reference.md +199 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-debt-tracker/reference.md +174 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-handoff/reference.md +125 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-md/reference.md +106 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-retrospective/reference.md +266 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-review/reference.md +123 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/design-system-alignment/reference.md +120 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/designpowers-critique/reference.md +164 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/heuristic-evaluation/reference.md +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/inclusive-personas/reference.md +98 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/inspiration-scouting/reference.md +165 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/interaction-design/reference.md +122 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/motion-choreography/reference.md +81 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/research-planning/reference.md +96 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/responsive-patterns/reference.md +77 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/synthetic-user-testing/reference.md +192 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/taste-feedback/reference.md +165 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/taste-report/reference.md +78 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/token-architecture/reference.md +75 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/ui-composition/reference.md +117 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/usability-testing/reference.md +78 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/verification-before-shipping/reference.md +125 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/voice-and-tone/reference.md +79 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/designpowers/vendor/skills/writing-design-plans/reference.md +119 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/perfection/README.md +160 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/perfection/react-perf-tooling.md +127 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/README.md +659 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/charts.csv +26 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/colors.csv +162 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/icons.csv +106 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/landing.csv +35 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/products.csv +162 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/react-performance.csv +45 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/astro.csv +54 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/flutter.csv +53 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/html-tailwind.csv +56 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/jetpack-compose.csv +53 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nextjs.csv +53 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxt-ui.csv +51 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxtjs.csv +59 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/react-native.csv +52 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/react.csv +54 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/shadcn.csv +61 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/svelte.csv +54 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/swiftui.csv +51 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/vue.csv +50 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/styles.csv +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/typography.csv +74 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/ui-reasoning.csv +162 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/ux-guidelines.csv +100 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/web-interface.csv +31 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/scripts/core.py +262 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/scripts/design_system.py +1148 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/scripts/search.py +114 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/legal/ATTRIBUTION.md +217 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/legal/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/legal/LICENSE-Apache-2.0.txt +201 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/manifest.json +876 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/adaptive-layout.md +81 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/brand-and-imagery.md +84 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +457 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/composition.md +89 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/creative-directions.md +81 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/evidence-review.md +113 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/implementation-platforms.md +92 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/inclusive-interface.md +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/interaction-motion.md +87 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/operating-lanes.md +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/performance-delivery.md +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/product-direction.md +86 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/redesign-playbook.md +91 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/system-foundations.md +82 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/taste-direction.md +86 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/visual-language.md +85 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/visual-reconstruction.md +94 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/PROVENANCE.json +38 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/THIRD-PARTY-NOTICE.txt +14 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/design-intelligence.json +1 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/resources/import-manifest.json +1060 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/schemas/design-contract-v1alpha1.schema.json +264 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/schemas/design-contract-v1beta1.schema.json +268 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/schemas/design-contract-v1beta2.schema.json +280 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design_contract_validation.py +996 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design_intelligence.py +128 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design_runtime.py +98 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/import_design_intelligence.py +56 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/source_import.py +144 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/source_manifest.py +65 -0
- package/assets/lithermes-plugin/skills/lit-burnoff/SKILL.md +448 -0
- package/assets/lithermes-plugin/skills/lit-burnoff-file/SKILL.md +254 -0
- package/assets/lithermes-plugin/skills/lit-code/SKILL.md +609 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/README.md +90 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/backend-stack.md +641 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/bootstrap.md +328 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/bubbletea-v2.md +359 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/cobra-stack.md +468 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/concurrency.md +362 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/data-modeling.md +329 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/error-handling.md +359 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/golangci-strict.md +236 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/grpc-connect.md +375 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/libraries.md +337 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/one-liners.md +202 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/sqlc-pgx.md +471 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/testing.md +467 -0
- package/assets/lithermes-plugin/skills/lit-code/references/go/type-patterns.md +298 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/README.md +314 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/async-anyio.md +442 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/data-modeling.md +233 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/data-processing.md +133 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/error-handling.md +218 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/fastapi-stack.md +316 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/httpx2-optimization.md +360 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/libraries.md +307 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/one-liners.md +268 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/orjson-stack.md +378 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/pydantic-ai.md +285 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/pyproject-strict.md +232 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/textual-tui.md +201 -0
- package/assets/lithermes-plugin/skills/lit-code/references/python/type-patterns.md +176 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/README.md +317 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/async-tokio.md +299 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/axum-stack.md +467 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/cargo-strict.md +317 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/clap-stack.md +409 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/concurrency.md +375 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/libraries.md +439 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/one-liners.md +291 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/proptest-insta.md +429 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/type-state.md +354 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/unsafe-discipline.md +250 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust/zero-cost-safety.md +527 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust-ub/README.md +289 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust-ub/miri-sanitizers-loom.md +411 -0
- package/assets/lithermes-plugin/skills/lit-code/references/rust-ub/ub-taxonomy.md +269 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/README.md +195 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/backend-hono.md +672 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/bootstrap.md +199 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/data-modeling.md +202 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/error-handling.md +169 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/tsconfig-strict.md +152 -0
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/type-patterns.md +196 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/check-no-excuse-rules.sh +173 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/new-project.py +138 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/.editorconfig +13 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/.golangci.yml +95 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/AGENTS.md.tmpl +24 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/README.md.tmpl +12 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/Taskfile.yml +40 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/ci.yml +37 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/config.go +24 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/gitignore +15 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/main.go.tmpl +22 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/go/templates/run.go +15 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/python/check-no-excuse-rules.py +687 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/python/new-project.py +172 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/python/new-script.py +116 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/rust/check-no-excuse-rules.py +296 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/rust/check-no-excuse-rules.sh +158 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/rust/new-project.py +175 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/check-no-excuse-rules.ts +282 -0
- package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/new-project.ts +177 -0
- package/assets/lithermes-plugin/skills/lit-commit/SKILL.md +242 -0
- package/assets/lithermes-plugin/skills/lit-comprehend/SKILL.md +259 -0
- package/assets/lithermes-plugin/skills/lit-comprehend/assets/explainer-scaffold.html +123 -0
- package/assets/lithermes-plugin/skills/lit-comprehend/references/artifact-template.md +205 -0
- package/assets/lithermes-plugin/skills/lit-comprehend/references/micro-worlds.md +139 -0
- package/assets/lithermes-plugin/skills/lit-comprehend/scripts/verify-explainer.py +324 -0
- package/assets/lithermes-plugin/skills/lit-crucible/SKILL.md +315 -0
- package/assets/lithermes-plugin/skills/lit-handoff/ORIGIN.json +23 -0
- package/assets/lithermes-plugin/skills/lit-handoff/SKILL.md +145 -0
- package/assets/lithermes-plugin/skills/lit-init/SKILL.md +329 -0
- package/assets/lithermes-plugin/skills/lit-korean/SKILL.md +237 -0
- package/assets/lithermes-plugin/skills/lit-korean/references/quick-rules.md +24 -0
- package/assets/lithermes-plugin/skills/lit-korean/references/safety-checklist.md +23 -0
- package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +596 -0
- package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +183 -0
- package/assets/lithermes-plugin/skills/lit-scientific-visualization/ORIGIN.json +32 -0
- package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +170 -0
- package/assets/lithermes-plugin/skills/litgoal/.gitkeep +0 -0
- package/assets/lithermes-plugin/skills/litgoal/SKILL.md +372 -0
- package/assets/lithermes-plugin/skills/litresearch/ATTRIBUTION.md +50 -0
- package/assets/lithermes-plugin/skills/litresearch/SKILL.md +593 -0
- package/assets/lithermes-plugin/skills/litwork/SKILL.md +419 -0
- package/assets/lithermes-plugin/skills/lsp/SKILL.md +165 -0
- package/assets/lithermes-plugin/skills/lsp-setup/SKILL.md +265 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/bash/README.md +68 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/c-cpp/README.md +78 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/csharp/README.md +90 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/dart/README.md +59 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/elixir/README.md +61 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/go/README.md +63 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/haskell/README.md +69 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/java/README.md +70 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/julia/README.md +70 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/kotlin/README.md +72 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/lua/README.md +68 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/php/README.md +61 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/python/README.md +73 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/ruby/README.md +67 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/rust/README.md +65 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/swift/README.md +64 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/terraform/README.md +66 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/typescript/README.md +82 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/yaml/README.md +65 -0
- package/assets/lithermes-plugin/skills/lsp-setup/references/zig/README.md +59 -0
- package/assets/lithermes-plugin/skills/lsp-setup/scripts/detect-lsp.ts +221 -0
- package/assets/lithermes-plugin/skills/lsp-setup/scripts/lsp-server-table.ts +146 -0
- package/assets/lithermes-plugin/skills/lsp-setup/scripts/tsconfig.json +18 -0
- package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +252 -0
- package/assets/lithermes-plugin/skills/refactor/SKILL.md +896 -0
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +450 -0
- package/assets/lithermes-plugin/skills/rules/SKILL.md +194 -0
- package/assets/lithermes-plugin/skills/skill-observer/SKILL.md +190 -0
- package/assets/lithermes-plugin/skills/skill-observer/references/review-contract.md +77 -0
- package/assets/lithermes-plugin/skills/start-work/SKILL.md +550 -0
- package/assets/lithermes-plugin/skills/structural-search/SKILL.md +274 -0
- package/assets/lithermes-plugin/skills/visual-qa/SKILL.md +48 -0
- package/assets/lithermes-plugin/skills/visual-qa/references/capture-playbook.md +133 -0
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +522 -0
- package/assets/lithermes-plugin/skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json +135 -0
- package/assets/lithermes-plugin/skills/visual-qa/schemas/evidence-manifest-v1beta1.schema.json +110 -0
- package/assets/lithermes-plugin/skills/visual-qa/schemas/review-receipt-v1alpha1.schema.json +49 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/contract_checks.py +71 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/evidence_contract.py +288 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/png_runtime.py +379 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/review_contract.py +151 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/tier_checks.py +190 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/tier_contract.py +103 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/tui_runtime.py +170 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/visual_contracts.py +31 -0
- package/assets/lithermes-plugin/skills/visual-qa/scripts/visual_qa.py +79 -0
- package/assets/lithermes-plugin/skills/wikify/LICENSE +21 -0
- package/assets/lithermes-plugin/skills/wikify/ORIGIN.json +12 -0
- package/assets/lithermes-plugin/skills/wikify/SKILL.md +192 -0
- package/assets/lithermes-plugin/skills/wikify/assets/home-template.md +44 -0
- package/assets/lithermes-plugin/skills/wikify/assets/maintenance-report-template.md +46 -0
- package/assets/lithermes-plugin/skills/wikify/assets/paper-source-note-template.md +138 -0
- package/assets/lithermes-plugin/skills/wikify/assets/source-note-template.md +45 -0
- package/assets/lithermes-plugin/skills/wikify/assets/wiki-rules-template.md +117 -0
- package/assets/lithermes-plugin/skills/wikify/modes/ingest/SKILL.md +24 -0
- package/assets/lithermes-plugin/skills/wikify/modes/init/SKILL.md +26 -0
- package/assets/lithermes-plugin/skills/wikify/modes/lint/SKILL.md +30 -0
- package/assets/lithermes-plugin/skills/wikify/modes/query/SKILL.md +22 -0
- package/assets/lithermes-plugin/skills/wikify/modes/save/SKILL.md +32 -0
- package/assets/lithermes-plugin/skills/wikify/references/full-contract.md +761 -0
- package/assets/lithermes-plugin/uiux_runtime_common.py +117 -0
- package/assets/lithermes-plugin/vendor/NOTICE.md +21 -0
- package/assets/lithermes-plugin/vendor/handoff/SKILL.md +199 -0
- package/assets/lithermes-plugin/vendor/handoff/evals/evals.json +154 -0
- package/assets/lithermes-plugin/vendor/handoff/examples/HANDOFF-example-generic-auth-refactor.md +97 -0
- package/assets/lithermes-plugin/vendor/handoff/templates/HANDOFF.md +121 -0
- package/assets/lithermes-plugin/vendor/licenses/022_handoff-MIT.txt +21 -0
- package/assets/lithermes-plugin/vendor/licenses/045_scientific-visualization-MIT.txt +21 -0
- package/assets/lithermes-plugin/vendor/provenance/022_handoff.md +16 -0
- package/assets/lithermes-plugin/vendor/provenance/045_scientific-visualization.md +18 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/SKILL.md +283 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/assets/color_palettes.py +197 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/assets/nature.mplstyle +75 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/assets/presentation.mplstyle +74 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/assets/publication.mplstyle +78 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/evals/evals.json +158 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/references/color_palettes.md +380 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/references/journal_requirements.md +359 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/references/matplotlib_examples.md +608 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/references/mdanalysis_martini_visualization.md +85 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/references/publication_guidelines.md +217 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/references/seaborn_for_publications.md +293 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/scripts/figure_export.py +238 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/scripts/style_presets.py +467 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/tests/test_figure_export.py +51 -0
- package/assets/lithermes-plugin/vendor/scientific-visualization/tests/test_style_presets.py +114 -0
- package/bin/lithermes.js +8 -0
- package/package.json +58 -0
- package/readme-assets/badge-license.svg +1 -0
- package/readme-assets/badge-version.svg +1 -0
- package/readme-assets/cover.webp +0 -0
- package/readme-assets/lithermes-clay-icon.png +0 -0
- package/readme-assets/lithermes-wordmark.svg +5 -0
- package/src/cli.js +396 -0
- package/src/lib/autoUpdate.js +4 -0
- package/src/lib/canonicalCorpus.js +436 -0
- package/src/lib/check.js +200 -0
- package/src/lib/config.js +229 -0
- package/src/lib/files.js +126 -0
- package/src/lib/hermesDiscovery.js +122 -0
- package/src/lib/hud.js +135 -0
- package/src/lib/install.js +559 -0
- package/src/lib/litMark.js +68 -0
- package/src/lib/modelConfig.js +431 -0
- package/src/lib/modelConfigBoundary.js +206 -0
- package/src/lib/modelPrompt.js +201 -0
- package/src/lib/modelRoutePolicy.js +379 -0
- package/src/lib/patch.js +153 -0
- package/src/lib/skillLoop.js +62 -0
- package/src/lib/skillPayload.js +306 -0
- package/src/lib/skins.js +274 -0
- package/src/lib/spinner.js +193 -0
- package/src/lib/updateNotifier.js +1133 -0
- 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
|