@mnstry/atelier 0.2.0-alpha.12 → 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 +661 -0
- package/README.md +142 -6
- 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-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/docs/architecture.md +89 -0
- package/docs/assurance-controls.md +2 -2
- 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 +181 -16
- package/docs/obsidian-contract.md +223 -15
- package/docs/obsidian-plugin.md +38 -4
- package/docs/obsidian.md +613 -51
- 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 +49 -0
- 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 +47 -2
- 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/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 +59 -5
- 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 +754 -63
- 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 +30 -0
- package/src/coordination/index.mjs +110 -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/projection/obsidian/edits/apply.mjs +55 -16
- package/src/projection/obsidian/personal-workspace.mjs +436 -0
- package/src/projection/obsidian/proposals/adapter.mjs +47 -8
- package/src/projection/obsidian/proposals/observation.mjs +10 -2
- package/src/projection/obsidian/publication/publisher.mjs +142 -13
- package/src/projection/obsidian/publication/vault-list.mjs +4 -3
- package/src/projection/obsidian/recovery/index.mjs +4 -1
- package/src/projection/obsidian/recovery/store.mjs +160 -7
- package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
- package/src/readiness/readiness.mjs +10 -3
- package/src/reflection/index.mjs +56 -0
- package/src/runtime/obsidian/app-production-seams.mjs +48 -16
- package/src/runtime/obsidian/app-registration.mjs +175 -28
- package/src/runtime/obsidian/app-restart.mjs +154 -0
- package/src/runtime/obsidian/engine.mjs +163 -20
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +10 -7
- package/src/runtime/obsidian/launch-plan.mjs +7 -4
- package/src/runtime/obsidian/lifecycle.mjs +92 -12
- package/src/runtime/obsidian/login-item.mjs +332 -0
- package/src/runtime/obsidian/machine-settings.mjs +119 -12
- package/src/runtime/obsidian/opening.mjs +171 -37
- package/src/runtime/obsidian/pipeline.mjs +71 -3
- package/src/runtime/obsidian/plugin-channel.mjs +11 -3
- package/src/runtime/obsidian/plugin-choice.mjs +16 -4
- package/src/runtime/obsidian/plugin-drift.mjs +59 -11
- package/src/runtime/obsidian/plugin-presence.mjs +14 -1
- 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 +99 -16
- 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 +87 -23
- package/src/runtime/obsidian/service.mjs +36 -3
- package/src/runtime/obsidian/startup-adapters.mjs +59 -21
- 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 +169 -2
- package/src/server/server.mjs +28 -2
- 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 +1 -0
- 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
|
@@ -4,9 +4,8 @@ import { refuse } from './errors.mjs'
|
|
|
4
4
|
// Builds the text of an operating-system startup unit for the maintenance
|
|
5
5
|
// service. Pure: it returns text and touches nothing. It writes no file,
|
|
6
6
|
// installs nothing and runs no service manager. Installing a unit is a
|
|
7
|
-
// separate
|
|
8
|
-
// recorded consent that covers startup
|
|
9
|
-
// without one.
|
|
7
|
+
// separate step (login-item.mjs) that a person asks for, and the service
|
|
8
|
+
// refuses `--startup` without a recorded consent that covers startup.
|
|
10
9
|
//
|
|
11
10
|
// Every machine-specific value is an input. Nothing is looked up here: no
|
|
12
11
|
// home directory, no environment, no current directory, no installed path.
|
|
@@ -15,17 +14,30 @@ import { refuse } from './errors.mjs'
|
|
|
15
14
|
// Linux a systemd user unit
|
|
16
15
|
// Windows refused, typed: startup there has not been qualified
|
|
17
16
|
//
|
|
18
|
-
// The unit runs the service entry in the foreground with `--startup
|
|
19
|
-
//
|
|
17
|
+
// The unit runs the service entry in the foreground with `--startup`, in the
|
|
18
|
+
// root directory (never one that could be a vault), with the search path it
|
|
19
|
+
// is given, so the service finds the same programs the person uses. It is
|
|
20
|
+
// restarted after a crash and after an exit with a code the service uses to
|
|
21
|
+
// ask for it (a new release on disk), never after a clean stop and never in a
|
|
22
|
+
// tight loop; a refusal under `--startup` exits cleanly. The service writes
|
|
23
|
+
// its own bounded log; the unit's output file receives only what happens
|
|
24
|
+
// before it can, such as a module that cannot be loaded.
|
|
20
25
|
|
|
21
26
|
export const STARTUP_PLATFORMS = Object.freeze(['darwin', 'linux'])
|
|
22
27
|
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
28
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
29
|
+
// Seconds: the least time between two starts, and how long a stop may take before the manager ends the process. The
|
|
30
|
+
// service gives a tick in flight 30 seconds to finish when it is asked to stop.
|
|
31
|
+
const THROTTLE_SECONDS = 60
|
|
32
|
+
const EXIT_TIMEOUT_SECONDS = 60
|
|
23
33
|
|
|
24
34
|
const xml = (value) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''')
|
|
25
35
|
// systemd: quoted, with its specifier and variable characters doubled.
|
|
26
36
|
const unitWord = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', '$$$$')}"`
|
|
37
|
+
// systemd: one quoted assignment of Environment=, where `$` has no meaning and only specifiers are expanded.
|
|
38
|
+
const unitAssignment = (name, value) => `"${`${name}=${value}`.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`
|
|
27
39
|
|
|
28
|
-
function launchdText({ label, words, logPath, workingDirectory }) {
|
|
40
|
+
function launchdText({ label, words, logPath, workingDirectory, searchPath }) {
|
|
29
41
|
const lines = [
|
|
30
42
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
31
43
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -33,12 +45,15 @@ function launchdText({ label, words, logPath, workingDirectory }) {
|
|
|
33
45
|
'<dict>',
|
|
34
46
|
' <key>Label</key>', ` <string>${xml(label)}</string>`,
|
|
35
47
|
' <key>ProgramArguments</key>', ' <array>', ...words.map((word) => ` <string>${xml(word)}</string>`), ' </array>',
|
|
48
|
+
...(searchPath === null ? [] : [' <key>EnvironmentVariables</key>', ' <dict>', ' <key>PATH</key>', ` <string>${xml(searchPath)}</string>`, ' </dict>']),
|
|
49
|
+
' <key>WorkingDirectory</key>', ` <string>${xml(workingDirectory)}</string>`,
|
|
36
50
|
' <key>RunAtLoad</key>', ' <true/>',
|
|
37
|
-
// Restarted after a crash, not after a clean stop; a refusal is not retried
|
|
51
|
+
// Restarted after a crash or a non-zero exit, not after a clean stop; a refusal exits cleanly and is not retried.
|
|
38
52
|
' <key>KeepAlive</key>', ' <dict>', ' <key>SuccessfulExit</key>', ' <false/>', ' </dict>',
|
|
39
|
-
' <key>ThrottleInterval</key>',
|
|
40
|
-
' <key>
|
|
41
|
-
|
|
53
|
+
' <key>ThrottleInterval</key>', ` <integer>${THROTTLE_SECONDS}</integer>`,
|
|
54
|
+
' <key>ExitTimeOut</key>', ` <integer>${EXIT_TIMEOUT_SECONDS}</integer>`,
|
|
55
|
+
// Standard, not Background: the service's budgets were measured without the I/O throttling of a background process.
|
|
56
|
+
' <key>ProcessType</key>', ' <string>Standard</string>',
|
|
42
57
|
' <key>StandardOutPath</key>', ` <string>${xml(logPath)}</string>`,
|
|
43
58
|
' <key>StandardErrorPath</key>', ` <string>${xml(logPath)}</string>`,
|
|
44
59
|
'</dict>',
|
|
@@ -47,7 +62,7 @@ function launchdText({ label, words, logPath, workingDirectory }) {
|
|
|
47
62
|
return `${lines.join('\n')}\n`
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
function systemdText({ label, words, logPath, workingDirectory }) {
|
|
65
|
+
function systemdText({ label, words, logPath, workingDirectory, searchPath }) {
|
|
51
66
|
const lines = [
|
|
52
67
|
'[Unit]',
|
|
53
68
|
`Description=Atelier Obsidian maintenance (${label.replaceAll(/[^A-Za-z0-9._-]/g, '')})`,
|
|
@@ -55,11 +70,13 @@ function systemdText({ label, words, logPath, workingDirectory }) {
|
|
|
55
70
|
'[Service]',
|
|
56
71
|
'Type=simple',
|
|
57
72
|
`ExecStart=${words.map(unitWord).join(' ')}`,
|
|
58
|
-
...(
|
|
59
|
-
|
|
73
|
+
...(searchPath === null ? [] : [`Environment=${unitAssignment('PATH', searchPath)}`]),
|
|
74
|
+
`WorkingDirectory=${workingDirectory.replaceAll('%', '%%')}`,
|
|
75
|
+
// Restarted after a crash or a non-zero exit; not after a clean stop, and not after a refusal (exit status 2).
|
|
60
76
|
'Restart=on-failure',
|
|
61
|
-
|
|
77
|
+
`RestartSec=${THROTTLE_SECONDS}`,
|
|
62
78
|
'RestartPreventExitStatus=2',
|
|
79
|
+
`TimeoutStopSec=${EXIT_TIMEOUT_SECONDS}`,
|
|
63
80
|
`StandardOutput=append:${logPath.replaceAll('%', '%%')}`,
|
|
64
81
|
`StandardError=append:${logPath.replaceAll('%', '%%')}`,
|
|
65
82
|
'',
|
|
@@ -69,19 +86,40 @@ function systemdText({ label, words, logPath, workingDirectory }) {
|
|
|
69
86
|
return `${lines.join('\n')}\n`
|
|
70
87
|
}
|
|
71
88
|
|
|
72
|
-
//
|
|
73
|
-
|
|
89
|
+
// The search path a unit carries: the entries of `value` (a PATH as the environment holds it) that are absolute
|
|
90
|
+
// directories, in their order, each once, and none inside a temporary directory (`temporary`, absolute prefixes): a
|
|
91
|
+
// relative entry would be resolved in the unit's working directory, and a temporary one (an agent's session, say) is
|
|
92
|
+
// gone after a restart. Pure. Null when nothing is left.
|
|
93
|
+
export function startupSearchPath(value, { temporary = [] } = {}) {
|
|
94
|
+
if (typeof value !== 'string') return null
|
|
95
|
+
const inside = (entry) => temporary.some((prefix) => typeof prefix === 'string' && path.posix.isAbsolute(prefix) && (entry === prefix || entry.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)))
|
|
96
|
+
const kept = []
|
|
97
|
+
for (const raw of value.split(':')) {
|
|
98
|
+
const entry = raw.length > 1 ? raw.replace(/\/+$/, '') : raw
|
|
99
|
+
if (!path.posix.isAbsolute(entry) || CONTROL.test(entry) || inside(entry) || kept.includes(entry)) continue
|
|
100
|
+
kept.push(entry)
|
|
101
|
+
}
|
|
102
|
+
return kept.length === 0 ? null : kept.join(':')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// { platform, kind, fileName, text }. `args` are the arguments of the entry after `--startup`; `searchPath` is the PATH
|
|
106
|
+
// the unit sets (absolute entries joined by `:`; see startupSearchPath), none when omitted.
|
|
107
|
+
export function buildStartupAdapter({ platform, label, nodePath, entryPath, args = [], logPath, workingDirectory = '/', searchPath = null } = {}) {
|
|
74
108
|
if (platform === 'win32') refuse('startup-platform-unqualified', 'operating-system startup on Windows has not been qualified; no unit is generated for it', { platform })
|
|
75
109
|
if (!STARTUP_PLATFORMS.includes(platform)) refuse('startup-platform-unsupported', 'no startup unit is known for this platform', { platform: String(platform) })
|
|
76
110
|
if (typeof label !== 'string' || !LABEL.test(label)) refuse('startup-adapter-input-invalid', 'the unit label must be a plain identifier')
|
|
77
111
|
const flavor = path.posix
|
|
78
|
-
for (const [name, value] of [['nodePath', nodePath], ['entryPath', entryPath], ['logPath', logPath],
|
|
79
|
-
if (typeof value !== 'string' || !flavor.isAbsolute(value) ||
|
|
112
|
+
for (const [name, value] of [['nodePath', nodePath], ['entryPath', entryPath], ['logPath', logPath], ['workingDirectory', workingDirectory]]) {
|
|
113
|
+
if (typeof value !== 'string' || !flavor.isAbsolute(value) || CONTROL.test(value)) refuse('startup-adapter-input-invalid', `${name} must be an absolute path given by the caller`, { name })
|
|
114
|
+
}
|
|
115
|
+
if (searchPath !== null && (typeof searchPath !== 'string' || searchPath.split(':').some((entry) => !flavor.isAbsolute(entry) || CONTROL.test(entry)))) {
|
|
116
|
+
refuse('startup-adapter-input-invalid', 'searchPath must be absolute directories joined by ":"', { name: 'searchPath' })
|
|
80
117
|
}
|
|
81
|
-
if (!Array.isArray(args) || args.some((word) => typeof word !== 'string' || word === '' ||
|
|
118
|
+
if (!Array.isArray(args) || args.some((word) => typeof word !== 'string' || word === '' || CONTROL.test(word))) refuse('startup-adapter-input-invalid', 'args must be a list of plain words')
|
|
82
119
|
if (args.includes('--startup')) refuse('startup-adapter-input-invalid', '--startup is added by the builder')
|
|
83
120
|
const words = [nodePath, entryPath, '--startup', ...args]
|
|
121
|
+
const input = { label, words, logPath, workingDirectory, searchPath }
|
|
84
122
|
return platform === 'darwin'
|
|
85
|
-
? { platform, kind: 'launchd-user-agent', fileName: `${label}.plist`, text: launchdText(
|
|
86
|
-
: { platform, kind: 'systemd-user-unit', fileName: `${label}.service`, text: systemdText(
|
|
123
|
+
? { platform, kind: 'launchd-user-agent', fileName: `${label}.plist`, text: launchdText(input) }
|
|
124
|
+
: { platform, kind: 'systemd-user-unit', fileName: `${label}.service`, text: systemdText(input) }
|
|
87
125
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
import { obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../../projection/obsidian/publication/vault-list.mjs'
|
|
5
|
+
import { realPathOfLocation } from './vault-location.mjs'
|
|
6
|
+
|
|
7
|
+
// Whether the Obsidian this account runs lists a vault Atelier is filling for
|
|
8
|
+
// the first time, as the publisher's `direct-unheld` path needs to know
|
|
9
|
+
// (publisher.mjs; docs/obsidian-contract.md, "First publication into a vault
|
|
10
|
+
// no Obsidian lists"). Read from the app's settings file, never from the app:
|
|
11
|
+
//
|
|
12
|
+
// { unlisted: true } the file was read, and none of its entries is the vault root, a folder above
|
|
13
|
+
// it or a folder inside it
|
|
14
|
+
// { unlisted: false, reason, ... } anything else, which gives no evidence
|
|
15
|
+
//
|
|
16
|
+
// It errs towards "no evidence": a file that is missing (an app that has shown
|
|
17
|
+
// only its starter window writes none), that cannot be read after `attempts`
|
|
18
|
+
// reads `delayMs` apart (the app rewrites it with a plain write, so a reader
|
|
19
|
+
// can find it empty or cut short while it writes), that is not a JSON object,
|
|
20
|
+
// or that has an entry whose folder is not an absolute path or has a `..`
|
|
21
|
+
// segment (after a link, `..` leads where the text does not); and a Flatpak or
|
|
22
|
+
// snap build of the app anywhere on this account, whose list lives in its
|
|
23
|
+
// sandbox and not in this file. The root and every listed folder are compared
|
|
24
|
+
// as written and as their real paths, in any letter case, so a link on the way
|
|
25
|
+
// to either side, or another spelling, does not hide an entry.
|
|
26
|
+
|
|
27
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
28
|
+
const folded = (target) => target.normalize('NFC').toLowerCase()
|
|
29
|
+
// The real path of a folder that may not exist, and its lexical path when it cannot be resolved (a volume that is gone).
|
|
30
|
+
const realOrLexical = (target) => { try { return realPathOfLocation(target) } catch { return path.resolve(target) } }
|
|
31
|
+
const spellings = (target) => [...new Set([path.resolve(target), realOrLexical(target)].map(folded))]
|
|
32
|
+
// Strictly inside: `..x` is a folder name, not a way up.
|
|
33
|
+
const inside = (parent, child) => { const relative = path.relative(parent, child); return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) }
|
|
34
|
+
|
|
35
|
+
// How a listed folder stands to the vault root, or null when it is neither the root, above it, nor inside it.
|
|
36
|
+
function relationOf(entryPath, root) {
|
|
37
|
+
const listed = spellings(entryPath)
|
|
38
|
+
if (listed.some((folder) => root.includes(folder))) return 'vault-listed'
|
|
39
|
+
if (listed.some((folder) => root.some((candidate) => inside(folder, candidate)))) return 'vault-inside-listed-vault'
|
|
40
|
+
if (listed.some((folder) => root.some((candidate) => inside(candidate, folder)))) return 'listed-vault-inside-vault'
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const noEvidence = (reason, extra = {}) => ({ unlisted: false, reason, ...extra })
|
|
45
|
+
|
|
46
|
+
// `read` answers readObsidianSettings for the app's user-data directory; `sandboxed` answers whether a Flatpak or snap
|
|
47
|
+
// build is present. Either one that throws gives no evidence.
|
|
48
|
+
export async function readUnheldEvidence({ vaultRoot, read, sandboxed, attempts = 3, delayMs = 50, sleep = delay } = {}) {
|
|
49
|
+
if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return noEvidence('vault-root-unknown')
|
|
50
|
+
if (typeof read !== 'function' || typeof sandboxed !== 'function') return noEvidence('obsidian-settings-location-unknown')
|
|
51
|
+
let present = true
|
|
52
|
+
try { present = sandboxed() !== false } catch { present = true }
|
|
53
|
+
if (present) return noEvidence('obsidian-sandboxed')
|
|
54
|
+
let answer = null
|
|
55
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
56
|
+
try { answer = read() } catch { answer = { ok: false, code: 'obsidian-settings-unreadable' } }
|
|
57
|
+
if (answer?.ok === true || answer?.code !== 'obsidian-settings-unreadable') break
|
|
58
|
+
if (attempt < attempts) await sleep(delayMs)
|
|
59
|
+
}
|
|
60
|
+
if (answer?.ok !== true) return noEvidence(typeof answer?.code === 'string' ? answer.code : 'obsidian-settings-unreadable')
|
|
61
|
+
const vaults = answer.vaults ?? {}
|
|
62
|
+
if (!isPlainObject(vaults)) return noEvidence('obsidian-settings-not-object')
|
|
63
|
+
const root = spellings(vaultRoot)
|
|
64
|
+
for (const [id, entry] of Object.entries(vaults)) {
|
|
65
|
+
if (!isPlainObject(entry) || typeof entry.path !== 'string' || !path.isAbsolute(entry.path) || entry.path.split(/[\\/]/).includes('..')) return noEvidence('obsidian-settings-entry-unreadable', { id })
|
|
66
|
+
const relation = relationOf(entry.path, root)
|
|
67
|
+
if (relation !== null) return noEvidence(relation, { id, path: entry.path })
|
|
68
|
+
}
|
|
69
|
+
return { unlisted: true }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The reader the maintenance service gives its engine: the settings file of the app for this account (HOME, and on
|
|
73
|
+
// Linux XDG_CONFIG_HOME), and any Flatpak or snap build, installed or having left its list, whichever wrote last.
|
|
74
|
+
// `exists` answers whether a path exists: the build's system-wide places (/var/lib/flatpak, /snap) are looked up through
|
|
75
|
+
// it too.
|
|
76
|
+
export function createProductionUnheldEvidence({ platform = process.platform, env = process.env, exists = fs.existsSync } = {}) {
|
|
77
|
+
const userDataDir = obsidianUserDataDir({ platform, env })
|
|
78
|
+
return ({ vaultRoot }) => readUnheldEvidence({
|
|
79
|
+
vaultRoot,
|
|
80
|
+
read: () => (userDataDir === null ? { ok: false, code: 'obsidian-settings-location-unknown' } : readObsidianSettings({ userDataDir })),
|
|
81
|
+
// Present at all, not only when its list was written last: with no list time given, any installation or list counts.
|
|
82
|
+
sandboxed: () => obsidianSandboxedBuild({ platform, env, exists, modified: () => null }) !== null,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { checkManagedRoots, realLocation } from '../../project/file-class.mjs'
|
|
4
|
+
import { realPathAsStored } from '../../project/private-state.mjs'
|
|
5
|
+
import { VAULT_ALLOCATION_SCHEMA, VAULT_LOCK_DIRECTORY, allocatedFolderState, folderIdentity, hasCommittedGeneration, readVaultAllocation, writeVaultAllocation } from '../../projection/obsidian/recovery/store.mjs'
|
|
6
|
+
import { refuse } from './errors.mjs'
|
|
7
|
+
|
|
8
|
+
// Where the vaults of a workspace live when a person decided it (the
|
|
9
|
+
// `location` decision of its machine settings), and the folder each view
|
|
10
|
+
// gets there.
|
|
11
|
+
//
|
|
12
|
+
// <location>/<project> (<view>) e.g. ~/Atelier/harbor-notes (everything)
|
|
13
|
+
//
|
|
14
|
+
// The folder's name is the vault's name in Obsidian (its vault switcher, its
|
|
15
|
+
// window title, a `vault=` call), so it carries the project and the view:
|
|
16
|
+
// readable, the same in the file manager and in the app, and unique across
|
|
17
|
+
// projects and views. The view is the text in the last parentheses, up to
|
|
18
|
+
// its first space (a number follows it when the name was taken; view ids
|
|
19
|
+
// never contain a space), and no
|
|
20
|
+
// name of this form is the beginning of another one's path, which matters
|
|
21
|
+
// because the app matches an `obsidian://open?path=` by the longest string
|
|
22
|
+
// prefix among the folders it lists.
|
|
23
|
+
//
|
|
24
|
+
// A folder is allocated once per view, at its first publication, by the
|
|
25
|
+
// maintenance engine under its lock, and recorded (see
|
|
26
|
+
// src/projection/obsidian/recovery/store.mjs). It is never recomputed: a
|
|
27
|
+
// renamed project or a new location leaves an allocated vault where it is.
|
|
28
|
+
// A view published before, under the data root, stays there; a workspace
|
|
29
|
+
// that decided no location publishes there as before.
|
|
30
|
+
|
|
31
|
+
export const MAX_PROJECT_NAME_BYTES = 80
|
|
32
|
+
|
|
33
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029/\\:*?"<>|#^[\]()]/g
|
|
34
|
+
// Bidirectional controls and marks, and characters that show nothing: a name reads as it is.
|
|
35
|
+
const INVISIBLE = /[\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g
|
|
36
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i
|
|
37
|
+
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
38
|
+
|
|
39
|
+
// The longest start of `text` of at most `maxBytes` bytes that ends between two characters as a person reads them: a
|
|
40
|
+
// letter and its accents, or an emoji sequence, stay together or go together.
|
|
41
|
+
function cutToBytes(text, maxBytes) {
|
|
42
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
|
|
43
|
+
let cut = ''
|
|
44
|
+
for (const { segment } of GRAPHEMES.segment(text)) {
|
|
45
|
+
if (Buffer.byteLength(cut + segment, 'utf8') > maxBytes) break
|
|
46
|
+
cut += segment
|
|
47
|
+
}
|
|
48
|
+
return cut
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A piece of text as a folder name on macOS, Linux and Windows, and in the app: NFC, no control, separator, reserved
|
|
52
|
+
// or link character, no parentheses (they end the view's part), single spaces, no leading or trailing space or dot
|
|
53
|
+
// (a leading dot hides a folder from the app), cut to a number of bytes between two characters as a person reads them,
|
|
54
|
+
// and no Windows device name. May be empty.
|
|
55
|
+
export function safeFolderPart(text, maxBytes = MAX_PROJECT_NAME_BYTES) {
|
|
56
|
+
const cleaned = String(text ?? '').normalize('NFC').replace(INVISIBLE, '').replace(UNSAFE, ' ').replace(/\s+/gu, ' ').replace(/^[\s.]+|[\s.]+$/gu, '')
|
|
57
|
+
const cut = cutToBytes(cleaned, maxBytes).replace(/[\s.]+$/u, '')
|
|
58
|
+
return WINDOWS_DEVICE.test(cut.split('.')[0]) ? `_${cut}` : cut
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The name people and Obsidian read for a project: its configured `name`, else the name of its folder.
|
|
62
|
+
export function projectDisplayName(project) {
|
|
63
|
+
return safeFolderPart(project?.config?.name) || safeFolderPart(path.basename(String(project?.configDir ?? ''))) || 'project'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `<project> (<view>)`, or `<project> (<view> <number>)` from the second.
|
|
67
|
+
export function vaultFolderName({ projectName, scopeId, number = 1 }) {
|
|
68
|
+
const view = String(scopeId).replaceAll(':', '_')
|
|
69
|
+
return `${projectName} (${view}${number > 1 ? ` ${number}` : ''})`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isInside = (parent, child) => { const relative = path.relative(parent, child); return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) }
|
|
73
|
+
const isSameOrInside = (parent, child) => parent === child || isInside(parent, child)
|
|
74
|
+
|
|
75
|
+
// The nearest folder that exists on the way up from `target` (itself included).
|
|
76
|
+
function nearestExisting(target) {
|
|
77
|
+
let current = path.resolve(target)
|
|
78
|
+
for (;;) {
|
|
79
|
+
if (fs.existsSync(current)) return current
|
|
80
|
+
const parent = path.dirname(current)
|
|
81
|
+
if (parent === current) return current
|
|
82
|
+
current = parent
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A path compared with another as a volume that folds letter case would: NFC, lower case. Used where a false match
|
|
87
|
+
// only refuses (an enclosing vault, a synced or protected folder, a listed name), so a case-sensitive volume is at worst
|
|
88
|
+
// refused a folder whose name differs from a listed vault's in letter case only.
|
|
89
|
+
const folded = (target) => target.normalize('NFC').toLowerCase()
|
|
90
|
+
const foldedInside = (parent, child) => isSameOrInside(folded(parent), folded(child))
|
|
91
|
+
// The real path of a folder that may not exist yet, as the file system stores it (realPathAsStored): the nearest
|
|
92
|
+
// existing folder on the way up resolved, links and letter case included, and the missing tail appended.
|
|
93
|
+
export const realPathOfLocation = (target) => realLocation(target, realPathAsStored)
|
|
94
|
+
// The same, for a folder read from somewhere else (the app's list, the home folder): its lexical path when it cannot be
|
|
95
|
+
// resolved, since it may be on a volume that is gone.
|
|
96
|
+
const realOrLexical = (target) => { try { return realPathOfLocation(target) } catch { return path.resolve(target) } }
|
|
97
|
+
// A path as written and as its real path, both.
|
|
98
|
+
const spellings = (target) => [...new Set([path.resolve(target), realOrLexical(target)])]
|
|
99
|
+
|
|
100
|
+
// A folder a sync client of this account keeps in step with other machines, by name; null for any other folder.
|
|
101
|
+
// `exists` answers whether a path exists. iCloud also syncs Desktop and Documents when "Desktop & Documents Folders"
|
|
102
|
+
// is on, which shows as their folders inside iCloud Drive.
|
|
103
|
+
export function syncedFolderOf(target, { homedir, exists = fs.existsSync } = {}) {
|
|
104
|
+
if (typeof homedir !== 'string' || !path.isAbsolute(homedir)) return null
|
|
105
|
+
const resolved = path.resolve(target)
|
|
106
|
+
const within = (...parts) => foldedInside(path.join(homedir, ...parts), resolved)
|
|
107
|
+
if (within('Library', 'Mobile Documents')) return 'iCloud Drive'
|
|
108
|
+
if (within('Library', 'CloudStorage')) return 'a cloud storage provider'
|
|
109
|
+
for (const [folder, name] of [['Dropbox', 'Dropbox'], ['OneDrive', 'OneDrive'], ['Google Drive', 'Google Drive'], ['Box', 'Box']]) if (within(folder)) return name
|
|
110
|
+
for (const folder of ['Desktop', 'Documents']) {
|
|
111
|
+
if (!within(folder)) continue
|
|
112
|
+
try { if (exists(path.join(homedir, 'Library', 'Mobile Documents', 'com~apple~CloudDocs', folder))) return `iCloud (${folder})` } catch { /* not known */ }
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A folder for which macOS asks a person before an app, or a background service, may read it; null otherwise.
|
|
118
|
+
export function protectedFolderOf(target, { homedir, platform = process.platform } = {}) {
|
|
119
|
+
if (platform !== 'darwin' || typeof homedir !== 'string' || !path.isAbsolute(homedir)) return null
|
|
120
|
+
const resolved = path.resolve(target)
|
|
121
|
+
return ['Desktop', 'Documents', 'Downloads'].find((folder) => foldedInside(path.join(homedir, folder), resolved)) ?? null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The folder of a vault Atelier publishes into that contains `target`, or null: the vault lock of a published vault,
|
|
125
|
+
// or a folder another view of this workspace was allocated.
|
|
126
|
+
function enclosingAtelierVault(target, allocatedPaths = []) {
|
|
127
|
+
const candidates = spellings(target)
|
|
128
|
+
for (const allocated of allocatedPaths) if (spellings(allocated).some((folder) => candidates.some((candidate) => foldedInside(folder, candidate)))) return allocated
|
|
129
|
+
for (const resolved of candidates) {
|
|
130
|
+
let current = resolved
|
|
131
|
+
for (;;) {
|
|
132
|
+
try { if (fs.statSync(path.join(current, VAULT_LOCK_DIRECTORY)).isDirectory()) return current } catch { /* not a published vault */ }
|
|
133
|
+
const parent = path.dirname(current)
|
|
134
|
+
if (parent === current) break
|
|
135
|
+
current = parent
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The folder of a vault the app lists that contains `target` or is it; null when there is none or no list is known.
|
|
142
|
+
// `vaults` is the app's own map, id -> { path }. Both sides are compared as written and as their real paths, in any
|
|
143
|
+
// letter case, so neither a link nor another spelling of a listed folder hides it.
|
|
144
|
+
function enclosingListedVault(target, vaults) {
|
|
145
|
+
if (vaults === null || typeof vaults !== 'object') return null
|
|
146
|
+
const candidates = spellings(target)
|
|
147
|
+
for (const entry of Object.values(vaults)) {
|
|
148
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.path !== 'string' || !path.isAbsolute(entry.path)) continue
|
|
149
|
+
if (spellings(entry.path).some((folder) => candidates.some((candidate) => foldedInside(folder, candidate)))) return entry.path
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A folder a sync client keeps in step, or one macOS protects, for any spelling of the target and the home folder.
|
|
155
|
+
const anySpelling = (check, target, homedir) => {
|
|
156
|
+
if (typeof homedir !== 'string' || !path.isAbsolute(homedir)) return null
|
|
157
|
+
for (const home of spellings(homedir)) for (const candidate of spellings(target)) { const found = check(candidate, home); if (found !== null) return found }
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Whether `parent` may hold this workspace's vaults, checked before it is decided and again before a folder is
|
|
162
|
+
// allocated there. Refuses, typed, a folder inside an enrolled repository or the project, inside a vault Atelier
|
|
163
|
+
// publishes, inside a vault the app lists (when `vaults` is known), or on another volume than the workspace's private
|
|
164
|
+
// state (the atomic exchange that publishes a note cannot cross volumes); and a folder a sync client keeps in step,
|
|
165
|
+
// unless `allowSynced`. Answers the warnings a person should hear: { synced, protected }. `deviceOf` answers the
|
|
166
|
+
// device a path is on.
|
|
167
|
+
export function checkVaultParent({ parent, workspaceRoot, repositoryRoots, vaults = null, allocatedPaths = [], allowSynced = false, homedir, platform = process.platform, deviceOf = (target) => fs.statSync(target).dev }) {
|
|
168
|
+
if (typeof parent !== 'string' || !path.isAbsolute(parent) || path.resolve(parent) !== parent) refuse('vault-location-not-absolute', 'where vaults live must be an absolute folder, written plainly')
|
|
169
|
+
// A repository beside the vaults, below the same folder (a home folder, say), is no overlap: each vault folder is
|
|
170
|
+
// checked again against every repository when its store is made.
|
|
171
|
+
const overlaps = checkManagedRoots({ managedRoots: [parent], repositoryRoots }).refusals.filter(({ code }) => code !== 'repository-inside-managed-root')
|
|
172
|
+
// A folder that is itself a link: the folder it leads to is named instead, so the decision says where vaults are.
|
|
173
|
+
if (overlaps.some(({ code }) => code === 'managed-root-symlink-alias')) refuse('vault-location-is-link', 'this folder is a link; name the folder it leads to', { refusals: overlaps.map(({ code }) => code) })
|
|
174
|
+
if (overlaps.length > 0) refuse('vault-location-inside-repository', 'vaults never live inside an enrolled repository or the project', { refusals: overlaps.map(({ code }) => code) })
|
|
175
|
+
// Atelier's own private state: publication state, recovery and staging live there, and a vault inside it would be
|
|
176
|
+
// published over them.
|
|
177
|
+
if (typeof workspaceRoot === 'string' && spellings(workspaceRoot).some((root) => spellings(parent).some((candidate) => foldedInside(root, candidate)))) {
|
|
178
|
+
refuse('vault-location-inside-private-state', 'vaults never live inside Atelier\'s private state for this workspace')
|
|
179
|
+
}
|
|
180
|
+
// A folder a vault can be made in: the nearest folder that exists on the way is a folder this user can write in.
|
|
181
|
+
const nearest = nearestExisting(parent)
|
|
182
|
+
try {
|
|
183
|
+
if (!fs.statSync(nearest).isDirectory()) refuse('vault-location-unusable', 'no folder can be made there: a file is in the way', { path: nearest })
|
|
184
|
+
fs.accessSync(nearest, fs.constants.W_OK | fs.constants.X_OK)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (typeof error?.code === 'string' && !/^E[A-Z]+$/.test(error.code)) throw error
|
|
187
|
+
refuse('vault-location-unusable', 'no folder can be made there: this user cannot write in the nearest folder that exists', { path: nearest, cause: error.code ?? null })
|
|
188
|
+
}
|
|
189
|
+
const inAtelierVault = enclosingAtelierVault(parent, allocatedPaths)
|
|
190
|
+
if (inAtelierVault !== null) refuse('vault-location-inside-vault', 'vaults never live inside a vault Atelier publishes', { vault: inAtelierVault })
|
|
191
|
+
const inListedVault = enclosingListedVault(parent, vaults)
|
|
192
|
+
if (inListedVault !== null) refuse('vault-location-inside-vault', 'vaults never live inside a folder Obsidian lists as a vault: that vault would show their notes too, and a call run in their folders would reach it', { vault: inListedVault })
|
|
193
|
+
if (typeof workspaceRoot === 'string' && deviceOf(nearestExisting(parent)) !== deviceOf(nearestExisting(workspaceRoot))) {
|
|
194
|
+
refuse('vault-location-other-volume', 'vaults live on the volume of Atelier\'s private data, because a note is published by an exchange that cannot cross volumes; name another data root (--data-root) on that volume to use it')
|
|
195
|
+
}
|
|
196
|
+
const synced = anySpelling((candidate, home) => syncedFolderOf(candidate, { homedir: home }), parent, homedir)
|
|
197
|
+
if (synced !== null && !allowSynced) {
|
|
198
|
+
refuse('vault-location-synced', `this folder is kept in step by ${synced}: another machine's Obsidian could hold a vault there unseen, and a sync conflict is not a publication; pass --allow-synced-location to use it anyway`, { synced })
|
|
199
|
+
}
|
|
200
|
+
return { synced, protected: anySpelling((candidate, home) => protectedFolderOf(candidate, { homedir: home, platform }), parent, homedir) }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The folder that holds the vaults, made when it is missing: private to this user, as each vault in it is. A folder on
|
|
204
|
+
// the way to it that is missing too is made as any other folder is, so naming ~/Projects/Atelier makes ~/Projects an
|
|
205
|
+
// ordinary folder.
|
|
206
|
+
function makeHoldingFolder(parent) {
|
|
207
|
+
fs.mkdirSync(path.dirname(parent), { recursive: true })
|
|
208
|
+
try { fs.mkdirSync(parent, { mode: 0o700 }) } catch (error) { if (error?.code !== 'EEXIST') throw error }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The view's allocation, allocated now when this workspace decided where its vaults live and the view has none yet
|
|
212
|
+
// and was never published under the data root; null when the view's vault stays under the data root. The folder is
|
|
213
|
+
// made here, exclusively and private to this user, under the first name that is free: taken by nothing on the disk
|
|
214
|
+
// and, when the app's list is known, by no vault it lists with that name in any letter case.
|
|
215
|
+
// `storedPathOf` answers a folder's real path as the file system stores it (realPathAsStored).
|
|
216
|
+
export function ensureVaultAllocation({ workspaceRoot, workspaceId, scopeId, location, projectName, repositoryRoots, vaults = null, allocatedPaths = [], homedir, now, storedPathOf = realPathAsStored }) {
|
|
217
|
+
const existing = readVaultAllocation({ workspaceRoot, workspaceId, scopeId })
|
|
218
|
+
// An allocated folder that has gone is made again where it was, private, and recorded as the folder it now is; one
|
|
219
|
+
// another folder replaced is left alone, and the store refuses to publish into it. `vaults` is the app's list here
|
|
220
|
+
// too: a folder is made again only where one would be allocated.
|
|
221
|
+
if (existing !== null && allocatedFolderState(existing) === 'missing') {
|
|
222
|
+
// Only where the store would publish: a record that names a folder inside a repository, the project or the
|
|
223
|
+
// private state (edited, or copied from elsewhere) makes nothing there.
|
|
224
|
+
const guard = checkManagedRoots({ managedRoots: [workspaceRoot, existing.path], repositoryRoots })
|
|
225
|
+
if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals.map(({ code }) => code) })
|
|
226
|
+
if (spellings(workspaceRoot).some((root) => spellings(existing.path).some((candidate) => foldedInside(root, candidate)))) {
|
|
227
|
+
refuse('vault-location-inside-private-state', 'vaults never live inside Atelier\'s private state for this workspace')
|
|
228
|
+
}
|
|
229
|
+
// Only where it was: the folder that held it leads where it did, through no link put on the way since (into a
|
|
230
|
+
// vault the app lists, a synced folder), and passes every check a location is decided with.
|
|
231
|
+
const moved = (leadsTo) => refuse('vault-allocation-moved', 'the folder that held this view\'s vault now leads somewhere else, through a link; its vault is not made again there', { parent: existing.parent, leadsTo })
|
|
232
|
+
let realParent = null
|
|
233
|
+
try { realParent = realPathOfLocation(existing.parent) } catch { /* not reachable */ }
|
|
234
|
+
if (realParent !== existing.parent) moved(realParent)
|
|
235
|
+
checkVaultParent({ parent: existing.parent, workspaceRoot, repositoryRoots, vaults, allocatedPaths: allocatedPaths.filter((allocated) => allocated !== existing.path), allowSynced: true, homedir })
|
|
236
|
+
try { makeHoldingFolder(existing.parent); fs.mkdirSync(existing.path, { mode: 0o700 }) } catch (error) {
|
|
237
|
+
if (error?.code === 'EEXIST') return existing
|
|
238
|
+
refuse('vault-location-unusable', 'the folder for this view\'s vault cannot be made again there', { parent: existing.parent, cause: error?.code ?? null })
|
|
239
|
+
}
|
|
240
|
+
// Made where the record says and nowhere else: a link put on the way between the check and the folder is found here.
|
|
241
|
+
let madeAt = null
|
|
242
|
+
try { madeAt = realPathAsStored(existing.path) } catch { /* not reachable */ }
|
|
243
|
+
if (madeAt !== existing.path) {
|
|
244
|
+
try { fs.rmdirSync(existing.path) } catch { /* not ours to remove */ }
|
|
245
|
+
moved(madeAt)
|
|
246
|
+
}
|
|
247
|
+
const made = folderIdentity(existing.path)
|
|
248
|
+
return writeVaultAllocation({ workspaceRoot, allocation: { ...existing, device: made.device, inode: made.inode } })
|
|
249
|
+
}
|
|
250
|
+
if (existing !== null) return existing
|
|
251
|
+
if (location === null || location === undefined || hasCommittedGeneration({ workspaceRoot, scopeId })) return null
|
|
252
|
+
const parent = location.parent
|
|
253
|
+
// A synced location was allowed when it was decided; it is checked again for everything else.
|
|
254
|
+
checkVaultParent({ parent, workspaceRoot, repositoryRoots, vaults, allocatedPaths, allowSynced: true, homedir })
|
|
255
|
+
const unusable = (error) => refuse('vault-location-unusable', 'the folder for this view\'s vault cannot be made there', { parent, cause: error?.code ?? String(error?.message ?? error) })
|
|
256
|
+
try { makeHoldingFolder(parent) } catch (error) { unusable(error) }
|
|
257
|
+
// The folder is recorded by its real path, as the file system stores it: a link on the way to the location decided
|
|
258
|
+
// then leads nowhere else later, and the path is the one the app and every check see.
|
|
259
|
+
const realParent = realPathAsStored(parent)
|
|
260
|
+
const listedNames = new Set(Object.values(vaults ?? {}).filter((entry) => typeof entry?.path === 'string').map((entry) => folded(path.basename(entry.path))))
|
|
261
|
+
for (let number = 1; number <= 99; number += 1) {
|
|
262
|
+
const name = vaultFolderName({ projectName, scopeId, number })
|
|
263
|
+
const folder = path.join(realParent, name)
|
|
264
|
+
if (listedNames.has(folded(name)) || fs.lstatSync(folder, { throwIfNoEntry: false }) !== undefined) continue
|
|
265
|
+
try { fs.mkdirSync(folder, { mode: 0o700 }) } catch (error) { if (error.code === 'EEXIST') continue; unusable(error) }
|
|
266
|
+
try { fs.chmodSync(folder, 0o700) } catch { /* a file system without modes */ }
|
|
267
|
+
const made = folderIdentity(folder)
|
|
268
|
+
// Recorded as the file system stores the folder's path, which is what every later check reads back: a volume that
|
|
269
|
+
// stores names in another Unicode form (HFS+ stores NFD) would otherwise never match the name as it was asked for.
|
|
270
|
+
let stored = null
|
|
271
|
+
try { stored = storedPathOf(folder) } catch { /* not reachable: refused below */ }
|
|
272
|
+
// Only the folder just made, under the parent that was checked: its name may come back in another Unicode form, and
|
|
273
|
+
// nothing else may differ. A link put on the way since would otherwise be recorded, and then trusted.
|
|
274
|
+
if (stored === null || path.dirname(stored) !== realParent || path.basename(stored).normalize('NFC') !== name.normalize('NFC')) {
|
|
275
|
+
try { fs.rmdirSync(folder) } catch { /* not ours to remove */ }
|
|
276
|
+
refuse('vault-allocation-moved', 'the folder that holds the vaults led somewhere else once this view\'s folder was made; nothing is recorded', { parent: realParent, leadsTo: stored })
|
|
277
|
+
}
|
|
278
|
+
return writeVaultAllocation({ workspaceRoot, allocation: { schema: VAULT_ALLOCATION_SCHEMA, workspaceId, scopeId, path: stored, name: path.basename(stored), parent: path.dirname(stored), device: made.device, inode: made.inode, allocatedAt: now } })
|
|
279
|
+
}
|
|
280
|
+
return refuse('vault-location-full', 'no free vault name is left for this view in that folder', { parent })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The app's vault list for an allocation, from its settings file and never from the app: { ok: true, vaults } when it
|
|
284
|
+
// was read, or when the app never ran here (no file, and no Flatpak or snap build whose list lives elsewhere), and
|
|
285
|
+
// { ok: false, code } otherwise, so a folder is never allocated while a listed vault could enclose it or have its
|
|
286
|
+
// name. `read` answers readObsidianSettings; a file the app is writing can read as unreadable for a moment, so an
|
|
287
|
+
// unreadable file is read again, up to `attempts` times `delayMs` apart. `sandboxed` is true for such a build.
|
|
288
|
+
export async function readAppVaultListForAllocation({ read, sandboxed = false, attempts = 3, delayMs = 50, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
289
|
+
if (sandboxed) return { ok: false, code: 'obsidian-sandboxed' }
|
|
290
|
+
let answer = null
|
|
291
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
292
|
+
try { answer = read() } catch { answer = { ok: false, code: 'obsidian-settings-unreadable' } }
|
|
293
|
+
if (answer?.ok === true) return { ok: true, vaults: answer.vaults ?? {} }
|
|
294
|
+
if (answer?.code === 'obsidian-settings-missing') return { ok: true, vaults: {} }
|
|
295
|
+
if (answer?.code !== 'obsidian-settings-unreadable') break
|
|
296
|
+
if (attempt < attempts) await sleep(delayMs)
|
|
297
|
+
}
|
|
298
|
+
return { ok: false, code: typeof answer?.code === 'string' ? answer.code : 'obsidian-settings-unreadable' }
|
|
299
|
+
}
|
|
300
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { selectScope } from '../../projection/obsidian/contracts.mjs'
|
|
2
|
+
import { DEFAULT_ELIGIBILITY, buildGraph, profileFor } from './pipeline.mjs'
|
|
3
|
+
|
|
4
|
+
// How many notes a view would show on this machine, and why the other notes
|
|
5
|
+
// its selector names are withheld: the numbers a person sees before a view is
|
|
6
|
+
// declared or first opened. Read-only. The canonical graph is built as the
|
|
7
|
+
// engine builds it, with the eligibility the caller names (the one the machine
|
|
8
|
+
// settings decide, `eligibilityFor`), and the selection is the contract's own
|
|
9
|
+
// selectScope, so the count is the view's.
|
|
10
|
+
|
|
11
|
+
// { corpus, named, shown, withheld: { unclassified, audience }, truncated }: the notes of the project; those the
|
|
12
|
+
// view's selector names, whatever their classification and audience; those the view shows under `audienceAllow`
|
|
13
|
+
// and `eligibility` (with its expansion); and, of the named ones, how many are withheld because they are not
|
|
14
|
+
// eligible (they carry no classification, or, in a vault that is only yours, cannot be read as notes), or carry an
|
|
15
|
+
// audience that is not admitted.
|
|
16
|
+
export function viewCounts({ project, audienceAllow, scope, eligibility = DEFAULT_ELIGIBILITY, workspaceId = 'ws-unprepared' }) {
|
|
17
|
+
const graph = buildGraph({ project, eligibility })
|
|
18
|
+
const known = new Set(graph.nodes.map((node) => node.id))
|
|
19
|
+
const edges = graph.edges.filter((edge) => known.has(edge.source) && known.has(edge.target))
|
|
20
|
+
const profile = profileFor({ project, workspaceId, audienceAllow })
|
|
21
|
+
const selection = selectScope({ canonicalSnapshot: { nodes: graph.nodes, edges }, profile, selector: scope.selector, expansion: scope.expansion, mode: scope.mode })
|
|
22
|
+
// The same selector over every note made visible names what it would show if nothing were withheld.
|
|
23
|
+
const everyNote = graph.nodes.map((node) => ({ ...node, eligible: true, audience: undefined }))
|
|
24
|
+
const named = selectScope({ canonicalSnapshot: { nodes: everyNote, edges: [] }, profile, selector: scope.selector, mode: scope.mode === 'focus' ? 'scoped' : scope.mode }).nodes
|
|
25
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]))
|
|
26
|
+
const allowed = new Set(audienceAllow)
|
|
27
|
+
const withheld = { unclassified: 0, audience: 0 }
|
|
28
|
+
for (const id of named) {
|
|
29
|
+
const node = byId.get(id)
|
|
30
|
+
if (node.eligible !== true) withheld.unclassified += 1
|
|
31
|
+
else if (node.audience !== undefined && !allowed.has(node.audience)) withheld.audience += 1
|
|
32
|
+
}
|
|
33
|
+
return { corpus: graph.nodes.length, named: named.length, shown: selection.nodes.length, withheld, truncated: selection.truncated }
|
|
34
|
+
}
|