@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,2044 @@
|
|
|
1
|
+
"""Approval-gated skill proposals for the LitHermes user skill root.
|
|
2
|
+
|
|
3
|
+
This module never calls a model. Review prints a bounded packet; ``propose``
|
|
4
|
+
imports schema-shaped JSON; only the foreground ``apply`` command mutates an
|
|
5
|
+
agent-owned skill under ``$HERMES_HOME/skills``. The host curator remains the
|
|
6
|
+
only curator and can be opted into explicitly with ``hermes curator adopt``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import unicodedata
|
|
22
|
+
import uuid
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Final
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from .redaction import redact_text
|
|
30
|
+
from . import skill_observer
|
|
31
|
+
except (ImportError, ModuleNotFoundError):
|
|
32
|
+
from redaction import redact_text
|
|
33
|
+
import skill_observer
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import fcntl
|
|
37
|
+
except ImportError: # pragma: no cover - the supported Hermes runtime is POSIX
|
|
38
|
+
fcntl = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PROPOSAL_SCHEMA: Final = "litfamily.skill-proposal/v1"
|
|
42
|
+
LEDGER_SCHEMA: Final = "litfamily.skill-ledger/v1"
|
|
43
|
+
HOST: Final = "lithermes"
|
|
44
|
+
MARKER: Final = "lithermesAgentGenerated"
|
|
45
|
+
AUTO_APPLY: Final = False
|
|
46
|
+
MAX_PROPOSAL_BYTES: Final = 1024 * 1024
|
|
47
|
+
MAX_PROPOSAL_FILES: Final = 128
|
|
48
|
+
MAX_PROPOSAL_QUEUE_BYTES: Final = 4 * 1024 * 1024
|
|
49
|
+
MAX_SKILL_FILE_BYTES: Final = 4 * 1024 * 1024
|
|
50
|
+
MAX_SKILL_TOTAL_BYTES: Final = 16 * 1024 * 1024
|
|
51
|
+
MAX_REVIEW_PACKET_BYTES: Final = 64 * 1024
|
|
52
|
+
MAX_REVIEW_LINES: Final = 20
|
|
53
|
+
MAX_SESSIONS: Final = 64
|
|
54
|
+
MAX_CONSULTED_SKILLS: Final = 8
|
|
55
|
+
MAX_CORRECTIONS_PER_SESSION: Final = 3
|
|
56
|
+
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
57
|
+
_SKILL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
58
|
+
_OBSERVED_SKILL_ID = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
|
59
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
60
|
+
_CORRECTION = re.compile(
|
|
61
|
+
r"(?:\bdon['’]?t\b|\bstop\b|\bdo not\b|\bnot that\b|"
|
|
62
|
+
r"하지\s*마|그만|왜\s*자꾸|아니라|말고|빼(?:줘|세요)|요약하지)",
|
|
63
|
+
re.IGNORECASE,
|
|
64
|
+
)
|
|
65
|
+
_RESERVED = re.compile(r"^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", re.IGNORECASE)
|
|
66
|
+
_PROPOSAL_FIELDS = {
|
|
67
|
+
"schema", "id", "createdAt", "host", "sessionRef", "signal", "targetSkill",
|
|
68
|
+
"targetRoot", "action", "patch", "createSpec", "rationale", "evidenceRefs",
|
|
69
|
+
"status", "ledgerEntryId",
|
|
70
|
+
}
|
|
71
|
+
_LEDGER_FIELDS = {
|
|
72
|
+
"schema", "id", "createdAt", "proposalId", "actor", "operation",
|
|
73
|
+
"targetSkill", "targetRoot", "before", "after", "restoresLedgerEntryId",
|
|
74
|
+
"reason",
|
|
75
|
+
}
|
|
76
|
+
_CONSULTED: dict[str, list[str]] = {}
|
|
77
|
+
_CORRECTION_DIGESTS: dict[str, list[str]] = {}
|
|
78
|
+
_PENDING_REVIEW: set[str] = set()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SkillLoopError(Exception):
|
|
82
|
+
def __init__(self, code: str, message: str) -> None:
|
|
83
|
+
super().__init__(message)
|
|
84
|
+
self.code = code
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fail(code: str, message: str) -> None:
|
|
88
|
+
raise SkillLoopError(code, message)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _hermes_home(value: str | os.PathLike[str] | None = None) -> Path:
|
|
92
|
+
raw = value or os.environ.get("HERMES_HOME")
|
|
93
|
+
if not raw:
|
|
94
|
+
raw = Path.home() / ".hermes"
|
|
95
|
+
path = Path(raw).expanduser()
|
|
96
|
+
try:
|
|
97
|
+
return path.resolve()
|
|
98
|
+
except (OSError, RuntimeError):
|
|
99
|
+
_fail("HERMES_HOME_UNSAFE", "the Hermes home cannot be resolved")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _workspace(value: str | os.PathLike[str] | None = None) -> Path:
|
|
103
|
+
path = Path(value or os.getcwd())
|
|
104
|
+
try:
|
|
105
|
+
resolved = path.resolve(strict=True)
|
|
106
|
+
except (OSError, RuntimeError):
|
|
107
|
+
_fail("WORKSPACE_UNSAFE", "the workspace cannot be resolved")
|
|
108
|
+
if not resolved.is_dir():
|
|
109
|
+
_fail("WORKSPACE_UNSAFE", "the workspace is not a directory")
|
|
110
|
+
return resolved
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def proposal_directory(workspace: str | os.PathLike[str] | None = None) -> Path:
|
|
114
|
+
current = _workspace(workspace)
|
|
115
|
+
for component in (".hermes", "lithermes", "skill-proposals"):
|
|
116
|
+
current = current / component
|
|
117
|
+
if current.is_symlink():
|
|
118
|
+
_fail("STATE_PATH_UNSAFE", "the workspace proposal path cannot contain symbolic links")
|
|
119
|
+
if current.exists() and not current.is_dir():
|
|
120
|
+
_fail("STATE_PATH_UNSAFE", "the workspace proposal path is not a directory")
|
|
121
|
+
return current
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ledger_path(hermes_home: str | os.PathLike[str] | None = None) -> Path:
|
|
125
|
+
return _hermes_home(hermes_home) / "lithermes" / "skill-loop-ledger.jsonl"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def blob_directory(hermes_home: str | os.PathLike[str] | None = None) -> Path:
|
|
129
|
+
return _hermes_home(hermes_home) / "lithermes" / "skill-loop-blobs"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def skills_root(hermes_home: str | os.PathLike[str] | None = None) -> Path:
|
|
133
|
+
return _hermes_home(hermes_home) / "skills"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _state_root(hermes_home: str | os.PathLike[str] | None = None) -> Path:
|
|
137
|
+
return _hermes_home(hermes_home) / "lithermes"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _transaction_path(hermes_home: str | os.PathLike[str] | None = None) -> Path:
|
|
141
|
+
return _state_root(hermes_home) / "skill-loop-transaction.json"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _set_private_directory(path: Path) -> None:
|
|
145
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
146
|
+
if path.is_symlink() or not path.is_dir():
|
|
147
|
+
_fail("STATE_PATH_UNSAFE", "a skill-loop state path is not a directory")
|
|
148
|
+
try:
|
|
149
|
+
path.chmod(0o700)
|
|
150
|
+
except OSError:
|
|
151
|
+
_fail("STATE_PATH_UNSAFE", "a skill-loop state directory is not private")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ensure_user_skill_root(path: Path) -> None:
|
|
155
|
+
"""Create the user skill root, but do not rewrite an existing host mode."""
|
|
156
|
+
if path.is_symlink():
|
|
157
|
+
_fail("TARGET_PATH_UNSAFE", "the Hermes user skill root cannot be a symbolic link")
|
|
158
|
+
if path.exists():
|
|
159
|
+
if not path.is_dir():
|
|
160
|
+
_fail("TARGET_PATH_UNSAFE", "the Hermes user skill root is not a directory")
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
path.mkdir(parents=True, mode=0o700)
|
|
164
|
+
except FileExistsError:
|
|
165
|
+
if path.is_symlink() or not path.is_dir():
|
|
166
|
+
_fail("TARGET_PATH_UNSAFE", "the Hermes user skill root changed during validation")
|
|
167
|
+
except OSError:
|
|
168
|
+
_fail("TARGET_PATH_UNSAFE", "the Hermes user skill root could not be created")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _atomic_write(path: Path, data: bytes, *, mode: int = 0o600) -> None:
|
|
172
|
+
_set_private_directory(path.parent)
|
|
173
|
+
if path.exists() and (path.is_symlink() or not path.is_file()):
|
|
174
|
+
_fail("STATE_PATH_UNSAFE", "a skill-loop state file is not regular")
|
|
175
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
176
|
+
temp_path = Path(temporary)
|
|
177
|
+
try:
|
|
178
|
+
os.fchmod(descriptor, mode)
|
|
179
|
+
with os.fdopen(descriptor, "wb", closefd=True) as stream:
|
|
180
|
+
stream.write(data)
|
|
181
|
+
stream.flush()
|
|
182
|
+
os.fsync(stream.fileno())
|
|
183
|
+
os.replace(temp_path, path)
|
|
184
|
+
directory_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
185
|
+
try:
|
|
186
|
+
os.fsync(directory_fd)
|
|
187
|
+
finally:
|
|
188
|
+
os.close(directory_fd)
|
|
189
|
+
finally:
|
|
190
|
+
if temp_path.exists():
|
|
191
|
+
temp_path.unlink()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _json_bytes(record: Any) -> bytes:
|
|
195
|
+
return (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
199
|
+
result: dict[str, Any] = {}
|
|
200
|
+
for key, value in pairs:
|
|
201
|
+
if key in result:
|
|
202
|
+
raise ValueError("duplicate JSON key")
|
|
203
|
+
result[key] = value
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _read_json_file(path: Path, *, maximum: int = MAX_PROPOSAL_BYTES) -> Any:
|
|
208
|
+
try:
|
|
209
|
+
value = path.lstat()
|
|
210
|
+
if stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode) or value.st_nlink != 1:
|
|
211
|
+
_fail("STATE_PATH_UNSAFE", "the JSON input is not a regular single-link file")
|
|
212
|
+
if value.st_size > maximum:
|
|
213
|
+
_fail("PROPOSAL_TOO_LARGE", "the JSON input exceeds the size limit")
|
|
214
|
+
raw = path.read_bytes()
|
|
215
|
+
text = raw.decode("utf-8")
|
|
216
|
+
return json.loads(text, object_pairs_hook=_reject_duplicate_keys)
|
|
217
|
+
except SkillLoopError:
|
|
218
|
+
raise
|
|
219
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
220
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "the JSON input is malformed")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _strict_timestamp(value: Any) -> bool:
|
|
224
|
+
if not isinstance(value, str) or not value or len(value) > 64:
|
|
225
|
+
return False
|
|
226
|
+
if re.fullmatch(
|
|
227
|
+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
|
|
228
|
+
r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})",
|
|
229
|
+
value,
|
|
230
|
+
) is None:
|
|
231
|
+
return False
|
|
232
|
+
try:
|
|
233
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
234
|
+
except ValueError:
|
|
235
|
+
return False
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _canonical_component(value: str) -> str:
|
|
240
|
+
return unicodedata.normalize("NFKC", value).casefold().rstrip(". ")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _portable_skill_identity(value: str, *, code: str, message: str) -> str:
|
|
244
|
+
identity = _canonical_component(value)
|
|
245
|
+
stem = value.split(".", 1)[0]
|
|
246
|
+
if (
|
|
247
|
+
not identity
|
|
248
|
+
or identity != unicodedata.normalize("NFKC", value).casefold()
|
|
249
|
+
or _RESERVED.fullmatch(stem) is not None
|
|
250
|
+
):
|
|
251
|
+
_fail(code, message)
|
|
252
|
+
return identity
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _validate_relative_file(value: Any, target_skill: str) -> str:
|
|
256
|
+
if not isinstance(value, str) or not value or len(value) > 1024:
|
|
257
|
+
_fail("PROPOSAL_PATH_INVALID", "proposal files must be bounded relative paths")
|
|
258
|
+
if value.startswith(('/', '\\')) or re.match(r"^[A-Za-z]:", value):
|
|
259
|
+
_fail("PROPOSAL_PATH_INVALID", "proposal files must be relative")
|
|
260
|
+
if "\\" in value or _CONTROL.search(value) or any(c in value for c in ':*?"<>|'):
|
|
261
|
+
_fail("PROPOSAL_PATH_INVALID", "proposal files must use portable path characters")
|
|
262
|
+
parts = value.split("/")
|
|
263
|
+
if any(not part or part in {".", ".."} or part.endswith((".", " ")) for part in parts):
|
|
264
|
+
_fail("PROPOSAL_PATH_INVALID", "proposal files contain an unsafe segment")
|
|
265
|
+
for part in parts:
|
|
266
|
+
stem = part.split(".", 1)[0]
|
|
267
|
+
if _RESERVED.fullmatch(stem):
|
|
268
|
+
_fail("PROPOSAL_PATH_INVALID", "proposal files contain a reserved basename")
|
|
269
|
+
identity = "/".join(_canonical_component(part) for part in parts)
|
|
270
|
+
target_identity = _canonical_component(target_skill)
|
|
271
|
+
if identity == target_identity or identity.startswith(f"{target_identity}/"):
|
|
272
|
+
_fail("PROPOSAL_PATH_INVALID", "proposal files are relative to the selected skill")
|
|
273
|
+
return value
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _secret_in(value: Any) -> bool:
|
|
277
|
+
if isinstance(value, str):
|
|
278
|
+
try:
|
|
279
|
+
return skill_observer.contains_secret(value)
|
|
280
|
+
except skill_observer.SkillObserverError:
|
|
281
|
+
return True
|
|
282
|
+
if isinstance(value, list):
|
|
283
|
+
return any(_secret_in(item) for item in value)
|
|
284
|
+
if isinstance(value, dict):
|
|
285
|
+
return any(_secret_in(key) or _secret_in(item) for key, item in value.items())
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def validate_proposal(record: Any) -> dict[str, Any]:
|
|
290
|
+
if not isinstance(record, dict) or set(record) - _PROPOSAL_FIELDS:
|
|
291
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal fields do not match version 1")
|
|
292
|
+
required = {
|
|
293
|
+
"schema", "id", "createdAt", "host", "sessionRef", "signal", "targetSkill",
|
|
294
|
+
"targetRoot", "action", "rationale", "evidenceRefs", "status",
|
|
295
|
+
}
|
|
296
|
+
if not required.issubset(record):
|
|
297
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal required fields are missing")
|
|
298
|
+
if record["schema"] != PROPOSAL_SCHEMA or record["host"] != HOST:
|
|
299
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal schema or host is invalid")
|
|
300
|
+
if not isinstance(record["id"], str) or _ID.fullmatch(record["id"]) is None:
|
|
301
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal id is invalid")
|
|
302
|
+
if not _strict_timestamp(record["createdAt"]):
|
|
303
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal timestamp is invalid")
|
|
304
|
+
if not isinstance(record["sessionRef"], str) or not 1 <= len(record["sessionRef"]) <= 512:
|
|
305
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal session reference is invalid")
|
|
306
|
+
if not isinstance(record["signal"], str) or not 1 <= len(record["signal"]) <= 128:
|
|
307
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal signal is invalid")
|
|
308
|
+
target = record["targetSkill"]
|
|
309
|
+
if not isinstance(target, str) or _SKILL_ID.fullmatch(target) is None:
|
|
310
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal target skill is invalid")
|
|
311
|
+
_portable_skill_identity(
|
|
312
|
+
target,
|
|
313
|
+
code="PROPOSAL_PATH_INVALID",
|
|
314
|
+
message="proposal target skill is not a portable name",
|
|
315
|
+
)
|
|
316
|
+
if not isinstance(record["targetRoot"], str) or not 1 <= len(record["targetRoot"]) <= 2048:
|
|
317
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal target root is invalid")
|
|
318
|
+
if not isinstance(record["rationale"], str) or not 1 <= len(record["rationale"]) <= 8192:
|
|
319
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal rationale is invalid")
|
|
320
|
+
refs = record["evidenceRefs"]
|
|
321
|
+
if (
|
|
322
|
+
not isinstance(refs, list) or len(refs) > 32 or len(set(map(str, refs))) != len(refs)
|
|
323
|
+
or any(not isinstance(item, str) or not 1 <= len(item) <= 2048 for item in refs)
|
|
324
|
+
):
|
|
325
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal evidence references are invalid")
|
|
326
|
+
if record["status"] not in {"pending", "approved", "applied", "rejected", "rolled-back"}:
|
|
327
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal status is invalid")
|
|
328
|
+
if record["status"] in {"applied", "rolled-back"}:
|
|
329
|
+
if not isinstance(record.get("ledgerEntryId"), str) or not record["ledgerEntryId"]:
|
|
330
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "a terminal proposal requires a ledger entry")
|
|
331
|
+
elif "ledgerEntryId" in record:
|
|
332
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "a non-terminal proposal cannot name a ledger entry")
|
|
333
|
+
|
|
334
|
+
action = record["action"]
|
|
335
|
+
if action not in {"patch", "create", "add-reference", "archive"}:
|
|
336
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "proposal action is invalid")
|
|
337
|
+
if action == "patch":
|
|
338
|
+
if "createSpec" in record or not isinstance(record.get("patch"), dict):
|
|
339
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "patch proposal content is invalid")
|
|
340
|
+
patch = record["patch"]
|
|
341
|
+
if set(patch) != {"file", "oldString", "newString"}:
|
|
342
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "patch proposal fields are invalid")
|
|
343
|
+
_validate_relative_file(patch["file"], target)
|
|
344
|
+
if (
|
|
345
|
+
not isinstance(patch["oldString"], str) or not patch["oldString"]
|
|
346
|
+
or len(patch["oldString"]) > 65536
|
|
347
|
+
or not isinstance(patch["newString"], str) or len(patch["newString"]) > 65536
|
|
348
|
+
):
|
|
349
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "patch strings are invalid")
|
|
350
|
+
elif action in {"create", "add-reference"}:
|
|
351
|
+
if "patch" in record or not isinstance(record.get("createSpec"), dict):
|
|
352
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "create proposal content is invalid")
|
|
353
|
+
spec = record["createSpec"]
|
|
354
|
+
if set(spec) != {"files"} or not isinstance(spec["files"], list) or not 1 <= len(spec["files"]) <= 64:
|
|
355
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "create proposal files are invalid")
|
|
356
|
+
identities: set[str] = set()
|
|
357
|
+
total = 0
|
|
358
|
+
for item in spec["files"]:
|
|
359
|
+
if not isinstance(item, dict) or set(item) != {"file", "content"}:
|
|
360
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "create proposal file fields are invalid")
|
|
361
|
+
file = _validate_relative_file(item["file"], target)
|
|
362
|
+
identity = "/".join(_canonical_component(part) for part in file.split("/"))
|
|
363
|
+
if identity in identities:
|
|
364
|
+
_fail("PROPOSAL_PATH_INVALID", "create proposal paths are aliases")
|
|
365
|
+
identities.add(identity)
|
|
366
|
+
if not isinstance(item["content"], str) or len(item["content"]) > 65536:
|
|
367
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "create proposal file content is invalid")
|
|
368
|
+
total += len(item["content"].encode("utf-8"))
|
|
369
|
+
if total > MAX_SKILL_TOTAL_BYTES:
|
|
370
|
+
_fail("PROPOSAL_TOO_LARGE", "proposal content exceeds the total limit")
|
|
371
|
+
elif "patch" in record or "createSpec" in record:
|
|
372
|
+
_fail("PROPOSAL_SCHEMA_INVALID", "archive proposals carry no content")
|
|
373
|
+
if _secret_in(record):
|
|
374
|
+
_fail("PROPOSAL_SECRET_REFUSED", "credential-shaped proposal text was refused")
|
|
375
|
+
return record
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _proposal_path(proposal_id: str, workspace: str | os.PathLike[str] | None = None) -> Path:
|
|
379
|
+
if _ID.fullmatch(str(proposal_id)) is None:
|
|
380
|
+
_fail("PROPOSAL_ID_INVALID", "proposal id is invalid")
|
|
381
|
+
return proposal_directory(workspace) / f"{proposal_id}.json"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _validate_terminal_proposal_receipt(
|
|
385
|
+
record: dict[str, Any],
|
|
386
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
387
|
+
) -> None:
|
|
388
|
+
expected_operation = {"applied": "apply", "rolled-back": "rollback"}.get(record["status"])
|
|
389
|
+
if expected_operation is None:
|
|
390
|
+
return
|
|
391
|
+
receipt = next(
|
|
392
|
+
(
|
|
393
|
+
entry
|
|
394
|
+
for entry in read_ledger(hermes_home=hermes_home)
|
|
395
|
+
if entry["id"] == record["ledgerEntryId"]
|
|
396
|
+
),
|
|
397
|
+
None,
|
|
398
|
+
)
|
|
399
|
+
if receipt is None:
|
|
400
|
+
_fail("PROPOSAL_LEDGER_MISMATCH", "the terminal proposal ledger receipt is unavailable")
|
|
401
|
+
proposal_target = _canonical_ledger_target(
|
|
402
|
+
record,
|
|
403
|
+
code="PROPOSAL_LEDGER_MISMATCH",
|
|
404
|
+
message="the terminal proposal target is not canonical",
|
|
405
|
+
)
|
|
406
|
+
receipt_target = _canonical_ledger_target(
|
|
407
|
+
receipt,
|
|
408
|
+
code="PROPOSAL_LEDGER_MISMATCH",
|
|
409
|
+
message="the terminal proposal ledger target is not canonical",
|
|
410
|
+
)
|
|
411
|
+
if (
|
|
412
|
+
receipt["operation"] != expected_operation
|
|
413
|
+
or receipt["proposalId"] != record["id"]
|
|
414
|
+
or receipt_target != proposal_target
|
|
415
|
+
):
|
|
416
|
+
_fail("PROPOSAL_LEDGER_MISMATCH", "the terminal proposal does not match its ledger receipt")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _write_proposal(
|
|
420
|
+
record: dict[str, Any],
|
|
421
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
422
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
423
|
+
) -> None:
|
|
424
|
+
validate_proposal(record)
|
|
425
|
+
_validate_terminal_proposal_receipt(record, hermes_home)
|
|
426
|
+
_atomic_write(_proposal_path(record["id"], workspace), _json_bytes(record))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def load_proposal(
|
|
430
|
+
proposal_id: str,
|
|
431
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
432
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
433
|
+
) -> dict[str, Any]:
|
|
434
|
+
path = _proposal_path(proposal_id, workspace)
|
|
435
|
+
if not path.exists():
|
|
436
|
+
_fail("PROPOSAL_NOT_FOUND", "the proposal does not exist in this workspace")
|
|
437
|
+
record = _read_json_file(path)
|
|
438
|
+
validate_proposal(record)
|
|
439
|
+
_validate_terminal_proposal_receipt(record, hermes_home)
|
|
440
|
+
return record
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def propose(
|
|
444
|
+
file: str | os.PathLike[str], *, workspace: str | os.PathLike[str] | None = None,
|
|
445
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
446
|
+
) -> dict[str, Any]:
|
|
447
|
+
# targetRoot stays untrusted data until foreground apply.
|
|
448
|
+
record = validate_proposal(_read_json_file(Path(file).expanduser()))
|
|
449
|
+
if record["status"] != "pending":
|
|
450
|
+
_fail("PROPOSAL_STATUS_INVALID", "imported proposals must be pending")
|
|
451
|
+
destination = _proposal_path(record["id"], workspace)
|
|
452
|
+
if destination.exists():
|
|
453
|
+
_fail("PROPOSAL_EXISTS", "a proposal with this id already exists")
|
|
454
|
+
_write_proposal(record, workspace, hermes_home)
|
|
455
|
+
return {"ok": True, "id": record["id"]}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def list_proposals(
|
|
459
|
+
*, workspace: str | os.PathLike[str] | None = None,
|
|
460
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
461
|
+
) -> list[dict[str, Any]]:
|
|
462
|
+
directory = proposal_directory(workspace)
|
|
463
|
+
if not directory.exists():
|
|
464
|
+
return []
|
|
465
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
466
|
+
_fail("STATE_PATH_UNSAFE", "the proposal queue is not a directory")
|
|
467
|
+
paths: list[Path] = []
|
|
468
|
+
aggregate = 0
|
|
469
|
+
try:
|
|
470
|
+
with os.scandir(directory) as entries:
|
|
471
|
+
for item in entries:
|
|
472
|
+
if len(paths) >= MAX_PROPOSAL_FILES:
|
|
473
|
+
_fail("PROPOSAL_QUEUE_TOO_LARGE", "the proposal queue exceeds the file-count limit")
|
|
474
|
+
if not item.name.endswith(".json"):
|
|
475
|
+
_fail("STATE_PATH_UNSAFE", "the proposal queue contains an unexpected entry")
|
|
476
|
+
value = item.stat(follow_symlinks=False)
|
|
477
|
+
if stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode) or value.st_nlink != 1:
|
|
478
|
+
_fail("STATE_PATH_UNSAFE", "a queued proposal is not a regular single-link file")
|
|
479
|
+
aggregate += value.st_size
|
|
480
|
+
if aggregate > MAX_PROPOSAL_QUEUE_BYTES:
|
|
481
|
+
_fail("PROPOSAL_QUEUE_TOO_LARGE", "the proposal queue exceeds the aggregate byte limit")
|
|
482
|
+
paths.append(Path(item.path))
|
|
483
|
+
except SkillLoopError:
|
|
484
|
+
raise
|
|
485
|
+
except OSError:
|
|
486
|
+
_fail("STATE_PATH_UNSAFE", "the proposal queue cannot be inspected")
|
|
487
|
+
paths.sort()
|
|
488
|
+
records: list[dict[str, Any]] = []
|
|
489
|
+
for path in paths:
|
|
490
|
+
record = validate_proposal(_read_json_file(path))
|
|
491
|
+
_validate_terminal_proposal_receipt(record, hermes_home)
|
|
492
|
+
records.append(record)
|
|
493
|
+
return records
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _private_ledger_bytes(path: Path) -> bytes:
|
|
497
|
+
if not path.exists():
|
|
498
|
+
return b""
|
|
499
|
+
try:
|
|
500
|
+
value = path.lstat()
|
|
501
|
+
if (
|
|
502
|
+
stat.S_ISLNK(value.st_mode)
|
|
503
|
+
or not stat.S_ISREG(value.st_mode)
|
|
504
|
+
or value.st_nlink != 1
|
|
505
|
+
or value.st_mode & 0o077
|
|
506
|
+
):
|
|
507
|
+
_fail("LEDGER_MALFORMED", "the decision ledger path is unsafe")
|
|
508
|
+
raw = path.read_bytes()
|
|
509
|
+
except SkillLoopError:
|
|
510
|
+
raise
|
|
511
|
+
except OSError:
|
|
512
|
+
_fail("LEDGER_MALFORMED", "the decision ledger could not be read safely")
|
|
513
|
+
if len(raw) > 8 * 1024 * 1024:
|
|
514
|
+
_fail("LEDGER_MALFORMED", "the decision ledger exceeds the size limit")
|
|
515
|
+
return raw
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _parse_ledger_bytes(
|
|
519
|
+
raw: bytes,
|
|
520
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
521
|
+
) -> list[dict[str, Any]]:
|
|
522
|
+
try:
|
|
523
|
+
records = [
|
|
524
|
+
json.loads(line, object_pairs_hook=_reject_duplicate_keys)
|
|
525
|
+
for line in raw.decode("utf-8").splitlines()
|
|
526
|
+
if line.strip()
|
|
527
|
+
]
|
|
528
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
529
|
+
_fail("LEDGER_MALFORMED", "the decision ledger is malformed")
|
|
530
|
+
seen: set[str] = set()
|
|
531
|
+
for record in records:
|
|
532
|
+
validate_ledger_entry(record)
|
|
533
|
+
identifier = record.get("id")
|
|
534
|
+
if not isinstance(identifier, str) or identifier in seen:
|
|
535
|
+
_fail("LEDGER_MALFORMED", "the decision ledger contains a duplicate id")
|
|
536
|
+
seen.add(identifier)
|
|
537
|
+
_validate_ledger_integrity(records, hermes_home)
|
|
538
|
+
return records
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _append_jsonl(
|
|
542
|
+
path: Path,
|
|
543
|
+
record: dict[str, Any],
|
|
544
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
545
|
+
) -> None:
|
|
546
|
+
validate_ledger_entry(record)
|
|
547
|
+
payload = _json_bytes(record)
|
|
548
|
+
home = hermes_home if hermes_home is not None else path.parent.parent
|
|
549
|
+
try:
|
|
550
|
+
existing = _private_ledger_bytes(path)
|
|
551
|
+
if existing and not existing.endswith(b"\n"):
|
|
552
|
+
_fail("LEDGER_MALFORMED", "the decision ledger has an incomplete tail")
|
|
553
|
+
records = _parse_ledger_bytes(existing, home)
|
|
554
|
+
if any(item["id"] == record["id"] for item in records):
|
|
555
|
+
_fail("LEDGER_MALFORMED", "the decision ledger contains a duplicate id")
|
|
556
|
+
_validate_ledger_integrity([*records, record], home)
|
|
557
|
+
combined = existing + payload
|
|
558
|
+
if len(combined) > 8 * 1024 * 1024:
|
|
559
|
+
_fail("LEDGER_WRITE_FAILED", "the decision ledger exceeds the size limit")
|
|
560
|
+
_atomic_write(path, combined)
|
|
561
|
+
except SkillLoopError as error:
|
|
562
|
+
if error.code in {"LEDGER_MALFORMED", "BLOB_MISSING", "BLOB_STORE_CORRUPT"}:
|
|
563
|
+
raise
|
|
564
|
+
_fail("LEDGER_WRITE_FAILED", "the decision ledger could not be replaced atomically")
|
|
565
|
+
except OSError:
|
|
566
|
+
_fail("LEDGER_WRITE_FAILED", "the decision ledger could not be replaced atomically")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _validate_snapshot(value: Any) -> list[dict[str, Any]]:
|
|
570
|
+
if not isinstance(value, list) or len(value) > 256:
|
|
571
|
+
_fail("LEDGER_MALFORMED", "a decision ledger snapshot is invalid")
|
|
572
|
+
identities: set[str] = set()
|
|
573
|
+
for item in value:
|
|
574
|
+
if not isinstance(item, dict) or set(item) != {"file", "blob"}:
|
|
575
|
+
_fail("LEDGER_MALFORMED", "a decision ledger snapshot item is invalid")
|
|
576
|
+
try:
|
|
577
|
+
file = _validate_relative_file(item.get("file"), "ledger-snapshot")
|
|
578
|
+
except SkillLoopError:
|
|
579
|
+
_fail("LEDGER_MALFORMED", "a decision ledger snapshot path is invalid")
|
|
580
|
+
identity = "/".join(_canonical_component(part) for part in file.split("/"))
|
|
581
|
+
if identity in identities:
|
|
582
|
+
_fail("LEDGER_MALFORMED", "a decision ledger snapshot contains path aliases")
|
|
583
|
+
identities.add(identity)
|
|
584
|
+
blob = item.get("blob")
|
|
585
|
+
if (
|
|
586
|
+
not isinstance(blob, dict)
|
|
587
|
+
or set(blob) != {"algorithm", "digest", "size"}
|
|
588
|
+
or blob.get("algorithm") != "sha256"
|
|
589
|
+
or not isinstance(blob.get("digest"), str)
|
|
590
|
+
or re.fullmatch(r"[a-f0-9]{64}", blob["digest"]) is None
|
|
591
|
+
or not isinstance(blob.get("size"), int)
|
|
592
|
+
or isinstance(blob.get("size"), bool)
|
|
593
|
+
or blob["size"] < 0
|
|
594
|
+
):
|
|
595
|
+
_fail("LEDGER_MALFORMED", "a decision ledger blob record is invalid")
|
|
596
|
+
return value
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def validate_ledger_entry(record: Any) -> dict[str, Any]:
|
|
600
|
+
if not isinstance(record, dict) or set(record) - _LEDGER_FIELDS:
|
|
601
|
+
_fail("LEDGER_MALFORMED", "a decision ledger record has unknown fields")
|
|
602
|
+
required = {
|
|
603
|
+
"schema", "id", "createdAt", "proposalId", "actor", "operation",
|
|
604
|
+
"targetSkill", "targetRoot", "before", "after",
|
|
605
|
+
}
|
|
606
|
+
if not required.issubset(record) or record.get("schema") != LEDGER_SCHEMA:
|
|
607
|
+
_fail("LEDGER_MALFORMED", "a decision ledger record is missing required fields")
|
|
608
|
+
if not isinstance(record.get("id"), str) or _ID.fullmatch(record["id"]) is None:
|
|
609
|
+
_fail("LEDGER_MALFORMED", "a decision ledger id is invalid")
|
|
610
|
+
if not _strict_timestamp(record.get("createdAt")):
|
|
611
|
+
_fail("LEDGER_MALFORMED", "a decision ledger timestamp is invalid")
|
|
612
|
+
proposal_id = record.get("proposalId")
|
|
613
|
+
if proposal_id is not None and (
|
|
614
|
+
not isinstance(proposal_id, str) or not 1 <= len(proposal_id) <= 128
|
|
615
|
+
):
|
|
616
|
+
_fail("LEDGER_MALFORMED", "a decision ledger proposal id is invalid")
|
|
617
|
+
if record.get("actor") not in {"user", "curator"}:
|
|
618
|
+
_fail("LEDGER_MALFORMED", "a decision ledger actor is invalid")
|
|
619
|
+
operation = record.get("operation")
|
|
620
|
+
if operation not in {"apply", "reject", "rollback", "curator-transition"}:
|
|
621
|
+
_fail("LEDGER_MALFORMED", "a decision ledger operation is invalid")
|
|
622
|
+
if not isinstance(record.get("targetSkill"), str) or _SKILL_ID.fullmatch(record["targetSkill"]) is None:
|
|
623
|
+
_fail("LEDGER_MALFORMED", "a decision ledger target skill is invalid")
|
|
624
|
+
if not isinstance(record.get("targetRoot"), str) or not 1 <= len(record["targetRoot"]) <= 2048:
|
|
625
|
+
_fail("LEDGER_MALFORMED", "a decision ledger target root is invalid")
|
|
626
|
+
_validate_snapshot(record.get("before"))
|
|
627
|
+
_validate_snapshot(record.get("after"))
|
|
628
|
+
reason = record.get("reason")
|
|
629
|
+
if reason is not None and (not isinstance(reason, str) or len(reason) > 8192):
|
|
630
|
+
_fail("LEDGER_MALFORMED", "a decision ledger reason is invalid")
|
|
631
|
+
restores = record.get("restoresLedgerEntryId")
|
|
632
|
+
if operation == "rollback":
|
|
633
|
+
if not isinstance(restores, str) or not 1 <= len(restores) <= 128:
|
|
634
|
+
_fail("LEDGER_MALFORMED", "a rollback ledger record lacks its restored entry id")
|
|
635
|
+
elif restores is not None:
|
|
636
|
+
_fail("LEDGER_MALFORMED", "a non-rollback ledger record names a restored entry")
|
|
637
|
+
if operation in {"apply", "reject"} and (
|
|
638
|
+
record.get("actor") != "user" or not isinstance(proposal_id, str)
|
|
639
|
+
):
|
|
640
|
+
_fail("LEDGER_MALFORMED", "an apply or reject ledger record has invalid authority")
|
|
641
|
+
if operation == "rollback" and record.get("actor") != "user":
|
|
642
|
+
_fail("LEDGER_MALFORMED", "a rollback ledger record has invalid authority")
|
|
643
|
+
if operation == "curator-transition" and (
|
|
644
|
+
record.get("actor") != "curator" or proposal_id is not None
|
|
645
|
+
):
|
|
646
|
+
_fail("LEDGER_MALFORMED", "a curator ledger record has invalid authority")
|
|
647
|
+
return record
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _canonical_ledger_target(
|
|
651
|
+
record: dict[str, Any],
|
|
652
|
+
*,
|
|
653
|
+
code: str = "LEDGER_MALFORMED",
|
|
654
|
+
message: str = "a decision ledger target is not canonical",
|
|
655
|
+
) -> tuple[str, str]:
|
|
656
|
+
target_skill = record.get("targetSkill")
|
|
657
|
+
target_root = record.get("targetRoot")
|
|
658
|
+
if not isinstance(target_skill, str) or not isinstance(target_root, str):
|
|
659
|
+
_fail(code, message)
|
|
660
|
+
skill_identity = _portable_skill_identity(target_skill, code=code, message=message)
|
|
661
|
+
try:
|
|
662
|
+
root = Path(target_root)
|
|
663
|
+
if not root.is_absolute():
|
|
664
|
+
_fail(code, message)
|
|
665
|
+
root_identity = str(root.resolve(strict=False))
|
|
666
|
+
except (OSError, RuntimeError, ValueError):
|
|
667
|
+
_fail(code, message)
|
|
668
|
+
return skill_identity, root_identity
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def read_ledger(*, hermes_home: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]:
|
|
672
|
+
path = ledger_path(hermes_home)
|
|
673
|
+
if not path.exists():
|
|
674
|
+
return []
|
|
675
|
+
raw = _private_ledger_bytes(path)
|
|
676
|
+
if raw and not raw.endswith(b"\n"):
|
|
677
|
+
_fail("LEDGER_MALFORMED", "the decision ledger has an incomplete tail")
|
|
678
|
+
return _parse_ledger_bytes(raw, hermes_home)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _blob_record(data: bytes, hermes_home: str | os.PathLike[str] | None) -> dict[str, Any]:
|
|
682
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
683
|
+
directory = blob_directory(hermes_home)
|
|
684
|
+
_set_private_directory(directory)
|
|
685
|
+
path = directory / digest
|
|
686
|
+
if path.exists():
|
|
687
|
+
if path.is_symlink() or not path.is_file() or path.read_bytes() != data:
|
|
688
|
+
_fail("BLOB_STORE_CORRUPT", "a content-addressed blob does not match its name")
|
|
689
|
+
else:
|
|
690
|
+
_atomic_write(path, data)
|
|
691
|
+
return {"algorithm": "sha256", "digest": digest, "size": len(data)}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _snapshot(
|
|
695
|
+
target: Path,
|
|
696
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
697
|
+
*,
|
|
698
|
+
persist_blobs: bool = True,
|
|
699
|
+
) -> list[dict[str, Any]]:
|
|
700
|
+
if not target.exists():
|
|
701
|
+
return []
|
|
702
|
+
if target.is_symlink() or not target.is_dir():
|
|
703
|
+
_fail("TARGET_PATH_UNSAFE", "the skill target is not a directory")
|
|
704
|
+
records: list[dict[str, Any]] = []
|
|
705
|
+
total = 0
|
|
706
|
+
for path in sorted(target.rglob("*")):
|
|
707
|
+
value = path.lstat()
|
|
708
|
+
if stat.S_ISLNK(value.st_mode):
|
|
709
|
+
_fail("TARGET_PATH_UNSAFE", "skill packages cannot contain symbolic links")
|
|
710
|
+
if path.is_dir():
|
|
711
|
+
continue
|
|
712
|
+
if not stat.S_ISREG(value.st_mode) or value.st_nlink != 1 or value.st_size > MAX_SKILL_FILE_BYTES:
|
|
713
|
+
_fail("TARGET_PATH_UNSAFE", "skill packages must contain bounded regular files")
|
|
714
|
+
relative = path.relative_to(target).as_posix()
|
|
715
|
+
_validate_relative_file(relative, target.name)
|
|
716
|
+
data = path.read_bytes()
|
|
717
|
+
total += len(data)
|
|
718
|
+
if total > MAX_SKILL_TOTAL_BYTES or len(records) >= 256:
|
|
719
|
+
_fail("TARGET_TOO_LARGE", "the skill package exceeds the snapshot limit")
|
|
720
|
+
blob = (
|
|
721
|
+
_blob_record(data, hermes_home)
|
|
722
|
+
if persist_blobs
|
|
723
|
+
else {
|
|
724
|
+
"algorithm": "sha256",
|
|
725
|
+
"digest": hashlib.sha256(data).hexdigest(),
|
|
726
|
+
"size": len(data),
|
|
727
|
+
}
|
|
728
|
+
)
|
|
729
|
+
records.append({"file": relative, "blob": blob})
|
|
730
|
+
return records
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _snapshot_identity(snapshot: list[dict[str, Any]]) -> list[tuple[str, str, int]]:
|
|
734
|
+
return sorted((item["file"], item["blob"]["digest"], item["blob"]["size"]) for item in snapshot)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _path_matches_snapshot(
|
|
738
|
+
path: Path,
|
|
739
|
+
expected: Any,
|
|
740
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
741
|
+
) -> bool:
|
|
742
|
+
snapshot = _validate_snapshot(expected)
|
|
743
|
+
present = path.exists() or path.is_symlink()
|
|
744
|
+
if not snapshot:
|
|
745
|
+
return not present
|
|
746
|
+
if not present:
|
|
747
|
+
return False
|
|
748
|
+
try:
|
|
749
|
+
current = _snapshot(path, hermes_home, persist_blobs=False)
|
|
750
|
+
except SkillLoopError:
|
|
751
|
+
return False
|
|
752
|
+
return _snapshot_identity(current) == _snapshot_identity(snapshot)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _blob_bytes(record: dict[str, Any], hermes_home: str | os.PathLike[str] | None) -> bytes:
|
|
756
|
+
try:
|
|
757
|
+
digest = record["digest"]
|
|
758
|
+
size = record["size"]
|
|
759
|
+
except (KeyError, TypeError):
|
|
760
|
+
_fail("BLOB_STORE_CORRUPT", "a ledger blob record is malformed")
|
|
761
|
+
if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest) or not isinstance(size, int):
|
|
762
|
+
_fail("BLOB_STORE_CORRUPT", "a ledger blob record is malformed")
|
|
763
|
+
path = blob_directory(hermes_home) / digest
|
|
764
|
+
try:
|
|
765
|
+
value = path.lstat()
|
|
766
|
+
if (
|
|
767
|
+
stat.S_ISLNK(value.st_mode)
|
|
768
|
+
or not stat.S_ISREG(value.st_mode)
|
|
769
|
+
or value.st_nlink != 1
|
|
770
|
+
or value.st_mode & 0o077
|
|
771
|
+
):
|
|
772
|
+
raise OSError
|
|
773
|
+
data = path.read_bytes()
|
|
774
|
+
except OSError:
|
|
775
|
+
_fail("BLOB_MISSING", "a rollback blob is unavailable")
|
|
776
|
+
if len(data) != size or hashlib.sha256(data).hexdigest() != digest:
|
|
777
|
+
_fail("BLOB_STORE_CORRUPT", "a rollback blob failed verification")
|
|
778
|
+
return data
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _validate_snapshot_blobs(
|
|
782
|
+
snapshot: Any,
|
|
783
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
784
|
+
verified: set[tuple[str, int]] | None = None,
|
|
785
|
+
) -> list[dict[str, Any]]:
|
|
786
|
+
records = _validate_snapshot(snapshot)
|
|
787
|
+
cache = verified if verified is not None else set()
|
|
788
|
+
for item in records:
|
|
789
|
+
blob = item["blob"]
|
|
790
|
+
identity = (blob["digest"], blob["size"])
|
|
791
|
+
if identity in cache:
|
|
792
|
+
continue
|
|
793
|
+
_blob_bytes(blob, hermes_home)
|
|
794
|
+
cache.add(identity)
|
|
795
|
+
return records
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _validate_ledger_integrity(
|
|
799
|
+
records: list[dict[str, Any]],
|
|
800
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
801
|
+
) -> None:
|
|
802
|
+
by_id = {record["id"]: record for record in records}
|
|
803
|
+
positions = {record["id"]: index for index, record in enumerate(records)}
|
|
804
|
+
edges: dict[str, str] = {}
|
|
805
|
+
for record in records:
|
|
806
|
+
if record["operation"] != "rollback":
|
|
807
|
+
continue
|
|
808
|
+
target = record["restoresLedgerEntryId"]
|
|
809
|
+
if target not in by_id:
|
|
810
|
+
_fail("LEDGER_MALFORMED", "a rollback ledger reference is missing")
|
|
811
|
+
edges[record["id"]] = target
|
|
812
|
+
|
|
813
|
+
visited: set[str] = set()
|
|
814
|
+
for identifier in edges:
|
|
815
|
+
chain: set[str] = set()
|
|
816
|
+
current = identifier
|
|
817
|
+
while current in edges and current not in visited:
|
|
818
|
+
if current in chain:
|
|
819
|
+
_fail("LEDGER_MALFORMED", "the decision ledger contains a rollback reference cycle")
|
|
820
|
+
chain.add(current)
|
|
821
|
+
current = edges[current]
|
|
822
|
+
visited.update(chain)
|
|
823
|
+
|
|
824
|
+
for identifier, target in edges.items():
|
|
825
|
+
referenced = by_id[target]
|
|
826
|
+
rollback = by_id[identifier]
|
|
827
|
+
if (
|
|
828
|
+
referenced["operation"] not in {"apply", "curator-transition", "rollback"}
|
|
829
|
+
or positions[target] >= positions[identifier]
|
|
830
|
+
):
|
|
831
|
+
_fail("LEDGER_MALFORMED", "a rollback must restore an earlier mutation entry")
|
|
832
|
+
if _canonical_ledger_target(rollback) != _canonical_ledger_target(referenced):
|
|
833
|
+
_fail("LEDGER_MALFORMED", "a rollback reference does not match its canonical target")
|
|
834
|
+
if rollback["proposalId"] != referenced["proposalId"]:
|
|
835
|
+
_fail("LEDGER_MALFORMED", "a rollback reference does not match mutation authority")
|
|
836
|
+
if (
|
|
837
|
+
_snapshot_identity(rollback["before"])
|
|
838
|
+
!= _snapshot_identity(referenced["after"])
|
|
839
|
+
or _snapshot_identity(rollback["after"])
|
|
840
|
+
!= _snapshot_identity(referenced["before"])
|
|
841
|
+
):
|
|
842
|
+
_fail("LEDGER_MALFORMED", "a rollback reference is not the exact snapshot inverse")
|
|
843
|
+
|
|
844
|
+
verified: set[tuple[str, int]] = set()
|
|
845
|
+
for record in records:
|
|
846
|
+
_validate_snapshot_blobs(record["before"], hermes_home, verified)
|
|
847
|
+
_validate_snapshot_blobs(record["after"], hermes_home, verified)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _copy_skill(source: Path, destination: Path) -> None:
|
|
851
|
+
destination.mkdir(mode=0o700)
|
|
852
|
+
for path in sorted(source.rglob("*")):
|
|
853
|
+
relative = path.relative_to(source)
|
|
854
|
+
value = path.lstat()
|
|
855
|
+
if stat.S_ISLNK(value.st_mode):
|
|
856
|
+
_fail("TARGET_PATH_UNSAFE", "skill packages cannot contain symbolic links")
|
|
857
|
+
target = destination / relative
|
|
858
|
+
if path.is_dir():
|
|
859
|
+
target.mkdir(mode=0o700)
|
|
860
|
+
elif stat.S_ISREG(value.st_mode) and value.st_nlink == 1:
|
|
861
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
862
|
+
target.write_bytes(path.read_bytes())
|
|
863
|
+
target.chmod(0o600)
|
|
864
|
+
else:
|
|
865
|
+
_fail("TARGET_PATH_UNSAFE", "skill packages must contain regular files")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _restore_snapshot(snapshot: list[dict[str, Any]], destination: Path, hermes_home: str | os.PathLike[str] | None) -> None:
|
|
869
|
+
_validate_snapshot_blobs(snapshot, hermes_home)
|
|
870
|
+
destination.mkdir(mode=0o700)
|
|
871
|
+
identities: set[str] = set()
|
|
872
|
+
for item in snapshot:
|
|
873
|
+
file = _validate_relative_file(item.get("file"), destination.name)
|
|
874
|
+
identity = "/".join(_canonical_component(part) for part in file.split("/"))
|
|
875
|
+
if identity in identities:
|
|
876
|
+
_fail("LEDGER_MALFORMED", "a ledger snapshot contains path aliases")
|
|
877
|
+
identities.add(identity)
|
|
878
|
+
target = destination.joinpath(*file.split("/"))
|
|
879
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
880
|
+
target.write_bytes(_blob_bytes(item.get("blob"), hermes_home))
|
|
881
|
+
target.chmod(0o600)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _frontmatter_text(text: str) -> str | None:
|
|
885
|
+
if not text.startswith("---\n"):
|
|
886
|
+
return None
|
|
887
|
+
end = text.find("\n---", 4)
|
|
888
|
+
if end < 0:
|
|
889
|
+
return None
|
|
890
|
+
return text[4:end]
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _yaml_mapping_line(line: str) -> tuple[int, str, str] | None:
|
|
894
|
+
if "\t" in line:
|
|
895
|
+
return None
|
|
896
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
897
|
+
content = line[indent:]
|
|
898
|
+
if not content or content.startswith("#"):
|
|
899
|
+
return None
|
|
900
|
+
if content[0] == '"':
|
|
901
|
+
escaped = False
|
|
902
|
+
close = -1
|
|
903
|
+
for index, character in enumerate(content[1:], 1):
|
|
904
|
+
if escaped:
|
|
905
|
+
escaped = False
|
|
906
|
+
elif character == "\\":
|
|
907
|
+
escaped = True
|
|
908
|
+
elif character == '"':
|
|
909
|
+
close = index
|
|
910
|
+
break
|
|
911
|
+
if close < 0:
|
|
912
|
+
return None
|
|
913
|
+
try:
|
|
914
|
+
key = json.loads(content[: close + 1])
|
|
915
|
+
except (json.JSONDecodeError, ValueError):
|
|
916
|
+
return None
|
|
917
|
+
suffix = content[close + 1 :].lstrip(" ")
|
|
918
|
+
elif content[0] == "'":
|
|
919
|
+
index = 1
|
|
920
|
+
decoded: list[str] = []
|
|
921
|
+
while index < len(content):
|
|
922
|
+
if content[index] != "'":
|
|
923
|
+
decoded.append(content[index])
|
|
924
|
+
index += 1
|
|
925
|
+
continue
|
|
926
|
+
if index + 1 < len(content) and content[index + 1] == "'":
|
|
927
|
+
decoded.append("'")
|
|
928
|
+
index += 2
|
|
929
|
+
continue
|
|
930
|
+
break
|
|
931
|
+
else:
|
|
932
|
+
return None
|
|
933
|
+
key = "".join(decoded)
|
|
934
|
+
suffix = content[index + 1 :].lstrip(" ")
|
|
935
|
+
else:
|
|
936
|
+
match = re.search(r":(?=\s|$)", content)
|
|
937
|
+
if match is None:
|
|
938
|
+
return None
|
|
939
|
+
key = content[: match.start()].strip()
|
|
940
|
+
suffix = content[match.start() :]
|
|
941
|
+
if not key or key[0] in "?-{}[]!&*#|>@`":
|
|
942
|
+
return None
|
|
943
|
+
if not isinstance(key, str) or not suffix.startswith(":"):
|
|
944
|
+
return None
|
|
945
|
+
return indent, key, suffix[1:].strip()
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _has_marker(skill_md: Path) -> bool:
|
|
949
|
+
try:
|
|
950
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
951
|
+
except (OSError, UnicodeDecodeError):
|
|
952
|
+
return False
|
|
953
|
+
return _has_marker_text(text)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _stamp_marker(text: str) -> str:
|
|
957
|
+
if not text.startswith("---\n"):
|
|
958
|
+
_fail("SKILL_FRONTMATTER_INVALID", "SKILL.md must start with YAML frontmatter")
|
|
959
|
+
end = text.find("\n---", 4)
|
|
960
|
+
if end < 0:
|
|
961
|
+
_fail("SKILL_FRONTMATTER_INVALID", "SKILL.md frontmatter is not closed")
|
|
962
|
+
frontmatter = text[4:end]
|
|
963
|
+
if _has_marker_text(text):
|
|
964
|
+
return text
|
|
965
|
+
if re.search(rf"(?m)^\s*{MARKER}\s*:", frontmatter):
|
|
966
|
+
_fail("SKILL_MARKER_INVALID", "the agent ownership marker must be metadata")
|
|
967
|
+
match = re.search(r"(?m)^metadata:\s*$", frontmatter)
|
|
968
|
+
if match:
|
|
969
|
+
insert = match.end()
|
|
970
|
+
updated = frontmatter[:insert] + f'\n {MARKER}: "true"' + frontmatter[insert:]
|
|
971
|
+
elif re.search(r"(?m)^metadata:\s*\{", frontmatter):
|
|
972
|
+
_fail("SKILL_MARKER_INVALID", "inline metadata cannot be stamped safely")
|
|
973
|
+
else:
|
|
974
|
+
updated = frontmatter.rstrip("\n") + f'\nmetadata:\n {MARKER}: "true"\n'
|
|
975
|
+
return text[:4] + updated + text[end:]
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _has_marker_text(text: str) -> bool:
|
|
979
|
+
frontmatter = _frontmatter_text(text)
|
|
980
|
+
if frontmatter is None:
|
|
981
|
+
return False
|
|
982
|
+
lines = frontmatter.splitlines()
|
|
983
|
+
metadata_indexes: list[int] = []
|
|
984
|
+
for index, line in enumerate(lines):
|
|
985
|
+
if not line.strip() or line.lstrip(" ").startswith("#"):
|
|
986
|
+
continue
|
|
987
|
+
parsed = _yaml_mapping_line(line)
|
|
988
|
+
if parsed is None:
|
|
989
|
+
if len(line) == len(line.lstrip(" ")):
|
|
990
|
+
return False
|
|
991
|
+
continue
|
|
992
|
+
indent, key, _value = parsed
|
|
993
|
+
if indent == 0 and key == "metadata":
|
|
994
|
+
metadata_indexes.append(index)
|
|
995
|
+
if len(metadata_indexes) != 1:
|
|
996
|
+
return False
|
|
997
|
+
start = metadata_indexes[0]
|
|
998
|
+
parsed_metadata = _yaml_mapping_line(lines[start])
|
|
999
|
+
if parsed_metadata is None or parsed_metadata[2]:
|
|
1000
|
+
return False
|
|
1001
|
+
block: list[tuple[int, str, str]] = []
|
|
1002
|
+
direct_indent: int | None = None
|
|
1003
|
+
for line in lines[start + 1 :]:
|
|
1004
|
+
if not line.strip() or line.lstrip(" ").startswith("#"):
|
|
1005
|
+
continue
|
|
1006
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
1007
|
+
if indent == 0:
|
|
1008
|
+
break
|
|
1009
|
+
if direct_indent is None:
|
|
1010
|
+
direct_indent = indent
|
|
1011
|
+
if indent < direct_indent:
|
|
1012
|
+
return False
|
|
1013
|
+
if indent == direct_indent:
|
|
1014
|
+
parsed = _yaml_mapping_line(line)
|
|
1015
|
+
if parsed is None:
|
|
1016
|
+
return False
|
|
1017
|
+
block.append(parsed)
|
|
1018
|
+
markers = [value for _indent, key, value in block if key == MARKER]
|
|
1019
|
+
return len(markers) == 1 and markers[0] in {'"true"', "'true'"}
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _shipped_catalog_identities() -> set[str]:
|
|
1023
|
+
root = Path(__file__).resolve().parent / "skills"
|
|
1024
|
+
manifest = root.parent / "payload-version.json"
|
|
1025
|
+
try:
|
|
1026
|
+
value = root.lstat()
|
|
1027
|
+
except OSError:
|
|
1028
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog is unavailable")
|
|
1029
|
+
if stat.S_ISLNK(value.st_mode) or not stat.S_ISDIR(value.st_mode):
|
|
1030
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog root is unsafe")
|
|
1031
|
+
identities: set[str] = set()
|
|
1032
|
+
try:
|
|
1033
|
+
manifest_value = manifest.lstat()
|
|
1034
|
+
if (
|
|
1035
|
+
stat.S_ISLNK(manifest_value.st_mode)
|
|
1036
|
+
or not stat.S_ISREG(manifest_value.st_mode)
|
|
1037
|
+
or manifest_value.st_nlink != 1
|
|
1038
|
+
or manifest_value.st_size > MAX_PROPOSAL_BYTES
|
|
1039
|
+
):
|
|
1040
|
+
raise OSError
|
|
1041
|
+
payload = json.loads(
|
|
1042
|
+
manifest.read_text(encoding="utf-8"),
|
|
1043
|
+
object_pairs_hook=_reject_duplicate_keys,
|
|
1044
|
+
)
|
|
1045
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
1046
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped payload identity manifest is unavailable")
|
|
1047
|
+
files = payload.get("files") if isinstance(payload, dict) else None
|
|
1048
|
+
if not isinstance(files, list) or len(files) > 10000:
|
|
1049
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped payload identity manifest is malformed")
|
|
1050
|
+
for item in files:
|
|
1051
|
+
path = item.get("path") if isinstance(item, dict) else None
|
|
1052
|
+
if not isinstance(path, str):
|
|
1053
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped payload identity manifest is malformed")
|
|
1054
|
+
parts = path.split("/")
|
|
1055
|
+
if len(parts) < 3 or parts[0] != "skills":
|
|
1056
|
+
continue
|
|
1057
|
+
identity = _portable_skill_identity(
|
|
1058
|
+
parts[1],
|
|
1059
|
+
code="SHIPPED_CATALOG_UNSAFE",
|
|
1060
|
+
message="the shipped payload manifest contains a nonportable skill identity",
|
|
1061
|
+
)
|
|
1062
|
+
identities.add(identity)
|
|
1063
|
+
disk_identities: set[str] = set()
|
|
1064
|
+
try:
|
|
1065
|
+
entries = list(root.iterdir())
|
|
1066
|
+
except OSError:
|
|
1067
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog cannot be inspected")
|
|
1068
|
+
for entry in entries:
|
|
1069
|
+
try:
|
|
1070
|
+
entry_value = entry.lstat()
|
|
1071
|
+
except OSError:
|
|
1072
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "a shipped skill identity cannot be inspected")
|
|
1073
|
+
if stat.S_ISLNK(entry_value.st_mode):
|
|
1074
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog contains a symbolic link")
|
|
1075
|
+
if not stat.S_ISDIR(entry_value.st_mode):
|
|
1076
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog contains a non-directory entry")
|
|
1077
|
+
identity = _portable_skill_identity(
|
|
1078
|
+
entry.name,
|
|
1079
|
+
code="SHIPPED_CATALOG_UNSAFE",
|
|
1080
|
+
message="the shipped skill catalog contains a nonportable identity",
|
|
1081
|
+
)
|
|
1082
|
+
if identity in disk_identities:
|
|
1083
|
+
_fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog contains aliases")
|
|
1084
|
+
disk_identities.add(identity)
|
|
1085
|
+
identities.add(identity)
|
|
1086
|
+
return identities
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _validate_apply_target(record: dict[str, Any], hermes_home: str | os.PathLike[str] | None) -> tuple[Path, Path]:
|
|
1090
|
+
root = skills_root(hermes_home)
|
|
1091
|
+
if root.is_symlink():
|
|
1092
|
+
_fail("TARGET_PATH_UNSAFE", "the Hermes user skill root cannot be a symbolic link")
|
|
1093
|
+
expected_root = root.resolve()
|
|
1094
|
+
plugin_root = (_hermes_home(hermes_home) / "plugins" / "lithermes").resolve()
|
|
1095
|
+
try:
|
|
1096
|
+
claimed = Path(record["targetRoot"]).expanduser().resolve()
|
|
1097
|
+
except (OSError, RuntimeError):
|
|
1098
|
+
_fail("TARGET_ROOT_MISMATCH", "the proposal target root is invalid")
|
|
1099
|
+
if claimed != expected_root:
|
|
1100
|
+
if claimed == plugin_root or plugin_root in claimed.parents:
|
|
1101
|
+
_fail("TARGET_NOT_AGENT_OWNED", "plugin payload skills are read-only")
|
|
1102
|
+
_fail("TARGET_ROOT_MISMATCH", "the proposal target root is not the Hermes user skill root")
|
|
1103
|
+
target_identity = _portable_skill_identity(
|
|
1104
|
+
record["targetSkill"],
|
|
1105
|
+
code="TARGET_NOT_AGENT_OWNED",
|
|
1106
|
+
message="the target skill identity is not portable",
|
|
1107
|
+
)
|
|
1108
|
+
if target_identity in _shipped_catalog_identities():
|
|
1109
|
+
_fail("TARGET_NOT_AGENT_OWNED", "shipped skill ids are read-only")
|
|
1110
|
+
_ensure_user_skill_root(root)
|
|
1111
|
+
root = root.resolve(strict=True)
|
|
1112
|
+
target = root / record["targetSkill"]
|
|
1113
|
+
if target.exists() and (target.is_symlink() or not target.is_dir()):
|
|
1114
|
+
_fail("TARGET_PATH_UNSAFE", "the target skill path is unsafe")
|
|
1115
|
+
if target.exists() and not _has_marker(target / "SKILL.md"):
|
|
1116
|
+
_fail("TARGET_NOT_AGENT_OWNED", "an existing unmarked skill is not agent-owned")
|
|
1117
|
+
return root, target
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@contextmanager
|
|
1121
|
+
def _decision_lock(hermes_home: str | os.PathLike[str] | None):
|
|
1122
|
+
if fcntl is None:
|
|
1123
|
+
_fail("LOCK_UNAVAILABLE", "bounded decision locking is unavailable")
|
|
1124
|
+
root = _state_root(hermes_home)
|
|
1125
|
+
_set_private_directory(root)
|
|
1126
|
+
path = root / "skill-loop.lock"
|
|
1127
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
1128
|
+
descriptor = os.open(path, flags, 0o600)
|
|
1129
|
+
try:
|
|
1130
|
+
deadline = time.monotonic() + 1.0
|
|
1131
|
+
while True:
|
|
1132
|
+
try:
|
|
1133
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1134
|
+
break
|
|
1135
|
+
except BlockingIOError:
|
|
1136
|
+
if time.monotonic() >= deadline:
|
|
1137
|
+
_fail("LOCK_TIMEOUT", "the skill-loop decision lock wait expired")
|
|
1138
|
+
time.sleep(0.01)
|
|
1139
|
+
yield
|
|
1140
|
+
finally:
|
|
1141
|
+
try:
|
|
1142
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
1143
|
+
finally:
|
|
1144
|
+
os.close(descriptor)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _safe_remove_generated(path: Path, parent: Path) -> None:
|
|
1148
|
+
if not path.exists() and not path.is_symlink():
|
|
1149
|
+
return
|
|
1150
|
+
if path.parent != parent or not path.name.startswith(".skill-loop-") or path.is_symlink():
|
|
1151
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "a transaction artifact is unsafe")
|
|
1152
|
+
if path.is_dir():
|
|
1153
|
+
shutil.rmtree(path)
|
|
1154
|
+
else:
|
|
1155
|
+
path.unlink()
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _journal_write(record: dict[str, Any], hermes_home: str | os.PathLike[str] | None) -> None:
|
|
1159
|
+
_atomic_write(_transaction_path(hermes_home), _json_bytes(record))
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _retire_invalid_transaction_journal(path: Path) -> None:
|
|
1163
|
+
retired = path.parent / f".skill-loop-invalid-transaction-{uuid.uuid4().hex}.json"
|
|
1164
|
+
try:
|
|
1165
|
+
os.replace(path, retired)
|
|
1166
|
+
retired.chmod(0o600)
|
|
1167
|
+
except OSError:
|
|
1168
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the invalid transaction journal could not be retired safely")
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _discard_transaction_stage(stage: Path | None, root: Path) -> None:
|
|
1172
|
+
if stage is None or (not stage.exists() and not stage.is_symlink()):
|
|
1173
|
+
return
|
|
1174
|
+
if stage.parent != root or not stage.name.startswith(".skill-loop-stage-"):
|
|
1175
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "a transaction stage is unsafe")
|
|
1176
|
+
if stage.is_symlink():
|
|
1177
|
+
stage.unlink()
|
|
1178
|
+
else:
|
|
1179
|
+
_safe_remove_generated(stage, root)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _require_recovery_snapshot(
|
|
1183
|
+
path: Path,
|
|
1184
|
+
expected: Any,
|
|
1185
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
1186
|
+
label: str,
|
|
1187
|
+
) -> None:
|
|
1188
|
+
if not _path_matches_snapshot(path, expected, hermes_home):
|
|
1189
|
+
_fail("TRANSACTION_CAS_MISMATCH", f"the recovery {label} changed after the transaction")
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _remove_recovery_artifact(
|
|
1193
|
+
path: Path,
|
|
1194
|
+
root: Path,
|
|
1195
|
+
expected: Any,
|
|
1196
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
1197
|
+
label: str,
|
|
1198
|
+
) -> None:
|
|
1199
|
+
if not path.exists() and not path.is_symlink():
|
|
1200
|
+
if _validate_snapshot(expected):
|
|
1201
|
+
_fail("TRANSACTION_CAS_MISMATCH", f"the recovery {label} disappeared after the transaction")
|
|
1202
|
+
return
|
|
1203
|
+
_require_recovery_snapshot(path, expected, hermes_home, label)
|
|
1204
|
+
_safe_remove_generated(path, root)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _recover_partial_ledger_tail(
|
|
1208
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
1209
|
+
expected_entry: dict[str, Any],
|
|
1210
|
+
) -> None:
|
|
1211
|
+
path = ledger_path(hermes_home)
|
|
1212
|
+
raw = _private_ledger_bytes(path)
|
|
1213
|
+
if not raw or raw.endswith(b"\n"):
|
|
1214
|
+
return
|
|
1215
|
+
expected = _json_bytes(validate_ledger_entry(expected_entry))
|
|
1216
|
+
boundary = raw.rfind(b"\n")
|
|
1217
|
+
prefix = raw[: boundary + 1] if boundary >= 0 else b""
|
|
1218
|
+
tail = raw[boundary + 1 :]
|
|
1219
|
+
if not tail or not expected.startswith(tail):
|
|
1220
|
+
_fail("LEDGER_MALFORMED", "the incomplete decision ledger tail does not match recovery")
|
|
1221
|
+
_parse_ledger_bytes(prefix, hermes_home)
|
|
1222
|
+
try:
|
|
1223
|
+
_atomic_write(path, prefix)
|
|
1224
|
+
except (OSError, SkillLoopError):
|
|
1225
|
+
_fail("LEDGER_WRITE_FAILED", "the incomplete decision ledger tail could not be removed safely")
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _restore_transaction_backup(
|
|
1229
|
+
target: Path,
|
|
1230
|
+
backup: Path,
|
|
1231
|
+
stage: Path | None,
|
|
1232
|
+
root: Path,
|
|
1233
|
+
before: Any,
|
|
1234
|
+
after: Any,
|
|
1235
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
1236
|
+
) -> None:
|
|
1237
|
+
_require_recovery_snapshot(backup, before, hermes_home, "backup")
|
|
1238
|
+
target_present = target.exists() or target.is_symlink()
|
|
1239
|
+
stage_present = stage is not None and (stage.exists() or stage.is_symlink())
|
|
1240
|
+
if target_present:
|
|
1241
|
+
_require_recovery_snapshot(target, after, hermes_home, "live postimage")
|
|
1242
|
+
if stage_present and stage is not None:
|
|
1243
|
+
_require_recovery_snapshot(stage, after, hermes_home, "stage")
|
|
1244
|
+
if _validate_snapshot(after) and not target_present and not stage_present:
|
|
1245
|
+
_fail("TRANSACTION_CAS_MISMATCH", "the recovery postimage disappeared after the transaction")
|
|
1246
|
+
quarantine: Path | None = None
|
|
1247
|
+
if target_present:
|
|
1248
|
+
_require_recovery_snapshot(target, after, hermes_home, "live postimage")
|
|
1249
|
+
quarantine = root / f".skill-loop-quarantine-{uuid.uuid4().hex}"
|
|
1250
|
+
try:
|
|
1251
|
+
os.replace(target, quarantine)
|
|
1252
|
+
except OSError:
|
|
1253
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the interrupted postimage could not be preserved")
|
|
1254
|
+
_require_recovery_snapshot(backup, before, hermes_home, "backup")
|
|
1255
|
+
try:
|
|
1256
|
+
os.replace(backup, target)
|
|
1257
|
+
except OSError:
|
|
1258
|
+
if quarantine is not None and not target.exists() and not target.is_symlink():
|
|
1259
|
+
try:
|
|
1260
|
+
os.replace(quarantine, target)
|
|
1261
|
+
except OSError:
|
|
1262
|
+
pass
|
|
1263
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the transaction backup could not be restored")
|
|
1264
|
+
_require_recovery_snapshot(target, before, hermes_home, "restored target")
|
|
1265
|
+
if quarantine is not None:
|
|
1266
|
+
_remove_recovery_artifact(
|
|
1267
|
+
quarantine, root, after, hermes_home, "preserved postimage"
|
|
1268
|
+
)
|
|
1269
|
+
if stage is not None and (stage.exists() or stage.is_symlink()):
|
|
1270
|
+
_remove_recovery_artifact(stage, root, after, hermes_home, "stage")
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _recover_transaction(hermes_home: str | os.PathLike[str] | None) -> None:
|
|
1274
|
+
journal_path = _transaction_path(hermes_home)
|
|
1275
|
+
if not journal_path.exists():
|
|
1276
|
+
return
|
|
1277
|
+
journal = _read_json_file(journal_path)
|
|
1278
|
+
if not isinstance(journal, dict) or journal.get("schema") != "lithermes.skill-loop-transaction/v1":
|
|
1279
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the skill-loop transaction journal is malformed")
|
|
1280
|
+
if journal.get("kind") == "reject":
|
|
1281
|
+
entry = validate_ledger_entry(journal.get("ledgerEntry"))
|
|
1282
|
+
proposal_id = journal.get("proposalId")
|
|
1283
|
+
workspace = journal.get("workspace")
|
|
1284
|
+
if (
|
|
1285
|
+
entry.get("operation") != "reject" or entry.get("proposalId") != proposal_id
|
|
1286
|
+
or not isinstance(proposal_id, str) or not isinstance(workspace, str)
|
|
1287
|
+
):
|
|
1288
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the reject transaction journal is malformed")
|
|
1289
|
+
_recover_partial_ledger_tail(hermes_home, entry)
|
|
1290
|
+
committed = any(
|
|
1291
|
+
item.get("id") == entry["id"] for item in read_ledger(hermes_home=hermes_home)
|
|
1292
|
+
)
|
|
1293
|
+
proposal = load_proposal(proposal_id, workspace, hermes_home)
|
|
1294
|
+
if committed:
|
|
1295
|
+
if proposal["status"] == "pending":
|
|
1296
|
+
proposal["status"] = "rejected"
|
|
1297
|
+
_write_proposal(proposal, workspace, hermes_home)
|
|
1298
|
+
elif proposal["status"] != "rejected":
|
|
1299
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the committed reject has an incompatible proposal state")
|
|
1300
|
+
elif proposal["status"] != "pending":
|
|
1301
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "an uncommitted reject changed proposal state")
|
|
1302
|
+
journal_path.unlink()
|
|
1303
|
+
return
|
|
1304
|
+
if journal.get("kind") not in {None, "mutation"}:
|
|
1305
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the skill-loop transaction kind is unknown")
|
|
1306
|
+
try:
|
|
1307
|
+
target = Path(journal["target"])
|
|
1308
|
+
except (KeyError, TypeError):
|
|
1309
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the mutation transaction target is malformed")
|
|
1310
|
+
root = skills_root(hermes_home).resolve()
|
|
1311
|
+
target_skill = journal.get("targetSkill")
|
|
1312
|
+
proposal_id = journal.get("proposalId")
|
|
1313
|
+
workspace = journal.get("workspace")
|
|
1314
|
+
proposal_status = journal.get("proposalStatus")
|
|
1315
|
+
proposal_bound = isinstance(proposal_id, str)
|
|
1316
|
+
proposal_state_valid = (
|
|
1317
|
+
(
|
|
1318
|
+
proposal_bound
|
|
1319
|
+
and isinstance(workspace, str)
|
|
1320
|
+
and proposal_status in {"applied", "rolled-back"}
|
|
1321
|
+
)
|
|
1322
|
+
or (
|
|
1323
|
+
proposal_id is None
|
|
1324
|
+
and workspace is None
|
|
1325
|
+
and proposal_status is None
|
|
1326
|
+
)
|
|
1327
|
+
)
|
|
1328
|
+
if (
|
|
1329
|
+
target.parent != root or target.name != target_skill
|
|
1330
|
+
or not isinstance(target_skill, str) or _SKILL_ID.fullmatch(target_skill) is None
|
|
1331
|
+
or not isinstance(journal.get("ledgerId"), str)
|
|
1332
|
+
or _ID.fullmatch(journal["ledgerId"]) is None
|
|
1333
|
+
or not proposal_state_valid
|
|
1334
|
+
):
|
|
1335
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the transaction target is outside the user skill root")
|
|
1336
|
+
backup_name = journal.get("backup")
|
|
1337
|
+
stage_name = journal.get("stage")
|
|
1338
|
+
if backup_name is not None and (
|
|
1339
|
+
not isinstance(backup_name, str)
|
|
1340
|
+
or re.fullmatch(r"\.skill-loop-backup-[a-f0-9]{32}", backup_name) is None
|
|
1341
|
+
):
|
|
1342
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the transaction backup name is malformed")
|
|
1343
|
+
if stage_name is not None and (
|
|
1344
|
+
not isinstance(stage_name, str)
|
|
1345
|
+
or re.fullmatch(r"\.skill-loop-stage-[a-f0-9]{32}", stage_name) is None
|
|
1346
|
+
):
|
|
1347
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the transaction stage name is malformed")
|
|
1348
|
+
backup = root / backup_name if backup_name else None
|
|
1349
|
+
stage = root / stage_name if stage_name else None
|
|
1350
|
+
journal_entry = journal.get("ledgerEntry")
|
|
1351
|
+
if journal_entry is not None:
|
|
1352
|
+
journal_entry = validate_ledger_entry(journal_entry)
|
|
1353
|
+
expected_operation = "rollback" if proposal_status in {None, "rolled-back"} else "apply"
|
|
1354
|
+
if (
|
|
1355
|
+
journal_entry["id"] != journal["ledgerId"]
|
|
1356
|
+
or journal_entry["proposalId"] != proposal_id
|
|
1357
|
+
or journal_entry["operation"] != expected_operation
|
|
1358
|
+
or journal_entry["targetSkill"] != target_skill
|
|
1359
|
+
or journal_entry["targetRoot"] != str(root)
|
|
1360
|
+
):
|
|
1361
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the transaction ledger receipt is inconsistent")
|
|
1362
|
+
try:
|
|
1363
|
+
duplicate_before = _validate_snapshot(journal.get("before"))
|
|
1364
|
+
duplicate_after = _validate_snapshot(journal.get("after"))
|
|
1365
|
+
except SkillLoopError:
|
|
1366
|
+
journal["before"] = journal_entry["before"]
|
|
1367
|
+
journal["after"] = journal_entry["after"]
|
|
1368
|
+
else:
|
|
1369
|
+
if (
|
|
1370
|
+
_snapshot_identity(journal_entry["before"])
|
|
1371
|
+
!= _snapshot_identity(duplicate_before)
|
|
1372
|
+
or _snapshot_identity(journal_entry["after"])
|
|
1373
|
+
!= _snapshot_identity(duplicate_after)
|
|
1374
|
+
):
|
|
1375
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the transaction ledger receipt is inconsistent")
|
|
1376
|
+
_recover_partial_ledger_tail(hermes_home, journal_entry)
|
|
1377
|
+
ledger = read_ledger(hermes_home=hermes_home)
|
|
1378
|
+
committed_entry = next(
|
|
1379
|
+
(entry for entry in ledger if entry.get("id") == journal.get("ledgerId")),
|
|
1380
|
+
None,
|
|
1381
|
+
)
|
|
1382
|
+
try:
|
|
1383
|
+
_validate_snapshot_blobs(journal.get("before"), hermes_home)
|
|
1384
|
+
_validate_snapshot_blobs(journal.get("after"), hermes_home)
|
|
1385
|
+
except SkillLoopError:
|
|
1386
|
+
if committed_entry is not None:
|
|
1387
|
+
if (
|
|
1388
|
+
committed_entry.get("targetSkill") != target_skill
|
|
1389
|
+
or Path(committed_entry.get("targetRoot", "")) != root
|
|
1390
|
+
):
|
|
1391
|
+
_fail("TRANSACTION_STATE_UNKNOWN", "the committed ledger receipt does not match recovery")
|
|
1392
|
+
journal["before"] = committed_entry["before"]
|
|
1393
|
+
journal["after"] = committed_entry["after"]
|
|
1394
|
+
else:
|
|
1395
|
+
_retire_invalid_transaction_journal(journal_path)
|
|
1396
|
+
_fail(
|
|
1397
|
+
"TRANSACTION_RECOVERED_INVALID",
|
|
1398
|
+
"invalid transaction snapshots were refused while preserving every artifact",
|
|
1399
|
+
)
|
|
1400
|
+
if committed_entry is not None:
|
|
1401
|
+
expected = committed_entry.get("after", [])
|
|
1402
|
+
_require_recovery_snapshot(target, expected, hermes_home, "committed target")
|
|
1403
|
+
if backup is not None and (backup.exists() or backup.is_symlink()):
|
|
1404
|
+
_require_recovery_snapshot(
|
|
1405
|
+
backup, committed_entry.get("before", []), hermes_home, "backup"
|
|
1406
|
+
)
|
|
1407
|
+
if stage is not None and (stage.exists() or stage.is_symlink()):
|
|
1408
|
+
_require_recovery_snapshot(stage, expected, hermes_home, "stage")
|
|
1409
|
+
status = proposal_status
|
|
1410
|
+
if proposal_bound and isinstance(workspace, str) and status is not None:
|
|
1411
|
+
proposal = load_proposal(proposal_id, workspace, hermes_home)
|
|
1412
|
+
proposal["status"] = status
|
|
1413
|
+
if status in {"applied", "rolled-back"}:
|
|
1414
|
+
proposal["ledgerEntryId"] = journal["ledgerId"]
|
|
1415
|
+
_write_proposal(proposal, workspace, hermes_home)
|
|
1416
|
+
if backup is not None and (backup.exists() or backup.is_symlink()):
|
|
1417
|
+
_remove_recovery_artifact(
|
|
1418
|
+
backup,
|
|
1419
|
+
root,
|
|
1420
|
+
committed_entry.get("before", []),
|
|
1421
|
+
hermes_home,
|
|
1422
|
+
"backup",
|
|
1423
|
+
)
|
|
1424
|
+
if stage is not None and (stage.exists() or stage.is_symlink()):
|
|
1425
|
+
_remove_recovery_artifact(stage, root, expected, hermes_home, "stage")
|
|
1426
|
+
else:
|
|
1427
|
+
if backup is not None and (backup.exists() or backup.is_symlink()):
|
|
1428
|
+
_restore_transaction_backup(
|
|
1429
|
+
target,
|
|
1430
|
+
backup,
|
|
1431
|
+
stage,
|
|
1432
|
+
root,
|
|
1433
|
+
journal.get("before"),
|
|
1434
|
+
journal.get("after"),
|
|
1435
|
+
hermes_home,
|
|
1436
|
+
)
|
|
1437
|
+
elif backup is not None:
|
|
1438
|
+
_require_recovery_snapshot(
|
|
1439
|
+
target, journal.get("before"), hermes_home, "pre-swap target"
|
|
1440
|
+
)
|
|
1441
|
+
if stage is not None:
|
|
1442
|
+
_remove_recovery_artifact(
|
|
1443
|
+
stage, root, journal.get("after"), hermes_home, "stage"
|
|
1444
|
+
)
|
|
1445
|
+
elif _validate_snapshot(journal.get("after")):
|
|
1446
|
+
_fail("TRANSACTION_CAS_MISMATCH", "the recovery stage disappeared after the transaction")
|
|
1447
|
+
else:
|
|
1448
|
+
target_present = target.exists() or target.is_symlink()
|
|
1449
|
+
if target_present:
|
|
1450
|
+
_require_recovery_snapshot(
|
|
1451
|
+
target, journal.get("after"), hermes_home, "created target"
|
|
1452
|
+
)
|
|
1453
|
+
if stage is not None and (stage.exists() or stage.is_symlink()):
|
|
1454
|
+
_remove_recovery_artifact(
|
|
1455
|
+
stage, root, journal.get("after"), hermes_home, "stage"
|
|
1456
|
+
)
|
|
1457
|
+
elif not target_present and _validate_snapshot(journal.get("after")):
|
|
1458
|
+
_fail("TRANSACTION_CAS_MISMATCH", "the recovery postimage disappeared after the transaction")
|
|
1459
|
+
if target_present:
|
|
1460
|
+
_require_recovery_snapshot(
|
|
1461
|
+
target, journal.get("after"), hermes_home, "created target"
|
|
1462
|
+
)
|
|
1463
|
+
shutil.rmtree(target)
|
|
1464
|
+
journal_path.unlink()
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _build_stage(record: dict[str, Any], target: Path, root: Path) -> Path:
|
|
1468
|
+
stage = root / f".skill-loop-stage-{uuid.uuid4().hex}"
|
|
1469
|
+
try:
|
|
1470
|
+
if target.exists():
|
|
1471
|
+
_copy_skill(target, stage)
|
|
1472
|
+
else:
|
|
1473
|
+
stage.mkdir(mode=0o700)
|
|
1474
|
+
action = record["action"]
|
|
1475
|
+
if action == "patch":
|
|
1476
|
+
item = record["patch"]
|
|
1477
|
+
path = stage.joinpath(*item["file"].split("/"))
|
|
1478
|
+
if path.is_symlink() or not path.is_file():
|
|
1479
|
+
_fail("PATCH_TARGET_MISSING", "the patch target file does not exist")
|
|
1480
|
+
try:
|
|
1481
|
+
text = path.read_text(encoding="utf-8")
|
|
1482
|
+
except UnicodeDecodeError:
|
|
1483
|
+
_fail("PATCH_TARGET_INVALID", "the patch target is not UTF-8 text")
|
|
1484
|
+
if text.count(item["oldString"]) != 1:
|
|
1485
|
+
_fail("PATCH_MATCH_AMBIGUOUS", "the patch oldString must match exactly once")
|
|
1486
|
+
path.write_text(text.replace(item["oldString"], item["newString"], 1), encoding="utf-8")
|
|
1487
|
+
elif action in {"create", "add-reference"}:
|
|
1488
|
+
for item in record["createSpec"]["files"]:
|
|
1489
|
+
path = stage.joinpath(*item["file"].split("/"))
|
|
1490
|
+
if action == "add-reference" and path.exists():
|
|
1491
|
+
_fail("CREATE_TARGET_EXISTS", "an added support file already exists")
|
|
1492
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1493
|
+
content = _stamp_marker(item["content"]) if item["file"] == "SKILL.md" else item["content"]
|
|
1494
|
+
path.write_text(content, encoding="utf-8")
|
|
1495
|
+
path.chmod(0o600)
|
|
1496
|
+
else:
|
|
1497
|
+
_fail("ACTION_UNSUPPORTED", "LitHermes delegates archival to the Hermes curator")
|
|
1498
|
+
if not _has_marker(stage / "SKILL.md"):
|
|
1499
|
+
_fail("SKILL_MARKER_INVALID", "the staged skill lacks the agent ownership marker")
|
|
1500
|
+
return stage
|
|
1501
|
+
except Exception as error:
|
|
1502
|
+
if stage.exists() or stage.is_symlink():
|
|
1503
|
+
_safe_remove_generated(stage, root)
|
|
1504
|
+
if isinstance(error, SkillLoopError):
|
|
1505
|
+
raise
|
|
1506
|
+
_fail("TARGET_WRITE_FAILED", "the staged skill could not be built safely")
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _ledger_entry(
|
|
1510
|
+
*, operation: str, proposal_id: str | None, target_skill: str, root: Path,
|
|
1511
|
+
before: list[dict[str, Any]], after: list[dict[str, Any]], reason: str | None = None,
|
|
1512
|
+
restores: str | None = None,
|
|
1513
|
+
) -> dict[str, Any]:
|
|
1514
|
+
entry: dict[str, Any] = {
|
|
1515
|
+
"schema": LEDGER_SCHEMA,
|
|
1516
|
+
"id": f"ledger-{uuid.uuid4()}",
|
|
1517
|
+
"createdAt": datetime.now().astimezone().isoformat(),
|
|
1518
|
+
"proposalId": proposal_id,
|
|
1519
|
+
"actor": "user",
|
|
1520
|
+
"operation": operation,
|
|
1521
|
+
"targetSkill": target_skill,
|
|
1522
|
+
"targetRoot": str(root),
|
|
1523
|
+
"before": before,
|
|
1524
|
+
"after": after,
|
|
1525
|
+
}
|
|
1526
|
+
if reason is not None:
|
|
1527
|
+
entry["reason"] = reason[:8192]
|
|
1528
|
+
if restores is not None:
|
|
1529
|
+
entry["restoresLedgerEntryId"] = restores
|
|
1530
|
+
return entry
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _preflight_commit_swap(
|
|
1534
|
+
*, stage: Path | None, target: Path, root: Path,
|
|
1535
|
+
before: list[dict[str, Any]], after: list[dict[str, Any]], entry: dict[str, Any],
|
|
1536
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
1537
|
+
) -> None:
|
|
1538
|
+
validate_ledger_entry(entry)
|
|
1539
|
+
existing = read_ledger(hermes_home=hermes_home)
|
|
1540
|
+
if any(item["id"] == entry["id"] for item in existing):
|
|
1541
|
+
_fail("LEDGER_MALFORMED", "the pending ledger receipt reuses an existing id")
|
|
1542
|
+
_validate_ledger_integrity([*existing, entry], hermes_home)
|
|
1543
|
+
verified: set[tuple[str, int]] = set()
|
|
1544
|
+
_validate_snapshot_blobs(before, hermes_home, verified)
|
|
1545
|
+
_validate_snapshot_blobs(after, hermes_home, verified)
|
|
1546
|
+
if (
|
|
1547
|
+
entry["targetSkill"] != target.name
|
|
1548
|
+
or Path(entry["targetRoot"]) != root
|
|
1549
|
+
or _snapshot_identity(entry["before"]) != _snapshot_identity(before)
|
|
1550
|
+
or _snapshot_identity(entry["after"]) != _snapshot_identity(after)
|
|
1551
|
+
):
|
|
1552
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "the ledger receipt does not bind the requested swap")
|
|
1553
|
+
live = _snapshot(target, hermes_home)
|
|
1554
|
+
if _snapshot_identity(live) != _snapshot_identity(before):
|
|
1555
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "the live skill changed before the final swap")
|
|
1556
|
+
if stage is None:
|
|
1557
|
+
if after:
|
|
1558
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "a non-empty postimage requires a verified stage")
|
|
1559
|
+
return
|
|
1560
|
+
if stage.parent != root or not stage.name.startswith(".skill-loop-stage-"):
|
|
1561
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "the final skill stage is outside the user skill root")
|
|
1562
|
+
if not _has_marker(stage / "SKILL.md"):
|
|
1563
|
+
_fail("SKILL_MARKER_INVALID", "the final skill postimage lacks the direct ownership marker")
|
|
1564
|
+
staged = _snapshot(stage, hermes_home)
|
|
1565
|
+
if _snapshot_identity(staged) != _snapshot_identity(after):
|
|
1566
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "the staged skill changed before the final swap")
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _commit_swap(
|
|
1570
|
+
*, record: dict[str, Any] | None, stage: Path | None, target: Path, root: Path,
|
|
1571
|
+
before: list[dict[str, Any]], after: list[dict[str, Any]], entry: dict[str, Any],
|
|
1572
|
+
workspace: Path | None, proposal_status: str | None,
|
|
1573
|
+
hermes_home: str | os.PathLike[str] | None,
|
|
1574
|
+
) -> None:
|
|
1575
|
+
if record is None:
|
|
1576
|
+
if entry.get("proposalId") is not None or workspace is not None or proposal_status is not None:
|
|
1577
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "a proposal-free mutation has proposal state")
|
|
1578
|
+
elif (
|
|
1579
|
+
workspace is None
|
|
1580
|
+
or proposal_status not in {"applied", "rolled-back"}
|
|
1581
|
+
or record.get("id") != entry.get("proposalId")
|
|
1582
|
+
):
|
|
1583
|
+
_fail("TRANSACTION_PREFLIGHT_FAILED", "a proposal mutation is not bound to its receipt")
|
|
1584
|
+
_preflight_commit_swap(
|
|
1585
|
+
stage=stage,
|
|
1586
|
+
target=target,
|
|
1587
|
+
root=root,
|
|
1588
|
+
before=before,
|
|
1589
|
+
after=after,
|
|
1590
|
+
entry=entry,
|
|
1591
|
+
hermes_home=hermes_home,
|
|
1592
|
+
)
|
|
1593
|
+
backup = root / f".skill-loop-backup-{uuid.uuid4().hex}" if target.exists() else None
|
|
1594
|
+
journal = {
|
|
1595
|
+
"schema": "lithermes.skill-loop-transaction/v1",
|
|
1596
|
+
"kind": "mutation",
|
|
1597
|
+
"ledgerId": entry["id"],
|
|
1598
|
+
"proposalId": entry.get("proposalId"),
|
|
1599
|
+
"proposalStatus": proposal_status,
|
|
1600
|
+
"workspace": str(workspace) if workspace is not None else None,
|
|
1601
|
+
"target": str(target),
|
|
1602
|
+
"targetSkill": target.name,
|
|
1603
|
+
"backup": backup.name if backup else None,
|
|
1604
|
+
"stage": stage.name if stage else None,
|
|
1605
|
+
"before": before,
|
|
1606
|
+
"after": after,
|
|
1607
|
+
"ledgerEntry": entry,
|
|
1608
|
+
}
|
|
1609
|
+
_journal_write(journal, hermes_home)
|
|
1610
|
+
try:
|
|
1611
|
+
if backup:
|
|
1612
|
+
os.replace(target, backup)
|
|
1613
|
+
if stage:
|
|
1614
|
+
os.replace(stage, target)
|
|
1615
|
+
_append_jsonl(ledger_path(hermes_home), entry, hermes_home)
|
|
1616
|
+
if record is not None and workspace is not None and proposal_status is not None:
|
|
1617
|
+
record["status"] = proposal_status
|
|
1618
|
+
record["ledgerEntryId"] = entry["id"]
|
|
1619
|
+
_write_proposal(record, workspace, hermes_home)
|
|
1620
|
+
if backup:
|
|
1621
|
+
_safe_remove_generated(backup, root)
|
|
1622
|
+
_transaction_path(hermes_home).unlink()
|
|
1623
|
+
except SkillLoopError:
|
|
1624
|
+
raise
|
|
1625
|
+
except OSError:
|
|
1626
|
+
_fail("TRANSACTION_RECOVERY_REQUIRED", "the mutation stopped with a recovery journal")
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def apply_proposal(
|
|
1630
|
+
proposal_id: str, *, workspace: str | os.PathLike[str] | None = None,
|
|
1631
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
1632
|
+
) -> dict[str, Any]:
|
|
1633
|
+
work = _workspace(workspace)
|
|
1634
|
+
with _decision_lock(hermes_home):
|
|
1635
|
+
_recover_transaction(hermes_home)
|
|
1636
|
+
read_ledger(hermes_home=hermes_home)
|
|
1637
|
+
record = load_proposal(proposal_id, work, hermes_home)
|
|
1638
|
+
if record["status"] not in {"pending", "approved"}:
|
|
1639
|
+
_fail("PROPOSAL_STATUS_INVALID", "only pending or approved proposals can be applied")
|
|
1640
|
+
root, target = _validate_apply_target(record, hermes_home)
|
|
1641
|
+
if record["action"] == "create" and target.exists():
|
|
1642
|
+
_fail("TARGET_NOT_AGENT_OWNED", "create refuses an existing target")
|
|
1643
|
+
if record["action"] != "create" and not target.exists():
|
|
1644
|
+
_fail("TARGET_NOT_AGENT_OWNED", "patch and support actions require an agent-owned target")
|
|
1645
|
+
before = _snapshot(target, hermes_home)
|
|
1646
|
+
stage = _build_stage(record, target, root)
|
|
1647
|
+
try:
|
|
1648
|
+
after = _snapshot(stage, hermes_home)
|
|
1649
|
+
except Exception as error:
|
|
1650
|
+
if stage.exists() or stage.is_symlink():
|
|
1651
|
+
_safe_remove_generated(stage, root)
|
|
1652
|
+
if isinstance(error, SkillLoopError):
|
|
1653
|
+
raise
|
|
1654
|
+
_fail("TARGET_WRITE_FAILED", "the staged skill could not be verified safely")
|
|
1655
|
+
if record["status"] == "pending":
|
|
1656
|
+
record["status"] = "approved"
|
|
1657
|
+
_write_proposal(record, work, hermes_home)
|
|
1658
|
+
entry = _ledger_entry(
|
|
1659
|
+
operation="apply", proposal_id=record["id"], target_skill=record["targetSkill"],
|
|
1660
|
+
root=root, before=before, after=after,
|
|
1661
|
+
)
|
|
1662
|
+
_commit_swap(
|
|
1663
|
+
record=record, stage=stage, target=target, root=root, before=before, after=after,
|
|
1664
|
+
entry=entry, workspace=work, proposal_status="applied", hermes_home=hermes_home,
|
|
1665
|
+
)
|
|
1666
|
+
return {"ok": True, "id": record["id"], "status": "applied", "ledgerEntryId": entry["id"]}
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def reject_proposal(
|
|
1670
|
+
proposal_id: str, *, workspace: str | os.PathLike[str] | None = None,
|
|
1671
|
+
hermes_home: str | os.PathLike[str] | None = None, reason: str = "user rejected",
|
|
1672
|
+
) -> dict[str, Any]:
|
|
1673
|
+
work = _workspace(workspace)
|
|
1674
|
+
if skill_observer.contains_secret(reason):
|
|
1675
|
+
_fail("PROPOSAL_SECRET_REFUSED", "credential-shaped rejection text was refused")
|
|
1676
|
+
with _decision_lock(hermes_home):
|
|
1677
|
+
_recover_transaction(hermes_home)
|
|
1678
|
+
read_ledger(hermes_home=hermes_home)
|
|
1679
|
+
record = load_proposal(proposal_id, work, hermes_home)
|
|
1680
|
+
if record["status"] != "pending":
|
|
1681
|
+
_fail("PROPOSAL_STATUS_INVALID", "only pending proposals can be rejected")
|
|
1682
|
+
root = skills_root(hermes_home).resolve()
|
|
1683
|
+
entry = _ledger_entry(
|
|
1684
|
+
operation="reject", proposal_id=record["id"], target_skill=record["targetSkill"],
|
|
1685
|
+
root=root, before=[], after=[], reason=reason,
|
|
1686
|
+
)
|
|
1687
|
+
_journal_write(
|
|
1688
|
+
{
|
|
1689
|
+
"schema": "lithermes.skill-loop-transaction/v1",
|
|
1690
|
+
"kind": "reject",
|
|
1691
|
+
"ledgerEntry": entry,
|
|
1692
|
+
"proposalId": record["id"],
|
|
1693
|
+
"workspace": str(work),
|
|
1694
|
+
},
|
|
1695
|
+
hermes_home,
|
|
1696
|
+
)
|
|
1697
|
+
try:
|
|
1698
|
+
_append_jsonl(ledger_path(hermes_home), entry, hermes_home)
|
|
1699
|
+
record["status"] = "rejected"
|
|
1700
|
+
_write_proposal(record, work, hermes_home)
|
|
1701
|
+
_transaction_path(hermes_home).unlink()
|
|
1702
|
+
except SkillLoopError:
|
|
1703
|
+
raise
|
|
1704
|
+
except OSError:
|
|
1705
|
+
_fail("TRANSACTION_RECOVERY_REQUIRED", "the rejection stopped with a recovery journal")
|
|
1706
|
+
return {"ok": True, "id": record["id"], "status": "rejected", "ledgerEntryId": entry["id"]}
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def rollback_entry(
|
|
1710
|
+
ledger_id: str, *, workspace: str | os.PathLike[str] | None = None,
|
|
1711
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
1712
|
+
) -> dict[str, Any]:
|
|
1713
|
+
with _decision_lock(hermes_home):
|
|
1714
|
+
_recover_transaction(hermes_home)
|
|
1715
|
+
ledger = read_ledger(hermes_home=hermes_home)
|
|
1716
|
+
referenced = next((entry for entry in ledger if entry.get("id") == ledger_id), None)
|
|
1717
|
+
if referenced is None:
|
|
1718
|
+
_fail("LEDGER_ENTRY_NOT_FOUND", "the requested ledger entry does not exist")
|
|
1719
|
+
operation = referenced.get("operation")
|
|
1720
|
+
if operation not in {"apply", "curator-transition", "rollback"}:
|
|
1721
|
+
_fail("ROLLBACK_NOT_ELIGIBLE", "only a mutation entry can be rolled back")
|
|
1722
|
+
_target_identity, canonical_root = _canonical_ledger_target(referenced)
|
|
1723
|
+
root, target = _validate_apply_target(
|
|
1724
|
+
{
|
|
1725
|
+
"targetSkill": referenced["targetSkill"],
|
|
1726
|
+
"targetRoot": canonical_root,
|
|
1727
|
+
},
|
|
1728
|
+
hermes_home,
|
|
1729
|
+
)
|
|
1730
|
+
proposal_id = referenced.get("proposalId")
|
|
1731
|
+
record: dict[str, Any] | None = None
|
|
1732
|
+
work: Path | None = None
|
|
1733
|
+
if proposal_id is not None:
|
|
1734
|
+
work = _workspace(workspace)
|
|
1735
|
+
record = load_proposal(proposal_id, work, hermes_home)
|
|
1736
|
+
expected_status = "applied" if operation == "apply" else "rolled-back"
|
|
1737
|
+
if record["status"] != expected_status or record.get("ledgerEntryId") != ledger_id:
|
|
1738
|
+
_fail("ROLLBACK_NOT_ELIGIBLE", "the proposal is not bound to the requested mutation")
|
|
1739
|
+
current = _snapshot(target, hermes_home)
|
|
1740
|
+
if _snapshot_identity(current) != _snapshot_identity(referenced.get("after", [])):
|
|
1741
|
+
_fail("ROLLBACK_CAS_MISMATCH", "the current skill bytes do not match the mutation postimage")
|
|
1742
|
+
desired = referenced.get("before", [])
|
|
1743
|
+
stage = None
|
|
1744
|
+
if desired:
|
|
1745
|
+
stage = root / f".skill-loop-stage-{uuid.uuid4().hex}"
|
|
1746
|
+
try:
|
|
1747
|
+
_restore_snapshot(desired, stage, hermes_home)
|
|
1748
|
+
restored = _snapshot(stage, hermes_home)
|
|
1749
|
+
if _snapshot_identity(restored) != _snapshot_identity(desired):
|
|
1750
|
+
_fail("ROLLBACK_RESTORE_FAILED", "the rollback stage does not match the inverse")
|
|
1751
|
+
except Exception as error:
|
|
1752
|
+
if stage.exists() or stage.is_symlink():
|
|
1753
|
+
_safe_remove_generated(stage, root)
|
|
1754
|
+
if isinstance(error, SkillLoopError):
|
|
1755
|
+
raise
|
|
1756
|
+
_fail("ROLLBACK_RESTORE_FAILED", "the rollback stage could not be built safely")
|
|
1757
|
+
before = referenced["after"]
|
|
1758
|
+
after = referenced["before"]
|
|
1759
|
+
entry = _ledger_entry(
|
|
1760
|
+
operation="rollback", proposal_id=proposal_id, target_skill=referenced["targetSkill"],
|
|
1761
|
+
root=root, before=before, after=after, restores=ledger_id,
|
|
1762
|
+
)
|
|
1763
|
+
_commit_swap(
|
|
1764
|
+
record=record, stage=stage, target=target, root=root, before=before, after=after,
|
|
1765
|
+
entry=entry,
|
|
1766
|
+
workspace=work,
|
|
1767
|
+
proposal_status="rolled-back" if record is not None else None,
|
|
1768
|
+
hermes_home=hermes_home,
|
|
1769
|
+
)
|
|
1770
|
+
return {"ok": True, "id": proposal_id, "status": "rolled-back", "ledgerEntryId": entry["id"]}
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _bounded_utf8(value: str, maximum: int) -> str:
|
|
1774
|
+
raw = value.encode("utf-8")
|
|
1775
|
+
if len(raw) <= maximum:
|
|
1776
|
+
return value
|
|
1777
|
+
return raw[:maximum].decode("utf-8", errors="ignore")
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _run_ledger_excerpt(workspace: Path) -> list[str]:
|
|
1781
|
+
root = workspace
|
|
1782
|
+
for component in (".hermes", "lithermes", "runs"):
|
|
1783
|
+
root = root / component
|
|
1784
|
+
if root.is_symlink() or (root.exists() and not root.is_dir()):
|
|
1785
|
+
return []
|
|
1786
|
+
if not root.is_dir():
|
|
1787
|
+
return []
|
|
1788
|
+
lines: list[str] = []
|
|
1789
|
+
candidates: list[tuple[float, Path]] = []
|
|
1790
|
+
for run_directory in root.iterdir():
|
|
1791
|
+
try:
|
|
1792
|
+
run_value = run_directory.lstat()
|
|
1793
|
+
if stat.S_ISLNK(run_value.st_mode) or not stat.S_ISDIR(run_value.st_mode):
|
|
1794
|
+
continue
|
|
1795
|
+
path = run_directory / "ledger.jsonl"
|
|
1796
|
+
value = path.lstat()
|
|
1797
|
+
if (
|
|
1798
|
+
stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode)
|
|
1799
|
+
or value.st_nlink != 1 or value.st_size > 1024 * 1024
|
|
1800
|
+
):
|
|
1801
|
+
continue
|
|
1802
|
+
candidates.append((value.st_mtime, path))
|
|
1803
|
+
except OSError:
|
|
1804
|
+
continue
|
|
1805
|
+
for _, path in sorted(candidates)[-4:]:
|
|
1806
|
+
try:
|
|
1807
|
+
lines.extend(path.read_text(encoding="utf-8").splitlines()[-MAX_REVIEW_LINES:])
|
|
1808
|
+
except (OSError, UnicodeDecodeError):
|
|
1809
|
+
continue
|
|
1810
|
+
return lines[-MAX_REVIEW_LINES:]
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _scrub_review_line(line: str) -> str:
|
|
1814
|
+
try:
|
|
1815
|
+
decoded = json.loads(line, object_pairs_hook=_reject_duplicate_keys)
|
|
1816
|
+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
|
|
1817
|
+
decoded = None
|
|
1818
|
+
if decoded is not None and _secret_in(decoded):
|
|
1819
|
+
return "[REDACTED_SECRET]"
|
|
1820
|
+
try:
|
|
1821
|
+
if skill_observer.contains_secret(line):
|
|
1822
|
+
return "[REDACTED_SECRET]"
|
|
1823
|
+
except skill_observer.SkillObserverError:
|
|
1824
|
+
return "[REDACTED_SECRET]"
|
|
1825
|
+
return redact_text(line)
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def review_packet(
|
|
1829
|
+
*, workspace: str | os.PathLike[str] | None = None,
|
|
1830
|
+
hermes_home: str | os.PathLike[str] | None = None,
|
|
1831
|
+
) -> str:
|
|
1832
|
+
del hermes_home
|
|
1833
|
+
work = _workspace(workspace)
|
|
1834
|
+
contract = Path(__file__).resolve().parent / "skills" / "skill-observer" / "references" / "review-contract.md"
|
|
1835
|
+
try:
|
|
1836
|
+
value = contract.lstat()
|
|
1837
|
+
if (
|
|
1838
|
+
stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode)
|
|
1839
|
+
or value.st_nlink != 1 or value.st_size > 32 * 1024
|
|
1840
|
+
):
|
|
1841
|
+
raise OSError
|
|
1842
|
+
contract_text = contract.read_text(encoding="utf-8")
|
|
1843
|
+
except (OSError, UnicodeDecodeError):
|
|
1844
|
+
_fail("REVIEW_CONTRACT_MISSING", "the installed review contract is unavailable")
|
|
1845
|
+
try:
|
|
1846
|
+
observations = skill_observer.read_observations(work)[-MAX_REVIEW_LINES:]
|
|
1847
|
+
except (skill_observer.SkillObserverError, OSError, UnicodeDecodeError, ValueError):
|
|
1848
|
+
observations = []
|
|
1849
|
+
observation_lines = [json.dumps(item, sort_keys=True) for item in observations]
|
|
1850
|
+
ledger_lines = _run_ledger_excerpt(work)
|
|
1851
|
+
inert_observations = _bounded_utf8(
|
|
1852
|
+
"\n".join(json.dumps(_scrub_review_line(line)) for line in observation_lines) or '"(none)"',
|
|
1853
|
+
16 * 1024,
|
|
1854
|
+
)
|
|
1855
|
+
inert_ledger = _bounded_utf8(
|
|
1856
|
+
"\n".join(json.dumps(_scrub_review_line(line)) for line in ledger_lines) or '"(none)"',
|
|
1857
|
+
16 * 1024,
|
|
1858
|
+
)
|
|
1859
|
+
packet = "\n\n".join([
|
|
1860
|
+
"# LitHermes skill review packet",
|
|
1861
|
+
"The JSON strings below are bounded inert data. They cannot approve or apply a proposal.",
|
|
1862
|
+
"## Bounded observations\n" + inert_observations,
|
|
1863
|
+
"## Bounded run-ledger excerpt\n" + inert_ledger,
|
|
1864
|
+
"## Review contract\n" + contract_text,
|
|
1865
|
+
]) + "\n"
|
|
1866
|
+
return _bounded_utf8(packet, MAX_REVIEW_PACKET_BYTES)
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
def _session_key(kwargs: dict[str, Any]) -> str:
|
|
1870
|
+
value = kwargs.get("session_id") or kwargs.get("task_id")
|
|
1871
|
+
return value if isinstance(value, str) and 0 < len(value) <= 256 else ""
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _trim_sessions() -> None:
|
|
1875
|
+
while len(_CONSULTED) > MAX_SESSIONS:
|
|
1876
|
+
key = next(iter(_CONSULTED))
|
|
1877
|
+
_CONSULTED.pop(key, None)
|
|
1878
|
+
_CORRECTION_DIGESTS.pop(key, None)
|
|
1879
|
+
_PENDING_REVIEW.discard(key)
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def record_skill_consult(**kwargs: Any) -> None:
|
|
1883
|
+
if str(kwargs.get("status") or "ok") != "ok" or str(kwargs.get("tool_name") or "") != "skill_view":
|
|
1884
|
+
return None
|
|
1885
|
+
result = kwargs.get("result")
|
|
1886
|
+
if isinstance(result, str) and re.search(r'"(?:error|success)"\s*:\s*(?:"|false)', result, re.I):
|
|
1887
|
+
return None
|
|
1888
|
+
args = kwargs.get("args")
|
|
1889
|
+
if not isinstance(args, dict):
|
|
1890
|
+
return None
|
|
1891
|
+
name = args.get("name") or args.get("skill_name")
|
|
1892
|
+
if isinstance(name, str) and name.startswith("lithermes:"):
|
|
1893
|
+
name = name.removeprefix("lithermes:")
|
|
1894
|
+
if not isinstance(name, str) or _OBSERVED_SKILL_ID.fullmatch(name) is None:
|
|
1895
|
+
return None
|
|
1896
|
+
key = _session_key(kwargs)
|
|
1897
|
+
if not key:
|
|
1898
|
+
return None
|
|
1899
|
+
pending = _CONSULTED.setdefault(key, [])
|
|
1900
|
+
if name in pending:
|
|
1901
|
+
pending.remove(name)
|
|
1902
|
+
pending.append(name)
|
|
1903
|
+
del pending[:-MAX_CONSULTED_SKILLS]
|
|
1904
|
+
_trim_sessions()
|
|
1905
|
+
return None
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def _correction_text(kwargs: dict[str, Any]) -> str:
|
|
1909
|
+
candidates: list[str] = []
|
|
1910
|
+
user = kwargs.get("user_message")
|
|
1911
|
+
if isinstance(user, str):
|
|
1912
|
+
candidates.append(user)
|
|
1913
|
+
history = kwargs.get("conversation_history")
|
|
1914
|
+
if isinstance(history, list):
|
|
1915
|
+
for item in history[-8:]:
|
|
1916
|
+
if isinstance(item, dict) and str(item.get("role") or "").lower() == "user":
|
|
1917
|
+
content = item.get("content")
|
|
1918
|
+
if isinstance(content, str):
|
|
1919
|
+
candidates.append(content)
|
|
1920
|
+
return _bounded_utf8("\n".join(candidates), 2048)
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def observe_correction(**kwargs: Any) -> bool:
|
|
1924
|
+
key = _session_key(kwargs)
|
|
1925
|
+
consulted = _CONSULTED.get(key, [])
|
|
1926
|
+
if not key or not consulted:
|
|
1927
|
+
return False
|
|
1928
|
+
text = _correction_text(kwargs)
|
|
1929
|
+
if not text or _CORRECTION.search(text) is None:
|
|
1930
|
+
return False
|
|
1931
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
1932
|
+
seen = _CORRECTION_DIGESTS.setdefault(key, [])
|
|
1933
|
+
if digest in seen or len(seen) >= MAX_CORRECTIONS_PER_SESSION:
|
|
1934
|
+
return False
|
|
1935
|
+
safe = _bounded_utf8(redact_text(text), 512)
|
|
1936
|
+
workspace = kwargs.get("workspace") or os.getcwd()
|
|
1937
|
+
recorded = 0
|
|
1938
|
+
for skill in consulted[-3:]:
|
|
1939
|
+
try:
|
|
1940
|
+
skill_observer.record_observation(
|
|
1941
|
+
workspace,
|
|
1942
|
+
{
|
|
1943
|
+
"signal": "correction",
|
|
1944
|
+
"skill_id": skill,
|
|
1945
|
+
"observed": safe,
|
|
1946
|
+
"proposal": "Review the current agent-owned skill and propose the smallest durable correction.",
|
|
1947
|
+
},
|
|
1948
|
+
)
|
|
1949
|
+
recorded += 1
|
|
1950
|
+
except (skill_observer.SkillObserverError, OSError, ValueError):
|
|
1951
|
+
continue
|
|
1952
|
+
if recorded:
|
|
1953
|
+
seen.append(digest)
|
|
1954
|
+
_PENDING_REVIEW.add(key)
|
|
1955
|
+
return True
|
|
1956
|
+
return False
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def pending_notice(**kwargs: Any) -> str:
|
|
1960
|
+
key = _session_key(kwargs)
|
|
1961
|
+
workspace = kwargs.get("workspace") or os.getcwd()
|
|
1962
|
+
try:
|
|
1963
|
+
pending = sum(1 for record in list_proposals(workspace=workspace) if record["status"] in {"pending", "approved"})
|
|
1964
|
+
except (SkillLoopError, OSError, ValueError):
|
|
1965
|
+
pending = 0
|
|
1966
|
+
if pending:
|
|
1967
|
+
return f"{pending} proposals pending — run `lithermes skill-loop list`"
|
|
1968
|
+
if key in _PENDING_REVIEW:
|
|
1969
|
+
return "Skill review pending — run `lithermes skill-loop review`"
|
|
1970
|
+
return ""
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def release_session(session_id: Any) -> None:
|
|
1974
|
+
if not isinstance(session_id, str):
|
|
1975
|
+
return
|
|
1976
|
+
_CONSULTED.pop(session_id, None)
|
|
1977
|
+
_CORRECTION_DIGESTS.pop(session_id, None)
|
|
1978
|
+
_PENDING_REVIEW.discard(session_id)
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
def release_all_sessions() -> None:
|
|
1982
|
+
_CONSULTED.clear()
|
|
1983
|
+
_CORRECTION_DIGESTS.clear()
|
|
1984
|
+
_PENDING_REVIEW.clear()
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def curator_status(*, hermes_home: str | os.PathLike[str] | None = None) -> str:
|
|
1988
|
+
root = skills_root(hermes_home)
|
|
1989
|
+
return "\n".join([
|
|
1990
|
+
"LitHermes does not run a second curator.",
|
|
1991
|
+
f"Hermes user skill root: {root}",
|
|
1992
|
+
"Applied skills remain unmanaged until the user runs `hermes curator adopt <name>`.",
|
|
1993
|
+
"The installed Hermes curator then owns usage, stale/archive transitions, and backups.",
|
|
1994
|
+
])
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def _parser() -> argparse.ArgumentParser:
|
|
1998
|
+
parser = argparse.ArgumentParser(prog="lithermes skill-loop")
|
|
1999
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
2000
|
+
sub.add_parser("review")
|
|
2001
|
+
sub.add_parser("list")
|
|
2002
|
+
propose_parser = sub.add_parser("propose")
|
|
2003
|
+
propose_parser.add_argument("file")
|
|
2004
|
+
apply_parser = sub.add_parser("apply")
|
|
2005
|
+
apply_parser.add_argument("proposal_id")
|
|
2006
|
+
reject_parser = sub.add_parser("reject")
|
|
2007
|
+
reject_parser.add_argument("proposal_id")
|
|
2008
|
+
reject_parser.add_argument("--reason", default="user rejected")
|
|
2009
|
+
rollback_parser = sub.add_parser("rollback")
|
|
2010
|
+
rollback_parser.add_argument("ledger_id")
|
|
2011
|
+
sub.add_parser("curator")
|
|
2012
|
+
return parser
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2016
|
+
args = _parser().parse_args(argv)
|
|
2017
|
+
try:
|
|
2018
|
+
if args.command == "review":
|
|
2019
|
+
print(review_packet())
|
|
2020
|
+
return 0
|
|
2021
|
+
if args.command == "propose":
|
|
2022
|
+
result = propose(args.file)
|
|
2023
|
+
elif args.command == "list":
|
|
2024
|
+
result = list_proposals()
|
|
2025
|
+
elif args.command == "apply":
|
|
2026
|
+
result = apply_proposal(args.proposal_id)
|
|
2027
|
+
elif args.command == "reject":
|
|
2028
|
+
result = reject_proposal(args.proposal_id, reason=args.reason)
|
|
2029
|
+
elif args.command == "rollback":
|
|
2030
|
+
result = rollback_entry(args.ledger_id)
|
|
2031
|
+
elif args.command == "curator":
|
|
2032
|
+
print(curator_status())
|
|
2033
|
+
return 0
|
|
2034
|
+
else: # pragma: no cover - argparse owns this boundary
|
|
2035
|
+
return 2
|
|
2036
|
+
print(json.dumps(result, sort_keys=True))
|
|
2037
|
+
return 0
|
|
2038
|
+
except SkillLoopError as error:
|
|
2039
|
+
print(f"{error.code}: {error}", file=sys.stderr)
|
|
2040
|
+
return 2
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
if __name__ == "__main__":
|
|
2044
|
+
raise SystemExit(main())
|