@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.13
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/CHANGELOG.md +1005 -0
- package/README.md +149 -9
- package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
- package/contracts/atelier-architecture.v1.schema.json +258 -0
- package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
- package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
- package/contracts/atelier-build.v1.schema.json +1076 -0
- package/contracts/atelier-capability.v1.schema.json +1252 -0
- package/contracts/atelier-coordination.v1.schema.json +1050 -0
- package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
- package/contracts/atelier-harness.v1.schema.json +234 -0
- package/contracts/atelier-ingestion.v1.schema.json +806 -0
- package/contracts/atelier-inquiry.v1.schema.json +1772 -0
- package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
- package/contracts/atelier-interaction.v1.schema.json +553 -0
- package/contracts/atelier-judgment.v1.schema.json +690 -0
- package/contracts/atelier-knowledge.v1.schema.json +1512 -0
- package/contracts/atelier-learning.v1.schema.json +1095 -0
- package/contracts/atelier-migration.v3.schema.json +64 -0
- package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
- package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
- package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
- package/contracts/atelier-template-adoption.v1.schema.json +223 -0
- package/contracts/atelier-template-profile.v1.schema.json +1168 -0
- package/contracts/atelier-trackable.v1.schema.json +781 -0
- package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
- package/contracts/compat-baseline.json +1 -1
- package/docs/architecture.md +89 -0
- package/docs/assurance-controls.md +4 -3
- package/docs/blocks/capability-stewardship.md +17 -0
- package/docs/blocks/claims.md +16 -3
- package/docs/blocks/inquiry-harnesses.md +19 -0
- package/docs/blocks/learning-harnesses.md +17 -0
- package/docs/capability-stewardship.md +269 -0
- package/docs/coordination.md +51 -0
- package/docs/decision-practices.md +111 -0
- package/docs/decisions.md +29 -0
- package/docs/evidence-navigation.md +237 -0
- package/docs/exact-upgrades.md +3 -2
- package/docs/ingestion.md +181 -0
- package/docs/inquiry-harnesses.md +211 -0
- package/docs/install.md +80 -3
- package/docs/intake-and-guides.md +5 -0
- package/docs/integration-contract-decisions.md +24 -2
- package/docs/interaction-and-reflection.md +72 -0
- package/docs/knowledge-graph.md +20 -0
- package/docs/knowledge-health/api.md +30 -0
- package/docs/knowledge-health/assessment/api.md +96 -0
- package/docs/knowledge-health/assessment/capabilities.md +40 -0
- package/docs/knowledge-health/assessment/evaluation.md +27 -0
- package/docs/knowledge-health/assessment/facilitator.md +30 -0
- package/docs/knowledge-health/assessment/quickstart.md +61 -0
- package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
- package/docs/knowledge-health/devday/facilitator.md +16 -0
- package/docs/knowledge-health/devday/participant-response.md +22 -0
- package/docs/knowledge-health/devday/public-default.md +107 -0
- package/docs/knowledge-health/devday/quickstart.md +18 -0
- package/docs/knowledge-health/devday/support.md +22 -0
- package/docs/knowledge-health/exercise.md +17 -0
- package/docs/knowledge-health/facilitator.md +20 -0
- package/docs/knowledge-health/quickstart.md +94 -0
- package/docs/knowledge-health/readme.md +15 -0
- package/docs/knowledge-health/support.md +34 -0
- package/docs/knowledge-setup.md +248 -0
- package/docs/knowledge-workspace.md +264 -0
- package/docs/learning-harnesses.md +238 -0
- package/docs/learning.md +215 -0
- package/docs/local-services.md +242 -26
- package/docs/obsidian-contract.md +878 -36
- package/docs/obsidian-plugin.md +619 -0
- package/docs/obsidian.md +809 -64
- package/docs/optional-ingestion.md +83 -0
- package/docs/personal-workspace.md +257 -0
- package/docs/practical-judgment.md +92 -0
- package/docs/progressive-ingestion-acceptance.md +89 -0
- package/docs/progressive-ingestion.md +235 -0
- package/docs/project-command-inventory.md +1 -0
- package/docs/release-engineering.md +56 -1
- package/docs/repo-boundary-guard.md +94 -0
- package/docs/research-starter-quickstart.md +66 -0
- package/docs/research-starter.md +114 -0
- package/docs/semantic-ingestion.md +198 -0
- package/docs/semantic-operation.md +317 -0
- package/docs/skill-steward.md +6 -0
- package/docs/template-profile.md +164 -0
- package/docs/template-upgrades.md +114 -0
- package/docs/trackables.md +111 -0
- package/docs/upgrade.md +68 -3
- package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
- package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
- package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
- package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
- package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
- package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
- package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
- package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
- package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
- package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
- package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
- package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
- package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
- package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
- package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
- package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
- package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
- package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
- package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
- package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
- package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
- package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
- package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
- package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
- package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
- package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
- package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
- package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
- package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
- package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
- package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
- package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
- package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
- package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
- package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
- package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
- package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
- package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
- package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
- package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
- package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
- package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
- package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
- package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
- package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
- package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
- package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
- package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
- package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
- package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
- package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
- package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
- package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
- package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
- package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
- package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
- package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
- package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
- package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
- package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
- package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
- package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
- package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
- package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
- package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
- package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
- package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
- package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
- package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
- package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
- package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
- package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
- package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
- package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
- package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
- package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
- package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
- package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
- package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
- package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
- package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
- package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
- package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
- package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
- package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
- package/fixtures/atelier-interaction/act/valid/document.json +24 -0
- package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
- package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
- package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
- package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
- package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
- package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
- package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
- package/fixtures/atelier-judgment/case/valid/document.json +75 -0
- package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
- package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
- package/fixtures/atelier-judgment/native-decision.md +3 -0
- package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
- package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
- package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
- package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
- package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
- package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
- package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
- package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
- package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
- package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
- package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
- package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
- package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
- package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
- package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
- package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
- package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
- package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
- package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
- package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
- package/fixtures/atelier-learning/invalid/authority.json +14 -0
- package/fixtures/atelier-learning/valid/capture.json +13 -0
- package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
- package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
- package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
- package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
- package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
- package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
- package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
- package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
- package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
- package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
- package/fixtures/atelier-trackable/request/valid/document.json +22 -0
- package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
- package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
- package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
- package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
- package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
- package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
- package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
- package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
- package/fixtures/capability-packages/adoption.example.json +70 -0
- package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
- package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
- package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
- package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
- package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
- package/fixtures/capability-packages/research/capability-package.json +88 -0
- package/fixtures/capability-packages/research/capability-release.json +134 -0
- package/fixtures/capability-packages/research/evaluation.md +15 -0
- package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
- package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
- package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
- package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
- package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
- package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
- package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
- package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
- package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
- package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
- package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
- package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
- package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
- package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
- package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
- package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
- package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
- package/fixtures/evidence-navigation/valid/claim.json +36 -0
- package/fixtures/evidence-navigation/valid/host.json +16 -0
- package/fixtures/evidence-navigation/valid/profile.json +16 -0
- package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
- package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
- package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
- package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
- package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
- package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
- package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
- package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
- package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
- package/fixtures/harnesses/learning-cycle.json +780 -0
- package/fixtures/inquiry/workshop.json +675 -0
- package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
- package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
- package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
- package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
- package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
- package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
- package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
- package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
- package/fixtures/knowledge-health/calibration-suite.json +26 -0
- package/fixtures/knowledge-health/calibration-trial.json +37 -0
- package/fixtures/knowledge-health/corrected/checklist.md +18 -0
- package/fixtures/knowledge-health/devday/participant-record.json +19 -0
- package/fixtures/knowledge-health/expected-summary.json +27 -0
- package/fixtures/knowledge-health/workshop/README.md +20 -0
- package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
- package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
- package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
- package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
- package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
- package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
- package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
- package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
- package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
- package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
- package/fixtures/obsidian/edits/workspace.json +1 -1
- package/fixtures/obsidian/materialization/expected-full.json +21 -21
- package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
- package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
- package/fixtures/personal-workspace/overlay.json +24 -0
- package/fixtures/templates/local-library.v1.json +75 -0
- package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
- package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
- package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
- package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
- package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
- package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
- package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
- package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
- package/package.json +64 -6
- package/plugins/obsidian/main.js +444 -0
- package/plugins/obsidian/manifest.json +9 -0
- package/plugins/obsidian/styles.css +22 -0
- package/skills/claude/atelier-build-harness/SKILL.md +59 -0
- package/skills/claude/atelier-coordination/SKILL.md +32 -0
- package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
- package/skills/claude/atelier-interaction/SKILL.md +34 -0
- package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
- package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
- package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
- package/skills/claude/atelier-research-harness/SKILL.md +43 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
- package/skills/claude/atelier-trackables/SKILL.md +40 -0
- package/skills/codex/atelier-build-harness/SKILL.md +59 -0
- package/skills/codex/atelier-coordination/SKILL.md +32 -0
- package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
- package/skills/codex/atelier-interaction/SKILL.md +34 -0
- package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
- package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
- package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
- package/skills/codex/atelier-research-harness/SKILL.md +43 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
- package/skills/codex/atelier-trackables/SKILL.md +40 -0
- package/src/architecture/catalog.mjs +355 -0
- package/src/architecture/index.mjs +93 -0
- package/src/boundary/content-rules.mjs +4 -2
- package/src/boundary/delegation-command.mjs +158 -0
- package/src/boundary/delegation.mjs +396 -0
- package/src/boundary/policy.mjs +52 -10
- package/src/build/git.mjs +18 -0
- package/src/build/index.mjs +3 -0
- package/src/build/ledger.mjs +88 -0
- package/src/capabilities/adoption.mjs +381 -0
- package/src/capabilities/evidence.mjs +140 -0
- package/src/capabilities/files.mjs +124 -0
- package/src/capabilities/index.mjs +4 -0
- package/src/capabilities/instructions.mjs +250 -0
- package/src/capabilities/package.mjs +136 -0
- package/src/cli/command-failure.mjs +36 -0
- package/src/cli/execute-command.mjs +16 -2
- package/src/cli/run.mjs +65 -9
- package/src/coauthor/store.mjs +43 -2
- package/src/collaboration/proposals.mjs +189 -0
- package/src/commands/architecture.mjs +27 -0
- package/src/commands/boundary.mjs +3 -0
- package/src/commands/capability.mjs +73 -0
- package/src/commands/enroll.mjs +3 -0
- package/src/commands/harness.mjs +79 -0
- package/src/commands/ingest.mjs +39 -0
- package/src/commands/init.mjs +4 -1
- package/src/commands/inquiry.mjs +66 -0
- package/src/commands/knowledge-semantic.mjs +128 -0
- package/src/commands/knowledge.mjs +119 -0
- package/src/commands/learn.mjs +61 -0
- package/src/commands/obsidian.mjs +832 -62
- package/src/commands/practice.mjs +24 -0
- package/src/commands/setup.mjs +22 -2
- package/src/commands/trackable.mjs +19 -0
- package/src/contracts/corpus.mjs +36 -0
- package/src/coordination/index.mjs +110 -0
- package/src/egress/forbidden-egress.mjs +2 -0
- package/src/evidence-navigation/contracts.mjs +60 -0
- package/src/evidence-navigation/evaluate.mjs +69 -0
- package/src/evidence-navigation/generation.mjs +22 -0
- package/src/evidence-navigation/index.mjs +2 -0
- package/src/evidence-navigation/local.mjs +167 -0
- package/src/evidence-navigation/schema.generated.mjs +604 -0
- package/src/evidence-navigation/types.mjs +12 -0
- package/src/export/atelier-export-contract.mjs +35 -8
- package/src/graph/enroll.mjs +248 -0
- package/src/graph/graph.mjs +17 -1
- package/src/graph/knowledge-graph.mjs +58 -8
- package/src/harnesses/contracts.mjs +41 -0
- package/src/harnesses/exchange.mjs +154 -0
- package/src/harnesses/feedback.mjs +16 -0
- package/src/harnesses/history.mjs +37 -0
- package/src/harnesses/index.mjs +4 -0
- package/src/harnesses/store.mjs +29 -0
- package/src/ingestion/contracts.mjs +41 -0
- package/src/ingestion/evaluation.mjs +119 -0
- package/src/ingestion/optional/composition/index.mjs +171 -0
- package/src/ingestion/optional/evaluation/index.mjs +219 -0
- package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
- package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +933 -0
- package/src/ingestion/optional/graphify/json_bridge.py +163 -0
- package/src/ingestion/optional/graphify/query_receiving.py +209 -0
- package/src/ingestion/optional/index.mjs +9 -0
- package/src/ingestion/optional/jev/bridge.mjs +166 -0
- package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
- package/src/ingestion/optional/jev/judgments.mjs +174 -0
- package/src/ingestion/optional/jev/native-translation.mjs +32 -0
- package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
- package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
- package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
- package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
- package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
- package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
- package/src/ingestion/optional/vanilla/index.mjs +218 -0
- package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
- package/src/ingestion/processors.mjs +218 -0
- package/src/ingestion/semantic.mjs +203 -0
- package/src/ingestion/store.mjs +372 -0
- package/src/inquiry/beliefs.mjs +32 -0
- package/src/inquiry/contracts.mjs +23 -0
- package/src/inquiry/index.mjs +5 -0
- package/src/inquiry/ledger.mjs +135 -0
- package/src/inquiry/lenses.mjs +15 -0
- package/src/inquiry/projection.mjs +77 -0
- package/src/inquiry/store.mjs +35 -0
- package/src/intake/read-scope.mjs +12 -0
- package/src/intake/store.mjs +69 -6
- package/src/interaction/index.mjs +91 -0
- package/src/judgment/index.mjs +82 -0
- package/src/judgment/practice-evaluation.mjs +70 -0
- package/src/judgment/practice.mjs +144 -0
- package/src/knowledge/context.mjs +217 -0
- package/src/knowledge/index.mjs +5 -0
- package/src/knowledge/ingestion.mjs +79 -0
- package/src/knowledge/intake.mjs +33 -0
- package/src/knowledge/ledger.mjs +110 -0
- package/src/knowledge/participatory/guidance.mjs +36 -0
- package/src/knowledge/participatory/guided-contribution.mjs +44 -0
- package/src/knowledge/participatory/index.mjs +2 -0
- package/src/knowledge/participatory/installed-workshop.mjs +108 -0
- package/src/knowledge/plan.mjs +67 -0
- package/src/knowledge/projection.mjs +43 -0
- package/src/knowledge/semantic-operation.mjs +567 -0
- package/src/knowledge/sessions.mjs +467 -0
- package/src/knowledge/workspace.mjs +295 -0
- package/src/knowledge-health/assessment.mjs +165 -0
- package/src/knowledge-health/capabilities.mjs +350 -0
- package/src/knowledge-health/context.mjs +62 -0
- package/src/knowledge-health/example.mjs +47 -0
- package/src/knowledge-health/index.mjs +10 -0
- package/src/knowledge-health/owner-join.mjs +88 -0
- package/src/knowledge-health/proof/common.mjs +24 -0
- package/src/knowledge-health/proof/installed-check.mjs +100 -0
- package/src/knowledge-health/proof/native-readback.mjs +37 -0
- package/src/knowledge-health/proof/rehearse.mjs +68 -0
- package/src/knowledge-health/proof/validate-participant.mjs +17 -0
- package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
- package/src/knowledge-health/proof/workshop-check.mjs +36 -0
- package/src/knowledge-health/rule-identity.mjs +44 -0
- package/src/knowledge-health/workshop-composition.mjs +19 -0
- package/src/knowledge-health/workshop-profile.mjs +176 -0
- package/src/knowledge-health/workshop.mjs +99 -0
- package/src/learning/adapters.mjs +68 -0
- package/src/learning/assistance.mjs +127 -0
- package/src/learning/contracts.mjs +65 -0
- package/src/learning/store.mjs +272 -0
- package/src/personal-workspace/index.mjs +391 -0
- package/src/personal-workspace/selection.mjs +385 -0
- package/src/project/config.mjs +8 -0
- package/src/project/durable-state.mjs +3 -1
- package/src/project/file-class.mjs +1 -1
- package/src/project/private-state.mjs +10 -1
- package/src/projection/obsidian/contracts.mjs +224 -18
- package/src/projection/obsidian/edits/apply.mjs +63 -19
- package/src/projection/obsidian/edits/regions.mjs +137 -42
- package/src/projection/obsidian/materialize/index.mjs +6 -3
- package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
- package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
- package/src/projection/obsidian/materialize/redaction.mjs +327 -0
- package/src/projection/obsidian/materialize/settings.mjs +113 -34
- package/src/projection/obsidian/personal-workspace.mjs +436 -0
- package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
- package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
- package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
- package/src/projection/obsidian/proposals/adapter.mjs +47 -8
- package/src/projection/obsidian/proposals/observation.mjs +20 -7
- package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
- package/src/projection/obsidian/publication/index.mjs +2 -1
- package/src/projection/obsidian/publication/publisher.mjs +312 -36
- package/src/projection/obsidian/publication/transport.mjs +63 -8
- package/src/projection/obsidian/publication/vault-list.mjs +200 -0
- package/src/projection/obsidian/recovery/index.mjs +4 -1
- package/src/projection/obsidian/recovery/restart.mjs +3 -1
- package/src/projection/obsidian/recovery/store.mjs +168 -8
- package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
- package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
- package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
- package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
- package/src/readiness/readiness.mjs +10 -3
- package/src/reflection/index.mjs +56 -0
- package/src/runtime/obsidian/app-capability.mjs +104 -12
- package/src/runtime/obsidian/app-production-seams.mjs +161 -42
- package/src/runtime/obsidian/app-registration.mjs +313 -0
- package/src/runtime/obsidian/app-restart.mjs +154 -0
- package/src/runtime/obsidian/engine.mjs +288 -29
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +15 -8
- package/src/runtime/obsidian/launch-plan.mjs +91 -0
- package/src/runtime/obsidian/lifecycle.mjs +193 -22
- package/src/runtime/obsidian/login-item.mjs +332 -0
- package/src/runtime/obsidian/machine-settings.mjs +119 -12
- package/src/runtime/obsidian/opening.mjs +369 -63
- package/src/runtime/obsidian/pending-edits.mjs +10 -0
- package/src/runtime/obsidian/pipeline.mjs +71 -3
- package/src/runtime/obsidian/plugin-channel.mjs +289 -0
- package/src/runtime/obsidian/plugin-choice.mjs +182 -0
- package/src/runtime/obsidian/plugin-drift.mjs +124 -0
- package/src/runtime/obsidian/plugin-presence.mjs +61 -0
- package/src/runtime/obsidian/process-identity.mjs +20 -10
- package/src/runtime/obsidian/project-views.mjs +282 -0
- package/src/runtime/obsidian/questions.mjs +48 -0
- package/src/runtime/obsidian/release-watch.mjs +56 -0
- package/src/runtime/obsidian/service-main.mjs +113 -17
- package/src/runtime/obsidian/service-manager-production.mjs +61 -0
- package/src/runtime/obsidian/service-managers.mjs +275 -0
- package/src/runtime/obsidian/service-record.mjs +105 -5
- package/src/runtime/obsidian/service-server.mjs +50 -12
- package/src/runtime/obsidian/service.mjs +134 -7
- package/src/runtime/obsidian/startup-adapters.mjs +59 -21
- package/src/runtime/obsidian/state-store.mjs +13 -1
- package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
- package/src/runtime/obsidian/vault-location.mjs +300 -0
- package/src/runtime/obsidian/view-counts.mjs +34 -0
- package/src/server/local-sidecar.mjs +193 -7
- package/src/server/server.mjs +45 -6
- package/src/template-bindings/project.mjs +92 -0
- package/src/templates/profile.mjs +225 -0
- package/src/trackables/domain.mjs +175 -0
- package/src/trackables/store.mjs +114 -0
- package/src/ui/knowledge-page.mjs +1388 -0
- package/src/upgrade/explanation.mjs +1 -1
- package/src/upgrade/template-participant.mjs +171 -0
- package/src/upgrade/transaction.mjs +72 -25
- package/src/upgrade/upgrade.mjs +2 -1
- package/templates/external-project-workspace/pack-lifecycle.json +6 -1
- package/templates/knowledge-workspace/.gitattributes +2 -0
- package/templates/knowledge-workspace/AGENTS.md +27 -0
- package/templates/knowledge-workspace/README.md +44 -0
- package/templates/knowledge-workspace/atelier.project.json +22 -0
- package/templates/knowledge-workspace/gitignore +6 -0
- package/templates/knowledge-workspace/knowledge-plan.json +111 -0
- package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
- package/templates/knowledge-workspace/records/forecast.md +22 -0
- package/templates/knowledge-workspace/records/inspection.md +25 -0
- package/templates/knowledge-workspace/records/telescope.md +24 -0
- package/templates/knowledge-workspace/repo-access.v1.json +9 -0
|
@@ -91,6 +91,16 @@ export function heldPaths(edits, scopeId) {
|
|
|
91
91
|
return [...new Set(edits.filter((edit) => edit.scopeId === scopeId && isOpenEdit(edit)).map((edit) => edit.path))].sort(compareText)
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// The notes a view must keep in the vault layout they were published in: those
|
|
95
|
+
// held for an open edit, and those whose edit closed on this tick (automatic
|
|
96
|
+
// mode applies and closes an edit on the tick it observes it). The view is
|
|
97
|
+
// prepared in that layout once more, so the file the person edited becomes the
|
|
98
|
+
// published note, and a later tick lays the view out again and retires it.
|
|
99
|
+
export function layoutHeldPaths(edits, scopeId, now) {
|
|
100
|
+
const closedNow = edits.filter((edit) => edit.scopeId === scopeId && edit.closedAt !== null && edit.closedAt === now)
|
|
101
|
+
return [...new Set([...heldPaths(edits, scopeId), ...closedNow.map((edit) => edit.path)])].sort(compareText)
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
// Automatic dispatch. `authorize()` reads the machine settings and the policy
|
|
95
105
|
// from disk. It is called before the batch and again immediately before each
|
|
96
106
|
// edit, so a revocation, a pause or a switch back to manual stops the very
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
|
|
4
|
-
import {
|
|
4
|
+
import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
|
|
5
|
+
import { EMITTER_VERSION, createPreparationCache, prepareView, readMarkdownLens, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
|
|
6
|
+
import { personalWorkspaceGraph } from '../../projection/obsidian/personal-workspace.mjs'
|
|
5
7
|
import { publishView } from '../../projection/obsidian/publication/publisher.mjs'
|
|
6
8
|
import { recheckDisplacedFiles } from '../../projection/obsidian/recovery/late-writer.mjs'
|
|
7
9
|
import { createRecoveryStore, readFileBytes } from '../../projection/obsidian/recovery/store.mjs'
|
|
@@ -31,6 +33,68 @@ export const DEFAULT_ELIGIBILITY = Object.freeze({
|
|
|
31
33
|
isEligible: (node) => node.classification === 'classified',
|
|
32
34
|
})
|
|
33
35
|
|
|
36
|
+
// A vault that is the person's own also shows the notes that carry no classification, when they decided so: the
|
|
37
|
+
// audience decision `only-you` with `unclassified: 'shown'`, which no other audience decision may carry. Those notes
|
|
38
|
+
// are the person's own files, in a vault only they see. One is admitted only when its bytes read as a note (the byte
|
|
39
|
+
// lens the emitter reads it with), so a file the emitter would refuse never stops the vault: it stays withheld, as does
|
|
40
|
+
// one that cannot be read. A note whose labels are unknown is never admitted: front matter Atelier could not read
|
|
41
|
+
// (`malformed-frontmatter`: a block scalar, a wrapped value), or any top-level `kg` key that is not a
|
|
42
|
+
// block, may carry an audience such as `sensitive` that "only you" leaves out. Only a note with no front matter, or
|
|
43
|
+
// front matter of plain top-level keys at column 0 none of which is `kg` (plainTopLevelKeys) and in which the graph
|
|
44
|
+
// reads no `kg` either, is the person's plain note. Assets follow the documents that
|
|
45
|
+
// embed them, as always.
|
|
46
|
+
export function onlyYouEligibility({ project }) {
|
|
47
|
+
const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
|
|
48
|
+
const readsAsNote = (node) => {
|
|
49
|
+
const root = roots.get(node.repo)
|
|
50
|
+
if (root === undefined || node.extension !== 'md' || typeof node.path !== 'string') return false
|
|
51
|
+
let bytes
|
|
52
|
+
let lens
|
|
53
|
+
try { bytes = readFileBytes(path.join(root, ...node.path.split('/'))); lens = readMarkdownLens(bytes) } catch { return false }
|
|
54
|
+
// Read now, not as the graph saw it: a file that gained front matter since is judged by what it holds now.
|
|
55
|
+
if (lens.frontmatter === null) return node.classificationReason === 'absent-frontmatter'
|
|
56
|
+
if (node.classificationReason !== 'missing-kg-block') return false
|
|
57
|
+
const text = bytes.toString('utf8')
|
|
58
|
+
const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
|
|
59
|
+
if (block === null || !plainTopLevelKeys(block[1])) return false
|
|
60
|
+
// A second layer: the graph's own reading of the front matter has no kg key either.
|
|
61
|
+
try { return !('kg' in markdownMetadata(text)) } catch { return false }
|
|
62
|
+
}
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
revision: () => 'classified-documents/v2+unclassified-notes-read-as-notes-for-only-you/v4+assets-embedded-by-eligible-documents/v1',
|
|
65
|
+
isEligible: (node) => node.classification === 'classified' || (node.classification === 'unclassified' && readsAsNote(node)),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Whether front matter is plain `key: value` lines at its top level, none of them `kg`: every line that is not blank,
|
|
70
|
+
// a comment, or indented (a nested value or a continuation) starts with a bare key. A quoted key, a flow or JSON
|
|
71
|
+
// mapping, a complex key (`? `), a top-level list or anything else unusual is not plain, so a label it may hold (a
|
|
72
|
+
// `kg.audience: sensitive` Atelier did not read) keeps the note withheld. An allow-list, never a pattern to find.
|
|
73
|
+
const PLAIN_KEY = /^([A-Za-z_][A-Za-z0-9_-]*)[ \t]*:(?:[ \t]|$)/
|
|
74
|
+
function plainTopLevelKeys(frontmatter) {
|
|
75
|
+
// Line breaks YAML 1.1 readers honour and this reading does not (NEL, LS, PS) could hide a kg from it.
|
|
76
|
+
if (/[\u0085\u2028\u2029]/.test(frontmatter)) return false
|
|
77
|
+
let rooted = false
|
|
78
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
79
|
+
const trimmed = line.trim()
|
|
80
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue
|
|
81
|
+
// An indented line is a nested value or a continuation only below a key at column 0; YAML also lets a whole root
|
|
82
|
+
// mapping be indented, and then its keys (kg among them) are indented too.
|
|
83
|
+
if (/^[ \t]/.test(line)) { if (!rooted) return false; continue }
|
|
84
|
+
const key = PLAIN_KEY.exec(line)
|
|
85
|
+
if (key === null || key[1] === 'kg') return false
|
|
86
|
+
rooted = true
|
|
87
|
+
}
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The eligibility a workspace's machine settings decide: the one above only for "only you" with unclassified notes
|
|
92
|
+
// shown, and the default, which withholds them, for every other decision and for none.
|
|
93
|
+
export function eligibilityFor({ machine, project }) {
|
|
94
|
+
const audience = machine?.decisions?.audience
|
|
95
|
+
return audience?.choice === 'only-you' && audience.unclassified === 'shown' ? onlyYouEligibility({ project }) : DEFAULT_ELIGIBILITY
|
|
96
|
+
}
|
|
97
|
+
|
|
34
98
|
export function assetEligibilityFor({ graph, eligibility }) {
|
|
35
99
|
const eligibleSources = new Set(graph.nodes.filter((node) => eligibility.isEligible(node) === true).map((node) => node.id))
|
|
36
100
|
const embeds = Array.isArray(graph.embeds) ? graph.embeds : []
|
|
@@ -59,9 +123,13 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
|
|
|
59
123
|
// `index` is the engine's observation index. With both, a source whose observed digest is the cached one is not
|
|
60
124
|
// opened: observation already decided by digest that it did not change, under the stat-hint bound observation
|
|
61
125
|
// documents, and a full reconciliation hashes it again. Without an index every source is read and hashed here.
|
|
62
|
-
|
|
126
|
+
// A project bound to a personal workspace (personal-workspace.mjs) is built by that route, for every caller, with the
|
|
127
|
+
// same caches: the composition's graph, served only under a validity key the composition confirmed. With
|
|
128
|
+
// `deferPersonalValidation` (the engine), an unconfirmed key refuses `personal-validation-pending` instead of composing
|
|
129
|
+
// on this thread.
|
|
130
|
+
export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
|
|
63
131
|
const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
|
|
64
|
-
const canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest })
|
|
132
|
+
const canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation }) ?? buildCanonicalGraph(project, { fileCache: cache, observedDigest })
|
|
65
133
|
if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
|
|
66
134
|
return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
|
|
67
135
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createHash, randomBytes as cryptoRandomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow, realPathAsStored } from '../../project/private-state.mjs'
|
|
5
|
+
import { assertAllocatedFolder, vaultRootFor } from '../../projection/obsidian/recovery/store.mjs'
|
|
6
|
+
import {
|
|
7
|
+
PLUGIN_BEARER, PLUGIN_CHALLENGE_WINDOW_MS, PLUGIN_CHANNEL_PROTOCOL, PLUGIN_HANDSHAKE_TTL_MS, PLUGIN_LEASE_TTL_MS, PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE, PLUGIN_MAX_SESSION_AGE_MS, PLUGIN_MAX_SESSIONS_PER_SCOPE,
|
|
8
|
+
PLUGIN_RENEW_INTERVAL_MS, PLUGIN_STATUS_SCHEMA, pluginClientProof, pluginKeyHint, pluginRequestMac, pluginResponseMac, pluginServerProof, pluginSessionKey, pluginVaultProof,
|
|
9
|
+
} from '../../projection/obsidian/plugin-bridge/channel.mjs'
|
|
10
|
+
import { canonicalJson, compareText, isPlainObject, isoTime } from './documents.mjs'
|
|
11
|
+
|
|
12
|
+
// The service's half of the plugin channel (plugin-bridge/channel.mjs).
|
|
13
|
+
//
|
|
14
|
+
// bearers one random bearer per view, minted when the view's vault first
|
|
15
|
+
// receives the plugin, kept owner-only under state/plugin/ and
|
|
16
|
+
// written into that vault's plugin data file, nowhere else; it
|
|
17
|
+
// is the key of the handshake and never crosses the wire
|
|
18
|
+
// sessions who holds a view open right now: a plugin that proved it holds
|
|
19
|
+
// the vault's key and renews its lease; in memory, gone when the
|
|
20
|
+
// service stops
|
|
21
|
+
// commands challenge, hello, lease, release and status, answered from
|
|
22
|
+
// state the service already keeps; nothing here writes a vault, a
|
|
23
|
+
// source, a manifest or an edit, and no command decides anything
|
|
24
|
+
//
|
|
25
|
+
// A live session also tells the app version the plugin runs in, and an id of
|
|
26
|
+
// that launch of the plugin. That version counts as checked for app
|
|
27
|
+
// qualification (app-capability.mjs) while one launch alone holds the view:
|
|
28
|
+
// the plugin runs inside exactly that app.
|
|
29
|
+
|
|
30
|
+
export const PLUGIN_BEARER_SCHEMA = 'atelier-obsidian-plugin-bearer/v1'
|
|
31
|
+
export const PLUGIN_PRESENCE_SCHEMA = 'atelier-obsidian-plugin-presence/v1'
|
|
32
|
+
const MAX_BEARER_FILES = 256
|
|
33
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
34
|
+
|
|
35
|
+
export function pluginBearerDirectory(workspaceRoot) {
|
|
36
|
+
return path.join(workspaceRoot, 'state', 'plugin')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readBearerFile(file, workspaceId) {
|
|
40
|
+
let document
|
|
41
|
+
try { document = JSON.parse(readRegularTextNoFollow(file)) } catch { return null }
|
|
42
|
+
if (!isPlainObject(document) || Object.keys(document).sort().join(',') !== 'bearer,createdAt,schema,scopeId,workspaceId') return null
|
|
43
|
+
if (document.schema !== PLUGIN_BEARER_SCHEMA || document.workspaceId !== workspaceId || typeof document.scopeId !== 'string' || !PLUGIN_BEARER.test(document.bearer ?? '')) return null
|
|
44
|
+
return document
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The bearer of one view's vault, minted on first use. A file that does not
|
|
48
|
+
// read as this view's bearer is replaced: it cannot authenticate anything, and
|
|
49
|
+
// the vault receives the new bearer with its next publication.
|
|
50
|
+
export function ensurePluginBearer({ workspaceRoot, workspaceId, scopeId, randomBytes = cryptoRandomBytes, clock = () => new Date() }) {
|
|
51
|
+
const directory = ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginBearerDirectory(workspaceRoot), label: 'Obsidian plugin state' })
|
|
52
|
+
const file = path.join(directory, `${segment(scopeId)}.json`)
|
|
53
|
+
const existing = readBearerFile(file, workspaceId)
|
|
54
|
+
if (existing?.scopeId === scopeId) return existing.bearer
|
|
55
|
+
const bearer = randomBytes(32).toString('base64url')
|
|
56
|
+
atomicReplacePrivateText(file, canonicalJson({ schema: PLUGIN_BEARER_SCHEMA, workspaceId, scopeId, bearer, createdAt: isoTime(clock) }))
|
|
57
|
+
return bearer
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every view's bearer this workspace has minted: scopeId -> bearer.
|
|
61
|
+
export function readPluginBearers({ workspaceRoot, workspaceId }) {
|
|
62
|
+
const directory = pluginBearerDirectory(workspaceRoot)
|
|
63
|
+
let names
|
|
64
|
+
try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return new Map(); throw error }
|
|
65
|
+
const bearers = new Map()
|
|
66
|
+
for (const name of names.filter((item) => item.endsWith('.json')).sort().slice(0, MAX_BEARER_FILES)) {
|
|
67
|
+
const document = readBearerFile(path.join(directory, name), workspaceId)
|
|
68
|
+
if (document !== null && name === `${segment(document.scopeId)}.json`) bearers.set(document.scopeId, document.bearer)
|
|
69
|
+
}
|
|
70
|
+
return bearers
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The bearers in memory. They are read again only when the directory changed
|
|
74
|
+
// (a bearer minted, replaced, or deleted to rotate it), so a request costs a
|
|
75
|
+
// stat and a listing of the directory, not a read of every file. The names
|
|
76
|
+
// are part of what is compared: not every platform changes a directory's times
|
|
77
|
+
// when an entry goes away. A bearer the service replaces under the same name
|
|
78
|
+
// is announced with `invalidate()`.
|
|
79
|
+
export function createPluginBearerCache({ workspaceRoot, workspaceId, read = readPluginBearers }) {
|
|
80
|
+
const directory = pluginBearerDirectory(workspaceRoot)
|
|
81
|
+
const signature = () => {
|
|
82
|
+
try {
|
|
83
|
+
const stat = fs.statSync(directory, { bigint: true })
|
|
84
|
+
return `${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${fs.readdirSync(directory).sort().join('/')}`
|
|
85
|
+
} catch (error) { if (error.code === 'ENOENT') return 'absent'; throw error }
|
|
86
|
+
}
|
|
87
|
+
let seen = null
|
|
88
|
+
let bearers = new Map()
|
|
89
|
+
return {
|
|
90
|
+
current() {
|
|
91
|
+
// Taken before the read: a change during the read is seen by the next call.
|
|
92
|
+
const now = signature()
|
|
93
|
+
if (now !== seen) { bearers = read({ workspaceRoot, workspaceId }); seen = now }
|
|
94
|
+
return bearers
|
|
95
|
+
},
|
|
96
|
+
invalidate() { seen = null },
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The decisions the channel's oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
|
|
101
|
+
export const PLUGIN_CHANNEL_PRIMITIVES = Object.freeze({
|
|
102
|
+
// Two HMACs in hex of validated shape, compared in constant time.
|
|
103
|
+
macMatches: (presented, expected) => typeof presented === 'string' && presented.length === expected.length && timingSafeEqual(Buffer.from(presented, 'hex'), Buffer.from(expected, 'hex')),
|
|
104
|
+
counterIsNew: (counter, last) => counter > last,
|
|
105
|
+
handshakeUsedOnce: true,
|
|
106
|
+
// A challenge is answered only within this long of the time it names, and each of its nonces once.
|
|
107
|
+
challengeWindowMs: PLUGIN_CHALLENGE_WINDOW_MS,
|
|
108
|
+
noncesAnsweredOnce: true,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Sessions of the plugin, per view. A session lives as long as its lease: a
|
|
112
|
+
// hello grants one, each renewal extends it by `ttlMs`, and a release, a lapse
|
|
113
|
+
// or `maxAgeMs` ends it. At most `maxPerScope` live sessions per view. A
|
|
114
|
+
// session holds the key its handshake derived; nothing it hands out carries it.
|
|
115
|
+
export function createPluginSessions({ now = () => Date.now(), ttlMs = PLUGIN_LEASE_TTL_MS, maxPerScope = PLUGIN_MAX_SESSIONS_PER_SCOPE, maxAgeMs = PLUGIN_MAX_SESSION_AGE_MS, randomBytes = cryptoRandomBytes } = {}) {
|
|
116
|
+
const sessions = new Map()
|
|
117
|
+
const prune = () => { const at = now(); for (const [sessionId, session] of sessions) if (session.expiresAt <= at || at - session.openedAt >= maxAgeMs) sessions.delete(sessionId) }
|
|
118
|
+
const liveFor = (scopeId) => { prune(); return [...sessions.values()].filter((session) => session.scopeId === scopeId) }
|
|
119
|
+
const shown = ({ sessionKey: _key, keyDigest: _digest, ...session }) => ({ ...session })
|
|
120
|
+
return {
|
|
121
|
+
open({ scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest }) {
|
|
122
|
+
if (liveFor(scopeId).length >= maxPerScope) return null
|
|
123
|
+
const at = now()
|
|
124
|
+
const session = { sessionId: `ps-${randomBytes(16).toString('hex')}`, scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest, counter: 0, openedAt: at, renewedAt: at, expiresAt: at + ttlMs }
|
|
125
|
+
sessions.set(session.sessionId, session)
|
|
126
|
+
return shown(session)
|
|
127
|
+
},
|
|
128
|
+
// The live session with this id, key included, for the channel only; null once it lapsed or was released.
|
|
129
|
+
held(sessionId) { prune(); return sessions.get(sessionId) ?? null },
|
|
130
|
+
renew(sessionId) {
|
|
131
|
+
const session = sessions.get(sessionId)
|
|
132
|
+
if (!session) return null
|
|
133
|
+
session.renewedAt = now()
|
|
134
|
+
session.expiresAt = session.renewedAt + ttlMs
|
|
135
|
+
return shown(session)
|
|
136
|
+
},
|
|
137
|
+
release: (sessionId) => sessions.delete(sessionId),
|
|
138
|
+
live: (scopeId) => liveFor(scopeId).map(shown),
|
|
139
|
+
// What the most recently renewed live session reports, or null when no plugin holds the view. `instances` counts
|
|
140
|
+
// the launches of the plugin behind the live sessions: more than one means more than one app holds the vault.
|
|
141
|
+
report(scopeId) {
|
|
142
|
+
const live = liveFor(scopeId).sort((left, right) => right.renewedAt - left.renewedAt)
|
|
143
|
+
if (live.length === 0) return null
|
|
144
|
+
const [newest] = live
|
|
145
|
+
return { scopeId, appVersion: newest.appVersion, pluginVersion: newest.pluginVersion, sessions: live.length, instances: new Set(live.map((session) => session.instanceId)).size, renewedAt: new Date(newest.renewedAt).toISOString() }
|
|
146
|
+
},
|
|
147
|
+
scopeIds() { prune(); return [...new Set([...sessions.values()].map((session) => session.scopeId))] },
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The commands of the plugin channel (plugin-bridge/channel.mjs). `statusOf(scopeId)`
|
|
152
|
+
// returns the view's freshness entry as the service reports it and its open
|
|
153
|
+
// pending edits; the vault a hello must prove is the view's vault under the
|
|
154
|
+
// workspace state. `authority` is the exact address the request reached.
|
|
155
|
+
export function createPluginChannelForOracleTests({
|
|
156
|
+
workspaceRoot, workspaceId, runtimeId, sessions, statusOf, serviceStatus, now = () => Date.now(), randomBytes = cryptoRandomBytes,
|
|
157
|
+
bearers = createPluginBearerCache({ workspaceRoot, workspaceId }),
|
|
158
|
+
// Told each time a plugin opened a session for a view: the app it runs in has the view's entry.
|
|
159
|
+
onSessionOpened = () => {},
|
|
160
|
+
}, primitives = PLUGIN_CHANNEL_PRIMITIVES) {
|
|
161
|
+
const rules = { ...PLUGIN_CHANNEL_PRIMITIVES, ...primitives }
|
|
162
|
+
// The view's vault wherever it is (vaultRootFor: allocated for the view, else under the data root), and an allocated one
|
|
163
|
+
// only while it is the folder recorded; as the file system stores the path, as the store registers the vault in the app
|
|
164
|
+
// (realPathAsStored): a data root given in another letter case names the same vault.
|
|
165
|
+
const vaultRootOf = (scopeId) => {
|
|
166
|
+
try {
|
|
167
|
+
const found = vaultRootFor({ workspaceRoot, workspaceId, scopeId })
|
|
168
|
+
if (found.allocation !== null) assertAllocatedFolder(found.allocation)
|
|
169
|
+
return realPathAsStored(found.path)
|
|
170
|
+
} catch { return null }
|
|
171
|
+
}
|
|
172
|
+
const keyDigestOf = (bearer) => createHash('sha256').update(bearer, 'utf8').digest()
|
|
173
|
+
const handshakes = new Map()
|
|
174
|
+
const pruneHandshakes = () => { const at = now(); for (const [handshakeId, handshake] of handshakes) if (handshake.expiresAt <= at) handshakes.delete(handshakeId) }
|
|
175
|
+
// The nonces of the challenges each view's key answered, until their time has left the window: a challenge recorded
|
|
176
|
+
// by whoever squatted the address, sent later, is answered at most once and only while it is fresh.
|
|
177
|
+
const answered = new Map()
|
|
178
|
+
const seenBefore = (scopeId, clientNonce, issuedAt) => {
|
|
179
|
+
const at = now()
|
|
180
|
+
const nonces = answered.get(scopeId) ?? new Map()
|
|
181
|
+
for (const [nonce, until] of nonces) if (until <= at) nonces.delete(nonce)
|
|
182
|
+
const seen = nonces.has(clientNonce)
|
|
183
|
+
if (rules.noncesAnsweredOnce) nonces.set(clientNonce, issuedAt + rules.challengeWindowMs + 1)
|
|
184
|
+
answered.set(scopeId, nonces)
|
|
185
|
+
return seen
|
|
186
|
+
}
|
|
187
|
+
const answer = (statusCode, body) => ({ statusCode, body })
|
|
188
|
+
// An answer only the session's key can have made: the exact text of the document, and a MAC over it.
|
|
189
|
+
const sealed = ({ sessionKey, command, sessionId, counter }, document) => {
|
|
190
|
+
const payload = JSON.stringify(document)
|
|
191
|
+
return answer(200, { payload, mac: pluginResponseMac({ sessionKey, command, sessionId, counter, payload }) })
|
|
192
|
+
}
|
|
193
|
+
// A request of a session: its MAC verifies under the session's key and its counter was never seen. A session
|
|
194
|
+
// whose vault's bearer was rotated since its handshake ends: its key came from the old bearer.
|
|
195
|
+
const authenticated = (command, body) => {
|
|
196
|
+
const session = sessions.held(body.sessionId)
|
|
197
|
+
if (session === null) return { refusal: answer(409, { error: 'session-unknown' }) }
|
|
198
|
+
if (!rules.macMatches(body.mac, pluginRequestMac({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }))) return { refusal: answer(401, { error: 'request-not-authenticated' }) }
|
|
199
|
+
if (!rules.counterIsNew(body.counter, session.counter)) return { refusal: answer(401, { error: 'request-replayed' }) }
|
|
200
|
+
session.counter = body.counter
|
|
201
|
+
const bearer = bearers.current().get(session.scopeId)
|
|
202
|
+
if (typeof bearer !== 'string' || !timingSafeEqual(keyDigestOf(bearer), session.keyDigest)) {
|
|
203
|
+
sessions.release(session.sessionId)
|
|
204
|
+
return { refusal: answer(409, { error: 'session-unknown' }) }
|
|
205
|
+
}
|
|
206
|
+
return { session, seal: (document) => sealed({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }, document) }
|
|
207
|
+
}
|
|
208
|
+
const commands = {
|
|
209
|
+
challenge({ body, authority }) {
|
|
210
|
+
if (Math.abs(now() - body.issuedAt) > rules.challengeWindowMs) return answer(401, { error: 'challenge-stale' })
|
|
211
|
+
// The key the hint was made with: every key is tried, all of them, whichever matches.
|
|
212
|
+
let found = null
|
|
213
|
+
for (const [scopeId, bearer] of bearers.current()) {
|
|
214
|
+
if (rules.macMatches(body.keyHint, pluginKeyHint({ bearer, clientNonce: body.clientNonce, issuedAt: body.issuedAt })) && found === null) found = { scopeId, bearer }
|
|
215
|
+
}
|
|
216
|
+
if (found === null) return answer(401, { error: 'plugin-key-unknown' })
|
|
217
|
+
if (seenBefore(found.scopeId, body.clientNonce, body.issuedAt)) return answer(401, { error: 'challenge-replayed' })
|
|
218
|
+
pruneHandshakes()
|
|
219
|
+
// Bounded per view: one view's waiting handshakes never hold up another's.
|
|
220
|
+
if ([...handshakes.values()].filter((handshake) => handshake.scopeId === found.scopeId).length >= PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE) return answer(429, { error: 'too-many-handshakes' })
|
|
221
|
+
const handshake = { handshakeId: `ph-${randomBytes(16).toString('hex')}`, scopeId: found.scopeId, authority, clientNonce: body.clientNonce, serverNonce: randomBytes(32).toString('hex'), expiresAt: now() + PLUGIN_HANDSHAKE_TTL_MS }
|
|
222
|
+
handshakes.set(handshake.handshakeId, handshake)
|
|
223
|
+
return answer(200, { protocol: PLUGIN_CHANNEL_PROTOCOL, handshakeId: handshake.handshakeId, serverNonce: handshake.serverNonce, serverProof: pluginServerProof({ bearer: found.bearer, ...handshake }) })
|
|
224
|
+
},
|
|
225
|
+
hello({ body, authority }) {
|
|
226
|
+
pruneHandshakes()
|
|
227
|
+
const handshake = handshakes.get(body.handshakeId) ?? null
|
|
228
|
+
if (rules.handshakeUsedOnce) handshakes.delete(body.handshakeId)
|
|
229
|
+
const bearer = handshake === null ? undefined : bearers.current().get(handshake.scopeId)
|
|
230
|
+
if (handshake === null || handshake.authority !== authority || typeof bearer !== 'string') return answer(401, { error: 'handshake-unknown' })
|
|
231
|
+
const { scopeId } = handshake
|
|
232
|
+
const bound = { bearer, scopeId, authority, clientNonce: handshake.clientNonce, serverNonce: handshake.serverNonce, handshakeId: handshake.handshakeId }
|
|
233
|
+
const expected = pluginClientProof({ ...bound, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, vaultProof: body.vaultProof })
|
|
234
|
+
if (!rules.macMatches(body.clientProof, expected)) return answer(401, { error: 'plugin-not-authenticated' })
|
|
235
|
+
const sessionKey = pluginSessionKey(bound)
|
|
236
|
+
// Compared, never named: the vault the app has open, proven under this session's key.
|
|
237
|
+
const vaultRoot = vaultRootOf(scopeId)
|
|
238
|
+
if (vaultRoot === null || !rules.macMatches(body.vaultProof, pluginVaultProof({ sessionKey, vaultPath: vaultRoot }))) return answer(409, { error: 'wrong-vault' })
|
|
239
|
+
const session = sessions.open({ scopeId, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, sessionKey, keyDigest: keyDigestOf(bearer) })
|
|
240
|
+
if (session === null) return answer(429, { error: 'too-many-sessions' })
|
|
241
|
+
onSessionOpened(scopeId)
|
|
242
|
+
return sealed({ sessionKey, command: 'hello', sessionId: session.sessionId, counter: 0 }, {
|
|
243
|
+
schema: PLUGIN_CHANNEL_PROTOCOL, scopeId, sessionId: session.sessionId, runtimeId, leaseTtlMs: session.expiresAt - session.openedAt, renewEveryMs: PLUGIN_RENEW_INTERVAL_MS,
|
|
244
|
+
})
|
|
245
|
+
},
|
|
246
|
+
lease({ body }) {
|
|
247
|
+
const request = authenticated('lease', body)
|
|
248
|
+
if (request.refusal) return request.refusal
|
|
249
|
+
const session = sessions.renew(request.session.sessionId)
|
|
250
|
+
return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: session.scopeId, sessionId: session.sessionId, leaseTtlMs: session.expiresAt - session.renewedAt })
|
|
251
|
+
},
|
|
252
|
+
release({ body }) {
|
|
253
|
+
const request = authenticated('release', body)
|
|
254
|
+
if (request.refusal) return request.refusal
|
|
255
|
+
return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: request.session.scopeId, released: sessions.release(request.session.sessionId) })
|
|
256
|
+
},
|
|
257
|
+
status({ body }) {
|
|
258
|
+
const request = authenticated('status', body)
|
|
259
|
+
if (request.refusal) return request.refusal
|
|
260
|
+
const { scopeId } = request.session
|
|
261
|
+
const { view, pendingEdits } = statusOf(scopeId)
|
|
262
|
+
return request.seal({ schema: PLUGIN_STATUS_SCHEMA, scopeId, service: { status: serviceStatus() }, view, pendingEdits })
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
bearers: () => bearers.current(),
|
|
267
|
+
// The service minted a bearer: whatever the directory's times say, the next lookup reads the bearers again.
|
|
268
|
+
bearersChanged: () => bearers.invalidate?.(),
|
|
269
|
+
handle: (command, request) => commands[command](request),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function createPluginChannel(options) {
|
|
274
|
+
return createPluginChannelForOracleTests(options, PLUGIN_CHANNEL_PRIMITIVES)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Which views a plugin holds open, for the service's status document, and
|
|
278
|
+
// whether the person wants the plugin in each vault (`entry`: undecided,
|
|
279
|
+
// requested, on or off; see plugin-choice.mjs). Counts, versions and states
|
|
280
|
+
// only: no vault path, no session identity.
|
|
281
|
+
export function pluginPresence({ sessions, scopeIds, entryOf = () => null }) {
|
|
282
|
+
const scopes = [...new Set([...scopeIds, ...sessions.scopeIds()])].sort(compareText).map((scopeId) => {
|
|
283
|
+
const report = sessions.report(scopeId)
|
|
284
|
+
const entry = entryOf(scopeId)
|
|
285
|
+
const presence = report === null ? { scopeId, present: false, sessions: 0 } : { scopeId, present: true, sessions: report.sessions, appVersion: report.appVersion, pluginVersion: report.pluginVersion, renewedAt: report.renewedAt }
|
|
286
|
+
return entry === null ? presence : { ...presence, entry }
|
|
287
|
+
})
|
|
288
|
+
return { schema: PLUGIN_PRESENCE_SCHEMA, leaseTtlMs: PLUGIN_LEASE_TTL_MS, scopes }
|
|
289
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
4
|
+
import { PLUGIN_ID } from '../../projection/obsidian/plugin-bridge/channel.mjs'
|
|
5
|
+
import { COMMUNITY_PLUGINS_PATH } from '../../projection/obsidian/materialize/settings.mjs'
|
|
6
|
+
import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
|
|
7
|
+
import { vaultRootFor } from '../../projection/obsidian/recovery/store.mjs'
|
|
8
|
+
import { canonicalJson, isPlainObject, isoTime } from './documents.mjs'
|
|
9
|
+
|
|
10
|
+
// Whether the person wants Atelier's plugin in one vault, as the vault itself
|
|
11
|
+
// says it: the `atelier-projection` entry of .obsidian/community-plugins.json.
|
|
12
|
+
//
|
|
13
|
+
// (no record) nothing decided yet: Atelier offers the entry and the files
|
|
14
|
+
// requested `atelier obsidian plugin on` asked for it: offered again
|
|
15
|
+
// offered the entry is in place, but it was written while an app held
|
|
16
|
+
// the vault; that app keeps the list it read in memory and may
|
|
17
|
+
// write it back without the entry, so a list without it is no
|
|
18
|
+
// decision of the person's yet: the entry is offered again
|
|
19
|
+
// on the app has the entry: it was in place before an app next
|
|
20
|
+
// opened the vault (published while none ran), or the plugin
|
|
21
|
+
// ran in the app; from now on a list without it is the
|
|
22
|
+
// person's decision
|
|
23
|
+
// off the person removed the entry (turned the plugin off, or
|
|
24
|
+
// uninstalled it, in Obsidian): Atelier never adds it back and
|
|
25
|
+
// never creates a plugin file that is not there; it only keeps
|
|
26
|
+
// the files that are present current
|
|
27
|
+
//
|
|
28
|
+
// The decision is read before a view is prepared, from the bytes of the file
|
|
29
|
+
// on disk, and those bytes travel with the prepared view: the publisher writes
|
|
30
|
+
// the entry only over exactly those bytes, so a change the person makes while
|
|
31
|
+
// a publication runs is never written over, and the next preparation reads it.
|
|
32
|
+
// A file that is absent or is not a list decides nothing. A record that cannot
|
|
33
|
+
// be read counts as `off`: nothing is re-added on a guess. Status reads the
|
|
34
|
+
// same change without recording it (currentPluginChoice), so it says what the
|
|
35
|
+
// person did before the view's next preparation records it.
|
|
36
|
+
|
|
37
|
+
export const PLUGIN_CHOICE_SCHEMA = 'atelier-obsidian-plugin-choice/v1'
|
|
38
|
+
export const PLUGIN_CHOICE_STATES = Object.freeze(['requested', 'offered', 'on', 'off'])
|
|
39
|
+
const REASONS = Object.freeze(['requested-by-command', 'entry-offered-while-the-app-runs', 'entry-confirmed', 'entry-seen-by-the-app', 'entry-removed-by-person', 'entry-restored-by-person'])
|
|
40
|
+
// The states in which a list without the entry is not the person's decision: the entry is offered again.
|
|
41
|
+
const NOT_YET_CONFIRMED = Object.freeze(['undecided', 'requested', 'offered'])
|
|
42
|
+
const MAX_SETTINGS_BYTES = 256 * 1024
|
|
43
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
44
|
+
|
|
45
|
+
export const pluginChoiceDirectory = (workspaceRoot) => path.join(workspaceRoot, 'state', 'plugin', 'choices')
|
|
46
|
+
// Where a view's vault is, as the recovery store finds it (vaultRootFor): the folder allocated to the view where the
|
|
47
|
+
// workspace decided its vaults live, else its folder under the data root. A record that cannot be read, or was lost,
|
|
48
|
+
// refuses, typed. Pass `workspaceId`; every caller here does. The two-argument form of earlier releases still works:
|
|
49
|
+
// it takes the name of the workspace's private-state folder as the id, which a record of another workspace (or a
|
|
50
|
+
// folder reached through a link) does not match, and such a record refuses, typed, rather than answering another vault.
|
|
51
|
+
export function viewVaultRoot(workspaceRoot, scopeId, workspaceId = path.basename(String(workspaceRoot))) {
|
|
52
|
+
return vaultRootFor({ workspaceRoot, workspaceId, scopeId }).path
|
|
53
|
+
}
|
|
54
|
+
const choiceFile = (workspaceRoot, scopeId) => path.join(pluginChoiceDirectory(workspaceRoot), `${segment(scopeId)}.json`)
|
|
55
|
+
|
|
56
|
+
function validChoice(document, { workspaceId, scopeId }) {
|
|
57
|
+
return isPlainObject(document) && Object.keys(document).sort().join(',') === 'reason,schema,scopeId,since,state,workspaceId'
|
|
58
|
+
&& document.schema === PLUGIN_CHOICE_SCHEMA && document.workspaceId === workspaceId && document.scopeId === scopeId
|
|
59
|
+
&& PLUGIN_CHOICE_STATES.includes(document.state) && REASONS.includes(document.reason) && typeof document.since === 'string'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// { state: 'undecided' | 'requested' | 'offered' | 'on' | 'off', reason, since, unreadable? }
|
|
63
|
+
export function readPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
|
|
64
|
+
let text
|
|
65
|
+
try { text = readRegularTextNoFollow(choiceFile(workspaceRoot, scopeId)) } catch (error) {
|
|
66
|
+
if (error.code === 'ENOENT') return { state: 'undecided', reason: null, since: null }
|
|
67
|
+
return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
|
|
68
|
+
}
|
|
69
|
+
let document = null
|
|
70
|
+
try { document = JSON.parse(text) } catch { document = null }
|
|
71
|
+
if (!validChoice(document, { workspaceId, scopeId })) return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
|
|
72
|
+
return { state: document.state, reason: document.reason, since: document.since }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function writePluginChoice({ workspaceRoot, workspaceId, scopeId, state, reason, clock = () => new Date() }) {
|
|
76
|
+
const document = { schema: PLUGIN_CHOICE_SCHEMA, workspaceId, scopeId, state, reason, since: isoTime(clock) }
|
|
77
|
+
if (!validChoice(document, { workspaceId, scopeId })) throw new TypeError('a plugin choice names a known state and reason')
|
|
78
|
+
ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginChoiceDirectory(workspaceRoot), label: 'Obsidian plugin state' })
|
|
79
|
+
atomicReplacePrivateText(choiceFile(workspaceRoot, scopeId), canonicalJson(document))
|
|
80
|
+
return { state, reason, since: document.since }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 'absent', 'unreadable' (a parent is a link or not a directory) or 'reachable'.
|
|
84
|
+
function parentsOf(vaultRoot, relativePath) {
|
|
85
|
+
let current = vaultRoot
|
|
86
|
+
for (const part of relativePath.split('/').slice(0, -1)) {
|
|
87
|
+
current = path.join(current, part)
|
|
88
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false })
|
|
89
|
+
if (!stat) return 'absent'
|
|
90
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
|
|
91
|
+
}
|
|
92
|
+
return 'reachable'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Whether a vault path holds a regular file under real directories.
|
|
96
|
+
export function vaultFilePresent(vaultRoot, relativePath) {
|
|
97
|
+
return parentsOf(vaultRoot, relativePath) === 'reachable' && fs.lstatSync(path.join(vaultRoot, relativePath), { throwIfNoEntry: false })?.isFile() === true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The bytes of a settings file of a vault, read without following a link and
|
|
101
|
+
// bounded: { state: 'absent' | 'present' | 'unreadable', bytes, digest }.
|
|
102
|
+
export function readVaultSettingsFile(vaultRoot, relativePath) {
|
|
103
|
+
const parents = parentsOf(vaultRoot, relativePath)
|
|
104
|
+
if (parents !== 'reachable') return { state: parents, bytes: null, digest: null }
|
|
105
|
+
let descriptor
|
|
106
|
+
try { descriptor = openRegularFileNoFollow(path.join(vaultRoot, relativePath)) } catch (error) {
|
|
107
|
+
return error.code === 'ENOENT' ? { state: 'absent', bytes: null, digest: null } : { state: 'unreadable', bytes: null, digest: null }
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
if (fs.fstatSync(descriptor).size > MAX_SETTINGS_BYTES) return { state: 'unreadable', bytes: null, digest: null }
|
|
111
|
+
const bytes = fs.readFileSync(descriptor)
|
|
112
|
+
return { state: 'present', bytes, digest: sha256Digest(bytes) }
|
|
113
|
+
} finally {
|
|
114
|
+
fs.closeSync(descriptor)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// What the vault's community plugin list says about Atelier's entry:
|
|
119
|
+
// 'listed', 'not-listed' (a list without it), or 'no-list' (absent, unreadable
|
|
120
|
+
// or not a list), with the bytes read.
|
|
121
|
+
export function readCommunityEntry(vaultRoot) {
|
|
122
|
+
const file = readVaultSettingsFile(vaultRoot, COMMUNITY_PLUGINS_PATH)
|
|
123
|
+
if (file.state !== 'present') return { entry: 'no-list', file }
|
|
124
|
+
let list = null
|
|
125
|
+
try { list = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(file.bytes)) } catch { list = null }
|
|
126
|
+
if (!Array.isArray(list)) return { entry: 'no-list', file }
|
|
127
|
+
return { entry: list.includes(PLUGIN_ID) ? 'listed' : 'not-listed', file }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A change of the decision the vault shows against the record, or null: an
|
|
131
|
+
// entry removed after it was confirmed is the person turning the plugin off; an
|
|
132
|
+
// entry the person put back turns it on again.
|
|
133
|
+
function changeShown(choice, entry) {
|
|
134
|
+
if (choice.state === 'on' && entry === 'not-listed') return { state: 'off', reason: 'entry-removed-by-person' }
|
|
135
|
+
if (choice.state === 'off' && !choice.unreadable && entry === 'listed') return { state: 'on', reason: 'entry-restored-by-person' }
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The decision for one view, from its record and what its vault says now, as
|
|
140
|
+
// the view is prepared: a change is recorded here.
|
|
141
|
+
export function decidePluginChoice({ workspaceRoot, workspaceId, scopeId, vaultRoot, clock }) {
|
|
142
|
+
const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
143
|
+
const community = readCommunityEntry(vaultRoot)
|
|
144
|
+
const change = changeShown(recorded, community.entry)
|
|
145
|
+
return { choice: change === null ? recorded : writePluginChoice({ workspaceRoot, workspaceId, scopeId, ...change, clock }), community }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The same decision for status, read only: a change not recorded yet is marked
|
|
149
|
+
// `pending` (the view's next preparation records it).
|
|
150
|
+
export function currentPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
|
|
151
|
+
const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
152
|
+
// A view whose vault cannot be found (its record cannot be read) shows what was recorded; its view says why.
|
|
153
|
+
let vaultRoot
|
|
154
|
+
try { vaultRoot = viewVaultRoot(workspaceRoot, scopeId, workspaceId) } catch (error) { if (typeof error?.code !== 'string') throw error; return recorded }
|
|
155
|
+
const change = changeShown(recorded, readCommunityEntry(vaultRoot).entry)
|
|
156
|
+
return change === null ? recorded : { ...change, since: null, pending: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// After a publication: an offered entry that is now in place. Published while
|
|
160
|
+
// no app ran, it is confirmed: an app reads the list, entry and all, when it
|
|
161
|
+
// next opens the vault. Published through a running app, it is only offered:
|
|
162
|
+
// that app may not have read it, and the plugin running there confirms it
|
|
163
|
+
// (confirmPluginSeen). So is one published into a vault no Obsidian listed
|
|
164
|
+
// while one ran (`direct-unheld`): the evidence covers only the lists Atelier
|
|
165
|
+
// can read, not another profile's or another machine's.
|
|
166
|
+
const IN_PLACE = new Set(['policy-satisfied', 'created', 'published', 'published-external-captured', 'already-current'])
|
|
167
|
+
export function confirmPluginEntry({ workspaceRoot, workspaceId, scopeId, result, clock }) {
|
|
168
|
+
const unit = (result?.notes ?? []).find((entry) => entry.path === COMMUNITY_PLUGINS_PATH)
|
|
169
|
+
if (!unit || !IN_PLACE.has(unit.outcome) || unit.changedAfterPublication === true) return null
|
|
170
|
+
const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
171
|
+
if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
|
|
172
|
+
if (result.mode === 'direct') return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-confirmed', clock })
|
|
173
|
+
if (choice.state === 'offered') return null
|
|
174
|
+
return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'offered', reason: 'entry-offered-while-the-app-runs', clock })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The plugin said hello from the vault: the app it runs in has the entry, so it is confirmed.
|
|
178
|
+
export function confirmPluginSeen({ workspaceRoot, workspaceId, scopeId, clock }) {
|
|
179
|
+
const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
|
|
180
|
+
if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
|
|
181
|
+
return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-seen-by-the-app', clock })
|
|
182
|
+
}
|