daftari 1.29.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +281 -0
- package/README.md +263 -9
- package/dist/access/rbac.d.ts +1 -0
- package/dist/access/rbac.d.ts.map +1 -1
- package/dist/access/rbac.js +7 -0
- package/dist/access/rbac.js.map +1 -1
- package/dist/asof/git-read.d.ts +17 -0
- package/dist/asof/git-read.d.ts.map +1 -0
- package/dist/asof/git-read.js +163 -0
- package/dist/asof/git-read.js.map +1 -0
- package/dist/asof/index.d.ts +2 -0
- package/dist/asof/index.d.ts.map +1 -0
- package/dist/asof/index.js +151 -0
- package/dist/asof/index.js.map +1 -0
- package/dist/asof/report.d.ts +11 -0
- package/dist/asof/report.d.ts.map +1 -0
- package/dist/asof/report.js +119 -0
- package/dist/asof/report.js.map +1 -0
- package/dist/asof/snapshot.d.ts +72 -0
- package/dist/asof/snapshot.d.ts.map +1 -0
- package/dist/asof/snapshot.js +207 -0
- package/dist/asof/snapshot.js.map +1 -0
- package/dist/audit/checks/broken_refs.d.ts +1 -1
- package/dist/audit/checks/broken_refs.d.ts.map +1 -1
- package/dist/audit/checks/broken_refs.js +77 -22
- package/dist/audit/checks/broken_refs.js.map +1 -1
- package/dist/audit/collect.d.ts +1 -0
- package/dist/audit/collect.d.ts.map +1 -1
- package/dist/audit/collect.js +22 -2
- package/dist/audit/collect.js.map +1 -1
- package/dist/audit/index.d.ts.map +1 -1
- package/dist/audit/index.js +9 -3
- package/dist/audit/index.js.map +1 -1
- package/dist/audit/links.d.ts.map +1 -1
- package/dist/audit/links.js +9 -3
- package/dist/audit/links.js.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/report.js +2 -0
- package/dist/audit/report.js.map +1 -1
- package/dist/audit/types.d.ts +4 -1
- package/dist/audit/types.d.ts.map +1 -1
- package/dist/audit/types.js.map +1 -1
- package/dist/backfill/apply.js +2 -2
- package/dist/backfill/apply.js.map +1 -1
- package/dist/backfill/derive.d.ts.map +1 -1
- package/dist/backfill/derive.js +1 -0
- package/dist/backfill/derive.js.map +1 -1
- package/dist/backfill/plan.js +2 -2
- package/dist/backfill/plan.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -1
- package/dist/consolidate/call-budget.d.ts +3 -0
- package/dist/consolidate/call-budget.d.ts.map +1 -0
- package/dist/consolidate/call-budget.js +37 -0
- package/dist/consolidate/call-budget.js.map +1 -0
- package/dist/consolidate/constants.d.ts +1 -0
- package/dist/consolidate/constants.d.ts.map +1 -1
- package/dist/consolidate/constants.js +5 -0
- package/dist/consolidate/constants.js.map +1 -1
- package/dist/consolidate/index.d.ts.map +1 -1
- package/dist/consolidate/index.js +96 -27
- package/dist/consolidate/index.js.map +1 -1
- package/dist/court/docket.d.ts +39 -0
- package/dist/court/docket.d.ts.map +1 -0
- package/dist/court/docket.js +103 -0
- package/dist/court/docket.js.map +1 -0
- package/dist/court/index.d.ts +2 -0
- package/dist/court/index.d.ts.map +1 -0
- package/dist/court/index.js +169 -0
- package/dist/court/index.js.map +1 -0
- package/dist/court/precedent.d.ts +18 -0
- package/dist/court/precedent.d.ts.map +1 -0
- package/dist/court/precedent.js +89 -0
- package/dist/court/precedent.js.map +1 -0
- package/dist/court/report.d.ts +10 -0
- package/dist/court/report.d.ts.map +1 -0
- package/dist/court/report.js +77 -0
- package/dist/court/report.js.map +1 -0
- package/dist/curation/consumes.d.ts +24 -0
- package/dist/curation/consumes.d.ts.map +1 -0
- package/dist/curation/consumes.js +122 -0
- package/dist/curation/consumes.js.map +1 -0
- package/dist/curation/edge-staleness.d.ts +47 -0
- package/dist/curation/edge-staleness.d.ts.map +1 -0
- package/dist/curation/edge-staleness.js +258 -0
- package/dist/curation/edge-staleness.js.map +1 -0
- package/dist/curation/edges.d.ts.map +1 -1
- package/dist/curation/edges.js +164 -10
- package/dist/curation/edges.js.map +1 -1
- package/dist/curation/lint.d.ts +4 -1
- package/dist/curation/lint.d.ts.map +1 -1
- package/dist/curation/lint.js +61 -6
- package/dist/curation/lint.js.map +1 -1
- package/dist/curation/provenance.d.ts +2 -0
- package/dist/curation/provenance.d.ts.map +1 -1
- package/dist/curation/provenance.js +2 -0
- package/dist/curation/provenance.js.map +1 -1
- package/dist/curation/read-log.d.ts +19 -0
- package/dist/curation/read-log.d.ts.map +1 -0
- package/dist/curation/read-log.js +120 -0
- package/dist/curation/read-log.js.map +1 -0
- package/dist/curation/review-throughput.d.ts +24 -0
- package/dist/curation/review-throughput.d.ts.map +1 -0
- package/dist/curation/review-throughput.js +102 -0
- package/dist/curation/review-throughput.js.map +1 -0
- package/dist/curation/staged-actions.d.ts +11 -1
- package/dist/curation/staged-actions.d.ts.map +1 -1
- package/dist/curation/staged-actions.js +117 -30
- package/dist/curation/staged-actions.js.map +1 -1
- package/dist/curation/staleness.js +1 -1
- package/dist/curation/staleness.js.map +1 -1
- package/dist/curation/structural.d.ts +16 -0
- package/dist/curation/structural.d.ts.map +1 -0
- package/dist/curation/structural.js +64 -0
- package/dist/curation/structural.js.map +1 -0
- package/dist/curation/tension-access.d.ts +7 -0
- package/dist/curation/tension-access.d.ts.map +1 -0
- package/dist/curation/tension-access.js +44 -0
- package/dist/curation/tension-access.js.map +1 -0
- package/dist/curation/tension-blast.d.ts +6 -1
- package/dist/curation/tension-blast.d.ts.map +1 -1
- package/dist/curation/tension-blast.js +11 -2
- package/dist/curation/tension-blast.js.map +1 -1
- package/dist/curation/tension-clusters.d.ts +1 -1
- package/dist/curation/tension-clusters.d.ts.map +1 -1
- package/dist/curation/tension-clusters.js +5 -2
- package/dist/curation/tension-clusters.js.map +1 -1
- package/dist/curation/tension.d.ts +6 -2
- package/dist/curation/tension.d.ts.map +1 -1
- package/dist/curation/tension.js +86 -35
- package/dist/curation/tension.js.map +1 -1
- package/dist/curation/tier0.d.ts +27 -0
- package/dist/curation/tier0.d.ts.map +1 -0
- package/dist/curation/tier0.js +196 -0
- package/dist/curation/tier0.js.map +1 -0
- package/dist/curation/tier1.d.ts +30 -0
- package/dist/curation/tier1.d.ts.map +1 -0
- package/dist/curation/tier1.js +142 -0
- package/dist/curation/tier1.js.map +1 -0
- package/dist/curation/tier2.d.ts +43 -0
- package/dist/curation/tier2.d.ts.map +1 -0
- package/dist/curation/tier2.js +182 -0
- package/dist/curation/tier2.js.map +1 -0
- package/dist/curation/vault-docs.d.ts +5 -1
- package/dist/curation/vault-docs.d.ts.map +1 -1
- package/dist/curation/vault-docs.js +15 -1
- package/dist/curation/vault-docs.js.map +1 -1
- package/dist/eval/generate.js +22 -5
- package/dist/eval/generate.js.map +1 -1
- package/dist/eval/index.d.ts.map +1 -1
- package/dist/eval/index.js +173 -25
- package/dist/eval/index.js.map +1 -1
- package/dist/eval/llm-openrouter.d.ts +9 -0
- package/dist/eval/llm-openrouter.d.ts.map +1 -0
- package/dist/eval/llm-openrouter.js +171 -0
- package/dist/eval/llm-openrouter.js.map +1 -0
- package/dist/eval/llm.js +1 -1
- package/dist/eval/llm.js.map +1 -1
- package/dist/eval/prune.d.ts +24 -0
- package/dist/eval/prune.d.ts.map +1 -0
- package/dist/eval/prune.js +83 -0
- package/dist/eval/prune.js.map +1 -0
- package/dist/eval/storage.d.ts +3 -0
- package/dist/eval/storage.d.ts.map +1 -1
- package/dist/eval/storage.js +14 -0
- package/dist/eval/storage.js.map +1 -1
- package/dist/frontmatter/parser.d.ts +1 -0
- package/dist/frontmatter/parser.d.ts.map +1 -1
- package/dist/frontmatter/parser.js +12 -0
- package/dist/frontmatter/parser.js.map +1 -1
- package/dist/frontmatter/schema.d.ts.map +1 -1
- package/dist/frontmatter/schema.js +38 -3
- package/dist/frontmatter/schema.js.map +1 -1
- package/dist/frontmatter/types.d.ts +4 -1
- package/dist/frontmatter/types.d.ts.map +1 -1
- package/dist/frontmatter/types.js +8 -0
- package/dist/frontmatter/types.js.map +1 -1
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +44 -9
- package/dist/import/index.js.map +1 -1
- package/dist/import/langgraph-store.d.ts +53 -0
- package/dist/import/langgraph-store.d.ts.map +1 -0
- package/dist/import/langgraph-store.js +292 -0
- package/dist/import/langgraph-store.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -18
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/lock.d.ts +9 -1
- package/dist/lifecycle/lock.d.ts.map +1 -1
- package/dist/lifecycle/lock.js +34 -9
- package/dist/lifecycle/lock.js.map +1 -1
- package/dist/okf/export.d.ts +12 -0
- package/dist/okf/export.d.ts.map +1 -0
- package/dist/okf/export.js +115 -0
- package/dist/okf/export.js.map +1 -0
- package/dist/okf/import.d.ts +24 -0
- package/dist/okf/import.d.ts.map +1 -0
- package/dist/okf/import.js +107 -0
- package/dist/okf/import.js.map +1 -0
- package/dist/okf/index.d.ts +2 -0
- package/dist/okf/index.d.ts.map +1 -0
- package/dist/okf/index.js +147 -0
- package/dist/okf/index.js.map +1 -0
- package/dist/okf/map.d.ts +16 -0
- package/dist/okf/map.d.ts.map +1 -0
- package/dist/okf/map.js +149 -0
- package/dist/okf/map.js.map +1 -0
- package/dist/okf/types.d.ts +9 -0
- package/dist/okf/types.d.ts.map +1 -0
- package/dist/okf/types.js +48 -0
- package/dist/okf/types.js.map +1 -0
- package/dist/search/contested.d.ts +18 -0
- package/dist/search/contested.d.ts.map +1 -0
- package/dist/search/contested.js +119 -0
- package/dist/search/contested.js.map +1 -0
- package/dist/search/coverage.d.ts.map +1 -1
- package/dist/search/coverage.js +1 -1
- package/dist/search/coverage.js.map +1 -1
- package/dist/search/hybrid.d.ts +23 -0
- package/dist/search/hybrid.d.ts.map +1 -1
- package/dist/search/hybrid.js +75 -15
- package/dist/search/hybrid.js.map +1 -1
- package/dist/search/reindex.d.ts +1 -0
- package/dist/search/reindex.d.ts.map +1 -1
- package/dist/search/reindex.js +57 -24
- package/dist/search/reindex.js.map +1 -1
- package/dist/search/watcher.d.ts +2 -0
- package/dist/search/watcher.d.ts.map +1 -1
- package/dist/search/watcher.js +23 -12
- package/dist/search/watcher.js.map +1 -1
- package/dist/serve/index.d.ts +36 -0
- package/dist/serve/index.d.ts.map +1 -0
- package/dist/serve/index.js +507 -0
- package/dist/serve/index.js.map +1 -0
- package/dist/server.d.ts +10 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +91 -12
- package/dist/server.js.map +1 -1
- package/dist/sleep/cycle.d.ts +59 -0
- package/dist/sleep/cycle.d.ts.map +1 -0
- package/dist/sleep/cycle.js +127 -0
- package/dist/sleep/cycle.js.map +1 -0
- package/dist/sleep/index.d.ts +5 -0
- package/dist/sleep/index.d.ts.map +1 -0
- package/dist/sleep/index.js +320 -0
- package/dist/sleep/index.js.map +1 -0
- package/dist/sleep/report.d.ts +14 -0
- package/dist/sleep/report.d.ts.map +1 -0
- package/dist/sleep/report.js +139 -0
- package/dist/sleep/report.js.map +1 -0
- package/dist/sleep/tension-scan-state.d.ts +19 -0
- package/dist/sleep/tension-scan-state.d.ts.map +1 -0
- package/dist/sleep/tension-scan-state.js +0 -0
- package/dist/sleep/tension-scan-state.js.map +1 -0
- package/dist/sleep/tension-scan.d.ts +44 -0
- package/dist/sleep/tension-scan.d.ts.map +1 -0
- package/dist/sleep/tension-scan.js +0 -0
- package/dist/sleep/tension-scan.js.map +1 -0
- package/dist/storage/backend.d.ts +12 -0
- package/dist/storage/backend.d.ts.map +1 -0
- package/dist/storage/backend.js +231 -0
- package/dist/storage/backend.js.map +1 -0
- package/dist/storage/backends/fs.d.ts +4 -0
- package/dist/storage/backends/fs.d.ts.map +1 -0
- package/dist/storage/backends/fs.js +99 -0
- package/dist/storage/backends/fs.js.map +1 -0
- package/dist/storage/fs-walk.d.ts +5 -0
- package/dist/storage/fs-walk.d.ts.map +1 -0
- package/dist/storage/fs-walk.js +28 -0
- package/dist/storage/fs-walk.js.map +1 -0
- package/dist/storage/index-db.d.ts +18 -0
- package/dist/storage/index-db.d.ts.map +1 -1
- package/dist/storage/index-db.js +159 -22
- package/dist/storage/index-db.js.map +1 -1
- package/dist/storage/local.d.ts +7 -1
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +42 -4
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/sync.d.ts +25 -0
- package/dist/storage/sync.d.ts.map +1 -0
- package/dist/storage/sync.js +308 -0
- package/dist/storage/sync.js.map +1 -0
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +136 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/themes/clustering.d.ts +6 -11
- package/dist/themes/clustering.d.ts.map +1 -1
- package/dist/themes/clustering.js +76 -84
- package/dist/themes/clustering.js.map +1 -1
- package/dist/tools/consumes.d.ts +14 -0
- package/dist/tools/consumes.d.ts.map +1 -0
- package/dist/tools/consumes.js +99 -0
- package/dist/tools/consumes.js.map +1 -0
- package/dist/tools/curation.d.ts +2 -0
- package/dist/tools/curation.d.ts.map +1 -1
- package/dist/tools/curation.js +102 -7
- package/dist/tools/curation.js.map +1 -1
- package/dist/tools/edge-staleness.d.ts +28 -0
- package/dist/tools/edge-staleness.d.ts.map +1 -0
- package/dist/tools/edge-staleness.js +205 -0
- package/dist/tools/edge-staleness.js.map +1 -0
- package/dist/tools/edges.d.ts.map +1 -1
- package/dist/tools/edges.js +28 -22
- package/dist/tools/edges.js.map +1 -1
- package/dist/tools/read.d.ts +15 -1
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +144 -31
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/receipt.d.ts +65 -0
- package/dist/tools/receipt.d.ts.map +1 -0
- package/dist/tools/receipt.js +257 -0
- package/dist/tools/receipt.js.map +1 -0
- package/dist/tools/search.d.ts +1 -0
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +186 -12
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/staged-actions.d.ts +3 -0
- package/dist/tools/staged-actions.d.ts.map +1 -1
- package/dist/tools/staged-actions.js +228 -23
- package/dist/tools/staged-actions.js.map +1 -1
- package/dist/tools/themes.d.ts +9 -0
- package/dist/tools/themes.d.ts.map +1 -1
- package/dist/tools/themes.js +333 -133
- package/dist/tools/themes.js.map +1 -1
- package/dist/tools/tier1.d.ts +14 -0
- package/dist/tools/tier1.d.ts.map +1 -0
- package/dist/tools/tier1.js +169 -0
- package/dist/tools/tier1.js.map +1 -0
- package/dist/tools/tier2.d.ts +27 -0
- package/dist/tools/tier2.d.ts.map +1 -0
- package/dist/tools/tier2.js +344 -0
- package/dist/tools/tier2.js.map +1 -0
- package/dist/tools/witness.d.ts +9 -0
- package/dist/tools/witness.d.ts.map +1 -0
- package/dist/tools/witness.js +66 -0
- package/dist/tools/witness.js.map +1 -0
- package/dist/tools/write.d.ts +9 -1
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +593 -59
- package/dist/tools/write.js.map +1 -1
- package/dist/utils/config.d.ts +54 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +420 -4
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/paths.d.ts +2 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +15 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/redos.d.ts +2 -0
- package/dist/utils/redos.d.ts.map +1 -0
- package/dist/utils/redos.js +101 -0
- package/dist/utils/redos.js.map +1 -0
- package/dist/utils/run-id.d.ts +3 -0
- package/dist/utils/run-id.d.ts.map +1 -0
- package/dist/utils/run-id.js +19 -0
- package/dist/utils/run-id.js.map +1 -0
- package/dist/utils/vault-gitignore.d.ts +1 -1
- package/dist/utils/vault-gitignore.d.ts.map +1 -1
- package/dist/utils/vault-gitignore.js +6 -1
- package/dist/utils/vault-gitignore.js.map +1 -1
- package/dist/witness/track-record.d.ts +42 -0
- package/dist/witness/track-record.d.ts.map +1 -0
- package/dist/witness/track-record.js +189 -0
- package/dist/witness/track-record.js.map +1 -0
- package/package.json +27 -2
- package/templates/config.yaml +8 -0
package/dist/tools/write.js
CHANGED
|
@@ -8,24 +8,32 @@
|
|
|
8
8
|
// Git is the version-control layer: the auto-commit *is* the document history.
|
|
9
9
|
// The provenance log is a separate advisory audit trail.
|
|
10
10
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
11
|
-
import { dirname } from "node:path";
|
|
11
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
12
12
|
import matter from "gray-matter";
|
|
13
13
|
import { acquireLock, openLockDb, releaseLock } from "../access/locks.js";
|
|
14
|
-
import { canPromote, canWrite } from "../access/rbac.js";
|
|
14
|
+
import { canPromote, canWrite, isProposeOnly } from "../access/rbac.js";
|
|
15
|
+
import { mintConsumesEdges } from "../curation/consumes.js";
|
|
15
16
|
import { frontmatterDiff, recordProvenance } from "../curation/provenance.js";
|
|
16
17
|
import { recordShadowAction } from "../curation/shadow.js";
|
|
18
|
+
import { stageActionWithConflictCheck } from "../curation/staged-actions.js";
|
|
19
|
+
import { sourceReadable } from "../curation/tension-access.js";
|
|
20
|
+
import { EXTERNAL_REF } from "../curation/tier0.js";
|
|
21
|
+
import { buildPathIndexes, extractLinks, outgoingLinkTargets, resolveLink, } from "../curation/vault-docs.js";
|
|
17
22
|
import { parseDocument } from "../frontmatter/parser.js";
|
|
18
23
|
import { validateFrontmatter } from "../frontmatter/schema.js";
|
|
19
|
-
import { CONFIDENCES, DOMAINS, err, ok, PROVENANCES, STATUSES, } from "../frontmatter/types.js";
|
|
24
|
+
import { CONFIDENCES, DOMAINS, err, ok, PROVENANCES, STATUSES, TIERS, } from "../frontmatter/types.js";
|
|
20
25
|
import { loadHooks, loadPreWriteTransformHooks } from "../hooks/loader.js";
|
|
21
26
|
import { runPreWriteHooks, runPreWriteTransformHooks } from "../hooks/runner.js";
|
|
22
27
|
import { getIndexStatus, indexingBusyMessage } from "../search/index-state.js";
|
|
23
28
|
import { indexDocument } from "../search/reindex.js";
|
|
24
29
|
import { noteSelfWrite } from "../search/self-write.js";
|
|
30
|
+
import { allDocumentPaths, getDocumentsByPaths } from "../storage/index-db.js";
|
|
25
31
|
import { readFile, resolveVaultPath } from "../storage/local.js";
|
|
26
32
|
import { loadConfig } from "../utils/config.js";
|
|
27
33
|
import { commit } from "../utils/git.js";
|
|
28
34
|
import { sha256Hex } from "../utils/hash.js";
|
|
35
|
+
import { readRunId } from "../utils/run-id.js";
|
|
36
|
+
import { openIndexForAccessOrNull } from "./search.js";
|
|
29
37
|
// ---------------------------------------------------------------------------
|
|
30
38
|
// shared helpers
|
|
31
39
|
// ---------------------------------------------------------------------------
|
|
@@ -52,6 +60,23 @@ function requireIndexReady() {
|
|
|
52
60
|
function collectionOf(relPath, fm) {
|
|
53
61
|
return fm.collection || (relPath.split("/")[0] ?? "");
|
|
54
62
|
}
|
|
63
|
+
// The collection the write gate for a *new* write must key off: the top-level
|
|
64
|
+
// directory the bytes physically land in, i.e. the resolved target path's first
|
|
65
|
+
// path segment — NEVER the caller-declared frontmatter.collection. Honoring the
|
|
66
|
+
// declared string let a role with write on collection A drop a file into
|
|
67
|
+
// collection B by lying in the frontmatter (S1, 2026-07-01 review): the gate
|
|
68
|
+
// checked A while `resolveVaultPath` wrote into B.
|
|
69
|
+
//
|
|
70
|
+
// `relPath` is lexically canonicalized against the vault root so an aliased path
|
|
71
|
+
// (`competitive-intel/../pricing/x.md`) resolves to its true top-level dir
|
|
72
|
+
// before the gate. This is pure path math — no disk I/O — so the check still
|
|
73
|
+
// runs before we touch the target, preserving "deny before revealing anything".
|
|
74
|
+
// A path that escapes the root yields a `..`-leading segment, which no role can
|
|
75
|
+
// write; `resolveVaultPath` rejects it properly downstream.
|
|
76
|
+
function targetCollection(vaultRoot, relPath) {
|
|
77
|
+
const rel = relative(resolve(vaultRoot), resolve(vaultRoot, relPath));
|
|
78
|
+
return rel.split(sep)[0] ?? "";
|
|
79
|
+
}
|
|
55
80
|
// Resolves an extension field's serializable value from the raw frontmatter:
|
|
56
81
|
// the value as written, with a js-yaml Date normalized to a YYYY-MM-DD string.
|
|
57
82
|
// A missing field — `undefined` or `null`, the latter matching how the
|
|
@@ -95,6 +120,10 @@ export function serializeDocument(fm, body, extensions = [], raw = {}) {
|
|
|
95
120
|
updated: fm.updated,
|
|
96
121
|
updated_by: fm.updated_by,
|
|
97
122
|
provenance: fm.provenance,
|
|
123
|
+
// `?? null` — a hand-built Frontmatter that predates tier may carry
|
|
124
|
+
// undefined here, which js-yaml refuses to dump (null it serializes fine,
|
|
125
|
+
// matching the superseded_by convention).
|
|
126
|
+
tier: fm.tier ?? null,
|
|
98
127
|
sources: fm.sources,
|
|
99
128
|
superseded_by: fm.superseded_by,
|
|
100
129
|
ttl_days: fm.ttl_days,
|
|
@@ -207,6 +236,7 @@ async function performWrite(params) {
|
|
|
207
236
|
file: params.relPath,
|
|
208
237
|
agent: params.agent,
|
|
209
238
|
...(params.principal ? { principal: params.principal } : {}),
|
|
239
|
+
...(params.runId ? { run_id: params.runId } : {}),
|
|
210
240
|
action: "rejected_stale",
|
|
211
241
|
reason: `stale: base_version ${shortHash(params.baseVersion)} != ` +
|
|
212
242
|
`current ${currentHash ? shortHash(currentHash) : "<absent>"}`,
|
|
@@ -237,9 +267,21 @@ async function performWrite(params) {
|
|
|
237
267
|
file: params.relPath,
|
|
238
268
|
agent: params.agent,
|
|
239
269
|
...(params.principal ? { principal: params.principal } : {}),
|
|
270
|
+
...(params.runId ? { run_id: params.runId } : {}),
|
|
271
|
+
...(params.bodyChanged !== undefined ? { body_changed: params.bodyChanged } : {}),
|
|
240
272
|
action: params.action,
|
|
241
273
|
frontmatter_diff: frontmatterDiff(params.oldFrontmatter, params.newFrontmatter),
|
|
242
274
|
});
|
|
275
|
+
// #233: a run-correlated write compiles its input set — every path the
|
|
276
|
+
// run read beforehand becomes a consumes edge. Best-effort like the
|
|
277
|
+
// provenance/index steps: the write is already durable, and the graph
|
|
278
|
+
// is advisory substrate, so a mint failure never fails the write.
|
|
279
|
+
if (params.runId) {
|
|
280
|
+
await mintConsumesEdges(params.vaultRoot, {
|
|
281
|
+
artifact: params.relPath,
|
|
282
|
+
runId: params.runId,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
243
285
|
return ok({
|
|
244
286
|
path: params.relPath,
|
|
245
287
|
action: params.action,
|
|
@@ -283,12 +325,118 @@ function readBaseVersion(args, tool) {
|
|
|
283
325
|
}
|
|
284
326
|
return ok(v.length === 0 ? undefined : v);
|
|
285
327
|
}
|
|
328
|
+
// #235: a propose-only role cannot mutate the vault directly. vault_write
|
|
329
|
+
// coerces into a staged proposal (see vaultWrite); every OTHER write tool is
|
|
330
|
+
// denied with a pointer to the staging surface. Structural enforcement — the
|
|
331
|
+
// permission layer decides, not convention.
|
|
332
|
+
function denyIfProposeOnly(access, tool) {
|
|
333
|
+
if (access && isProposeOnly(access.role)) {
|
|
334
|
+
return err(new Error(`${tool}: role '${access.roleName}' is propose-only — direct writes are ` +
|
|
335
|
+
`disabled. Propose the change instead: vault_write stages a 'write' ` +
|
|
336
|
+
`proposal, and vault_stage_action stages lifecycle actions ` +
|
|
337
|
+
`(promote, deprecate, supersede, merge, confidence-up) for ratification.`));
|
|
338
|
+
}
|
|
339
|
+
return ok(undefined);
|
|
340
|
+
}
|
|
286
341
|
// ---------------------------------------------------------------------------
|
|
287
342
|
// vault_write
|
|
288
343
|
// ---------------------------------------------------------------------------
|
|
289
344
|
// Creates a new document or overwrites an existing one. The caller supplies
|
|
290
345
|
// the full frontmatter; `updated` and `updated_by` are always stamped by the
|
|
291
346
|
// server, and `created` is preserved from the existing document on an update.
|
|
347
|
+
// --- tier write-protection (#141) ------------------------------------------
|
|
348
|
+
//
|
|
349
|
+
// `tier: source` bodies are immutable to EVERY writer; `tier: manual` bodies
|
|
350
|
+
// only accept rewrites from a human:* identity. The escape hatch is
|
|
351
|
+
// deliberately NOT an inline force flag — a refusal that names a bypass
|
|
352
|
+
// parameter teaches the calling model to pass it reflexively. Instead the
|
|
353
|
+
// caller must demote the tier first (vault_set_tier, reason required, change
|
|
354
|
+
// provenance-logged), then write. Frontmatter-only writes pass unchanged, so
|
|
355
|
+
// curation tools (promote, deprecate, supersede, set_confidence) and tag
|
|
356
|
+
// edits keep working on tiered docs.
|
|
357
|
+
// Body equality up to a leading-newline / trailing-whitespace difference:
|
|
358
|
+
// gray-matter's parsed content starts with the newline after the frontmatter
|
|
359
|
+
// fence and serializeDocument re-adds one, so a byte comparison would flag
|
|
360
|
+
// every round-tripped body as changed.
|
|
361
|
+
function sameBody(a, b) {
|
|
362
|
+
const norm = (s) => s.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
363
|
+
return norm(a) === norm(b);
|
|
364
|
+
}
|
|
365
|
+
function checkTierGuard(args) {
|
|
366
|
+
const { tool, path, tier, oldBody, newBody, agent } = args;
|
|
367
|
+
if (tier !== "source" && tier !== "manual")
|
|
368
|
+
return ok(undefined);
|
|
369
|
+
if (sameBody(oldBody, newBody))
|
|
370
|
+
return ok(undefined);
|
|
371
|
+
if (tier === "source") {
|
|
372
|
+
return err(new Error(`${tool}: ${path} is tier 'source' — its body is immutable. If this ` +
|
|
373
|
+
`rewrite is deliberate, demote the tier first with vault_set_tier ` +
|
|
374
|
+
`(a reason is required and the change is logged), then write. ` +
|
|
375
|
+
`vault_append can add to it without a tier change.`));
|
|
376
|
+
}
|
|
377
|
+
if (!agent.startsWith("human:")) {
|
|
378
|
+
return err(new Error(`${tool}: ${path} is tier 'manual' — body rewrites require a ` +
|
|
379
|
+
`human:* identity (got '${agent}'). vault_append can add to it.`));
|
|
380
|
+
}
|
|
381
|
+
return ok(undefined);
|
|
382
|
+
}
|
|
383
|
+
// #4: the generative-domain documents an accumulation-domain doc references
|
|
384
|
+
// (typed `sources` entries plus body links), resolved against the indexed
|
|
385
|
+
// path universe. Advisory only — callers attach the result as
|
|
386
|
+
// domain_warnings; index unavailability degrades to silence.
|
|
387
|
+
function generativeDomainRefs(vaultRoot, doc, access) {
|
|
388
|
+
if (doc.domain !== "accumulation")
|
|
389
|
+
return null;
|
|
390
|
+
// Most accumulation writes reference nothing; skip the index entirely
|
|
391
|
+
// rather than loading every vault path to resolve an empty candidate set.
|
|
392
|
+
const localSources = doc.sources.filter((s) => !EXTERNAL_REF.test(s));
|
|
393
|
+
if (localSources.length === 0 && extractLinks(doc.body).length === 0)
|
|
394
|
+
return null;
|
|
395
|
+
const db = openIndexForAccessOrNull(vaultRoot);
|
|
396
|
+
if (!db)
|
|
397
|
+
return null;
|
|
398
|
+
try {
|
|
399
|
+
const indexes = buildPathIndexes(allDocumentPaths(db).map((p) => ({ path: p })));
|
|
400
|
+
const candidates = new Set(outgoingLinkTargets(doc.body, doc.relPath, indexes));
|
|
401
|
+
for (const raw of localSources) {
|
|
402
|
+
const target = resolveLink(raw, doc.relPath, indexes.byPath, indexes.byBasename);
|
|
403
|
+
if (target && target !== doc.relPath)
|
|
404
|
+
candidates.add(target);
|
|
405
|
+
}
|
|
406
|
+
if (candidates.size === 0)
|
|
407
|
+
return null;
|
|
408
|
+
const generative = getDocumentsByPaths(db, [...candidates])
|
|
409
|
+
.filter((d) => d.domain === "generative")
|
|
410
|
+
.map((d) => d.path)
|
|
411
|
+
// Vantage rule (#217, security review on #261): a warning names the
|
|
412
|
+
// RESOLVED path and discloses the target's domain — metadata about a
|
|
413
|
+
// doc the caller may not read, and resolveLink's basename fallback can
|
|
414
|
+
// reveal a location the caller never typed. Targets outside the
|
|
415
|
+
// caller's read scope are omitted entirely, the same rule lint's
|
|
416
|
+
// domainLeaks inherits from runLint's pre-filtered doc set.
|
|
417
|
+
.filter((p) => !access || sourceReadable(db, access, p))
|
|
418
|
+
.sort();
|
|
419
|
+
if (generative.length === 0)
|
|
420
|
+
return null;
|
|
421
|
+
return generative.map((p) => `${p} is generative-domain — speculative material referenced from accumulation canon`);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// This runs after performWrite returned ok — the write is durable. A
|
|
425
|
+
// query failure here (index rebuilt mid-query, locked db) must degrade
|
|
426
|
+
// to "no warnings", never surface as a failed write to the caller.
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
db.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// The #169 nudge, verbatim on every vault_write overwrite. One fixed string:
|
|
434
|
+
// the signal is the field's presence, and a stable text is grep-able in
|
|
435
|
+
// agent traces.
|
|
436
|
+
const SUPERSEDE_HINT = "This write replaced an existing document in place. If it records a changed " +
|
|
437
|
+
"fact, prefer vault_supersede: it preserves the prior version and mints the " +
|
|
438
|
+
"superseded_by edge instead of erasing lineage. Advisory only — this write " +
|
|
439
|
+
"has already landed.";
|
|
292
440
|
export async function vaultWrite(vaultRoot, args, access) {
|
|
293
441
|
const ready = requireIndexReady();
|
|
294
442
|
if (!ready.ok)
|
|
@@ -302,6 +450,9 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
302
450
|
const baseVersion = readBaseVersion(args, "vault_write");
|
|
303
451
|
if (!baseVersion.ok)
|
|
304
452
|
return baseVersion;
|
|
453
|
+
const runId = readRunId(args, "vault_write");
|
|
454
|
+
if (!runId.ok)
|
|
455
|
+
return runId;
|
|
305
456
|
const body = args.body;
|
|
306
457
|
if (typeof body !== "string") {
|
|
307
458
|
return err(new Error("vault_write requires a string 'body' argument"));
|
|
@@ -312,11 +463,15 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
312
463
|
const rawFrontmatter = args.frontmatter;
|
|
313
464
|
// RBAC is checked before any file I/O or frontmatter validation: an
|
|
314
465
|
// unauthorized caller is denied without learning anything about the target.
|
|
466
|
+
// The gate keys off the directory the bytes physically land in (the resolved
|
|
467
|
+
// target path's top-level dir), NOT the caller-declared frontmatter.collection
|
|
468
|
+
// — otherwise a role with write on collection A could write into collection B
|
|
469
|
+
// by declaring `collection: A` while pointing `path` at B (S1). The declared
|
|
470
|
+
// collection is allowed to differ from the physical dir (e.g. a draft staged
|
|
471
|
+
// in _drafts/ that declares its destination collection); it just never widens
|
|
472
|
+
// access.
|
|
315
473
|
if (access) {
|
|
316
|
-
const
|
|
317
|
-
const collection = typeof declared === "string" && declared.length > 0
|
|
318
|
-
? declared
|
|
319
|
-
: (path.value.split("/")[0] ?? "");
|
|
474
|
+
const collection = targetCollection(vaultRoot, path.value);
|
|
320
475
|
if (!canWrite(access.role, collection)) {
|
|
321
476
|
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
|
|
322
477
|
`collection '${collection}'`));
|
|
@@ -325,9 +480,81 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
325
480
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
326
481
|
if (!resolved.ok)
|
|
327
482
|
return resolved;
|
|
328
|
-
|
|
483
|
+
// #235: a propose-only role's write lands as a staged `write` proposal, not
|
|
484
|
+
// a mutation. Coerced EARLY — before file I/O, tier guards, hooks, or
|
|
485
|
+
// validation — because all of that runs for real at ratify time when
|
|
486
|
+
// vault_ratify dispatches the payload back through this tool with the
|
|
487
|
+
// ratifier's access. The write grant above still scopes which collections
|
|
488
|
+
// the role may propose into. Inter-proposal conflicts are checked the same
|
|
489
|
+
// way vault_stage_action checks them: both proposals stay pending, a
|
|
490
|
+
// tension is logged, the result names the contenders. Staged under the
|
|
491
|
+
// CANONICAL relPath, matching vault_stage_action — a raw caller spelling
|
|
492
|
+
// (`pricing/./x.md`) would dodge the exact-string conflict match and the
|
|
493
|
+
// ratify gate's existing-doc lookup (#127/#128 rule).
|
|
494
|
+
if (access && isProposeOnly(access.role)) {
|
|
495
|
+
// The validation report is REAL, not fabricated: run the payload through
|
|
496
|
+
// the same merge-then-validate the eventual dispatch will apply (merged
|
|
497
|
+
// under any existing frontmatter, server-stamped fields defaulted), so a
|
|
498
|
+
// propose-only agent gets its schema feedback NOW instead of at ratify
|
|
499
|
+
// time up to ttl_days later. Advisory here — staging proceeds regardless;
|
|
500
|
+
// the blocking check runs for real when vault_ratify dispatches.
|
|
501
|
+
const proposeConfig = loadConfig(vaultRoot);
|
|
502
|
+
if (!proposeConfig.ok)
|
|
503
|
+
return proposeConfig;
|
|
504
|
+
let previewRaw = { ...rawFrontmatter };
|
|
505
|
+
const onDisk = await readFile(resolved.value.absPath);
|
|
506
|
+
if (onDisk.ok) {
|
|
507
|
+
const parsedExisting = parseDocument(onDisk.value);
|
|
508
|
+
if (parsedExisting.ok) {
|
|
509
|
+
const merged = { ...parsedExisting.value.raw };
|
|
510
|
+
for (const [key, value] of Object.entries(rawFrontmatter)) {
|
|
511
|
+
if (value === null)
|
|
512
|
+
delete merged[key];
|
|
513
|
+
else
|
|
514
|
+
merged[key] = value;
|
|
515
|
+
}
|
|
516
|
+
previewRaw = merged;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (previewRaw.updated === undefined || previewRaw.updated === null) {
|
|
520
|
+
previewRaw.updated = todayISO();
|
|
521
|
+
}
|
|
522
|
+
if (previewRaw.updated_by === undefined || previewRaw.updated_by === null) {
|
|
523
|
+
previewRaw.updated_by = agent.value;
|
|
524
|
+
}
|
|
525
|
+
const preview = validateFrontmatter(previewRaw, proposeConfig.value.schemaExtensions);
|
|
526
|
+
const staged = await stageActionWithConflictCheck(vaultRoot, {
|
|
527
|
+
actionType: "write",
|
|
528
|
+
targetPath: resolved.value.relPath,
|
|
529
|
+
proposedBy: agent.value,
|
|
530
|
+
rationale: typeof args.reason === "string" && args.reason.trim().length > 0
|
|
531
|
+
? args.reason.trim()
|
|
532
|
+
: `propose-only role '${access.roleName}': write staged for ratification`,
|
|
533
|
+
proposedDiff: { frontmatter: rawFrontmatter, body },
|
|
534
|
+
...(runId.value !== undefined ? { runId: runId.value } : {}),
|
|
535
|
+
});
|
|
536
|
+
if (!staged.ok)
|
|
537
|
+
return staged;
|
|
538
|
+
return ok({
|
|
539
|
+
path: resolved.value.relPath,
|
|
540
|
+
action: "staged",
|
|
541
|
+
commit: null,
|
|
542
|
+
committed: false,
|
|
543
|
+
status: "pending",
|
|
544
|
+
updated: todayISO(),
|
|
545
|
+
validation: preview.report,
|
|
546
|
+
indexUpdated: false,
|
|
547
|
+
staged_id: staged.value.id,
|
|
548
|
+
expires_at: staged.value.expires_at,
|
|
549
|
+
conflicts_with: staged.value.conflicts_with,
|
|
550
|
+
tension_id: staged.value.tension_id,
|
|
551
|
+
...(staged.value.tension_error ? { tension_error: staged.value.tension_error } : {}),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const existing = await readFile(resolved.value.absPath);
|
|
329
555
|
let oldFrontmatter = null;
|
|
330
556
|
let oldRaw = null;
|
|
557
|
+
let oldContent = "";
|
|
331
558
|
if (existing.ok) {
|
|
332
559
|
const parsed = parseDocument(existing.value);
|
|
333
560
|
if (!parsed.ok) {
|
|
@@ -342,9 +569,22 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
342
569
|
}
|
|
343
570
|
oldFrontmatter = parsed.value.frontmatter;
|
|
344
571
|
oldRaw = parsed.value.raw;
|
|
572
|
+
oldContent = parsed.value.content;
|
|
345
573
|
}
|
|
346
574
|
const isUpdate = oldFrontmatter !== null;
|
|
347
575
|
const hookOperation = isUpdate ? "update" : "create";
|
|
576
|
+
if (isUpdate && oldFrontmatter) {
|
|
577
|
+
const guard = checkTierGuard({
|
|
578
|
+
tool: "vault_write",
|
|
579
|
+
path: path.value,
|
|
580
|
+
tier: oldFrontmatter.tier,
|
|
581
|
+
oldBody: oldContent,
|
|
582
|
+
newBody: body,
|
|
583
|
+
agent: agent.value,
|
|
584
|
+
});
|
|
585
|
+
if (!guard.ok)
|
|
586
|
+
return guard;
|
|
587
|
+
}
|
|
348
588
|
// On update, merge the document's existing frontmatter under the payload, so
|
|
349
589
|
// a tool-mediated write never silently drops a field the author put there
|
|
350
590
|
// (#113). `oldRaw` is the frontmatter exactly as parsed — built-in fields,
|
|
@@ -367,6 +607,19 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
367
607
|
delete rawFrontmatter[key];
|
|
368
608
|
Object.assign(rawFrontmatter, merged);
|
|
369
609
|
}
|
|
610
|
+
// #141: a protected doc's tier only changes via vault_set_tier. Allowing it
|
|
611
|
+
// here would let a frontmatter-only write (which sameBody waves through)
|
|
612
|
+
// demote the tier — dodging set_tier's reason requirement and manual's
|
|
613
|
+
// human gate — and then rewrite the body in a second call.
|
|
614
|
+
if (isUpdate &&
|
|
615
|
+
oldFrontmatter &&
|
|
616
|
+
(oldFrontmatter.tier === "source" || oldFrontmatter.tier === "manual")) {
|
|
617
|
+
if (rawFrontmatter.tier !== oldFrontmatter.tier) {
|
|
618
|
+
return err(new Error(`vault_write: ${path.value} is tier '${oldFrontmatter.tier}' — changing ` +
|
|
619
|
+
`its tier requires vault_set_tier (a reason is required` +
|
|
620
|
+
`${oldFrontmatter.tier === "manual" ? ", and a human:* identity" : ""}).`));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
370
623
|
// Config-declared schema extensions participate in validation and
|
|
371
624
|
// serialization. A malformed config fails the write loudly, matching the
|
|
372
625
|
// server's loud-config contract.
|
|
@@ -433,10 +686,13 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
433
686
|
updated: todayISO(),
|
|
434
687
|
updated_by: agent.value,
|
|
435
688
|
};
|
|
436
|
-
|
|
689
|
+
const written = await performWrite({
|
|
437
690
|
vaultRoot,
|
|
438
|
-
|
|
439
|
-
|
|
691
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
692
|
+
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
693
|
+
// spellings of one file must contend on one lock (#127/#128).
|
|
694
|
+
relPath: resolved.value.relPath,
|
|
695
|
+
absPath: resolved.value.absPath,
|
|
440
696
|
agent: agent.value,
|
|
441
697
|
tool: "vault_write",
|
|
442
698
|
action: isUpdate ? "update" : "create",
|
|
@@ -450,6 +706,31 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
450
706
|
baseVersion: baseVersion.value,
|
|
451
707
|
shadowMode: config.value.shadowMode,
|
|
452
708
|
principal: access?.user,
|
|
709
|
+
...(runId.value !== undefined ? { runId: runId.value } : {}),
|
|
710
|
+
bodyChanged: !isUpdate || !sameBody(oldContent, body),
|
|
711
|
+
});
|
|
712
|
+
// #169: an in-place overwrite destroys the prior version's lineage, and
|
|
713
|
+
// daftari HAS the preserve-not-overwrite primitive — steer toward it at
|
|
714
|
+
// the moment of overwrite, the decay banner's channel: additive, advisory,
|
|
715
|
+
// never blocking, never auto-minting an edge. The agent still chooses.
|
|
716
|
+
// Attached here (not in performWrite) so vault_supersede and the other
|
|
717
|
+
// lifecycle tools never nudge about themselves. A shadow-mode result is
|
|
718
|
+
// excluded: nothing landed and nothing was replaced, so the hints' text
|
|
719
|
+
// would be false. #4's domain warnings ride the same channel.
|
|
720
|
+
if (!written.ok || written.value.shadow)
|
|
721
|
+
return written;
|
|
722
|
+
const warnings = generativeDomainRefs(vaultRoot, {
|
|
723
|
+
domain: stamped.domain,
|
|
724
|
+
sources: stamped.sources ?? [],
|
|
725
|
+
body,
|
|
726
|
+
relPath: resolved.value.relPath,
|
|
727
|
+
}, access);
|
|
728
|
+
if (!isUpdate && warnings === null)
|
|
729
|
+
return written;
|
|
730
|
+
return ok({
|
|
731
|
+
...written.value,
|
|
732
|
+
...(isUpdate ? { supersede_hint: SUPERSEDE_HINT } : {}),
|
|
733
|
+
...(warnings ? { domain_warnings: warnings } : {}),
|
|
453
734
|
});
|
|
454
735
|
}
|
|
455
736
|
// ---------------------------------------------------------------------------
|
|
@@ -473,10 +754,16 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
473
754
|
const baseVersion = readBaseVersion(args, "vault_append");
|
|
474
755
|
if (!baseVersion.ok)
|
|
475
756
|
return baseVersion;
|
|
757
|
+
const runId = readRunId(args, "vault_append");
|
|
758
|
+
if (!runId.ok)
|
|
759
|
+
return runId;
|
|
760
|
+
const proposeGate = denyIfProposeOnly(access, "vault_append");
|
|
761
|
+
if (!proposeGate.ok)
|
|
762
|
+
return proposeGate;
|
|
476
763
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
477
764
|
if (!resolved.ok)
|
|
478
765
|
return resolved;
|
|
479
|
-
const existing = await readFile(resolved.value);
|
|
766
|
+
const existing = await readFile(resolved.value.absPath);
|
|
480
767
|
if (!existing.ok) {
|
|
481
768
|
return err(new Error(`vault_append: document not found: ${path.value}`));
|
|
482
769
|
}
|
|
@@ -522,10 +809,13 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
522
809
|
const summary = appendIssues.map((i) => `${i.field}: ${i.message}`).join("; ");
|
|
523
810
|
return err(new Error(`invalid frontmatter: ${summary}`));
|
|
524
811
|
}
|
|
525
|
-
|
|
812
|
+
const appended = await performWrite({
|
|
526
813
|
vaultRoot,
|
|
527
|
-
|
|
528
|
-
|
|
814
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
815
|
+
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
816
|
+
// spellings of one file must contend on one lock (#127/#128).
|
|
817
|
+
relPath: resolved.value.relPath,
|
|
818
|
+
absPath: resolved.value.absPath,
|
|
529
819
|
agent: agent.value,
|
|
530
820
|
tool: "vault_append",
|
|
531
821
|
action: "append",
|
|
@@ -539,7 +829,26 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
539
829
|
baseVersion: baseVersion.value,
|
|
540
830
|
shadowMode: config.value.shadowMode,
|
|
541
831
|
principal: access?.user,
|
|
832
|
+
...(runId.value !== undefined ? { runId: runId.value } : {}),
|
|
833
|
+
bodyChanged: true,
|
|
542
834
|
});
|
|
835
|
+
// #4: an appended section can introduce body links into generative-domain
|
|
836
|
+
// docs — same advisory channel as vault_write, scoped to what THIS append
|
|
837
|
+
// added: only the new section is scanned (a doc that already leaned on
|
|
838
|
+
// generative material warned at write time; re-warning on every later,
|
|
839
|
+
// unrelated append would drown the signal), and sources are skipped
|
|
840
|
+
// entirely because an append cannot change frontmatter.
|
|
841
|
+
if (!appended.ok || appended.value.shadow)
|
|
842
|
+
return appended;
|
|
843
|
+
const appendWarnings = generativeDomainRefs(vaultRoot, {
|
|
844
|
+
domain: newFrontmatter.domain,
|
|
845
|
+
sources: [],
|
|
846
|
+
body: section.value,
|
|
847
|
+
relPath: resolved.value.relPath,
|
|
848
|
+
}, access);
|
|
849
|
+
if (appendWarnings === null)
|
|
850
|
+
return appended;
|
|
851
|
+
return ok({ ...appended.value, domain_warnings: appendWarnings });
|
|
543
852
|
}
|
|
544
853
|
// ---------------------------------------------------------------------------
|
|
545
854
|
// vault_promote
|
|
@@ -551,6 +860,9 @@ export async function vaultPromote(vaultRoot, args, access) {
|
|
|
551
860
|
const ready = requireIndexReady();
|
|
552
861
|
if (!ready.ok)
|
|
553
862
|
return ready;
|
|
863
|
+
const proposeGate = denyIfProposeOnly(access, "vault_promote");
|
|
864
|
+
if (!proposeGate.ok)
|
|
865
|
+
return proposeGate;
|
|
554
866
|
const path = requireString(args, "path", "vault_promote");
|
|
555
867
|
if (!path.ok)
|
|
556
868
|
return path;
|
|
@@ -566,7 +878,7 @@ export async function vaultPromote(vaultRoot, args, access) {
|
|
|
566
878
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
567
879
|
if (!resolved.ok)
|
|
568
880
|
return resolved;
|
|
569
|
-
const existing = await readFile(resolved.value);
|
|
881
|
+
const existing = await readFile(resolved.value.absPath);
|
|
570
882
|
if (!existing.ok) {
|
|
571
883
|
return err(new Error(`vault_promote: document not found: ${path.value}`));
|
|
572
884
|
}
|
|
@@ -603,8 +915,11 @@ export async function vaultPromote(vaultRoot, args, access) {
|
|
|
603
915
|
};
|
|
604
916
|
return performWrite({
|
|
605
917
|
vaultRoot,
|
|
606
|
-
|
|
607
|
-
|
|
918
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
919
|
+
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
920
|
+
// spellings of one file must contend on one lock (#127/#128).
|
|
921
|
+
relPath: resolved.value.relPath,
|
|
922
|
+
absPath: resolved.value.absPath,
|
|
608
923
|
agent: agent.value,
|
|
609
924
|
tool: "vault_promote",
|
|
610
925
|
action: "promote",
|
|
@@ -618,6 +933,7 @@ export async function vaultPromote(vaultRoot, args, access) {
|
|
|
618
933
|
baseVersion: baseVersion.value,
|
|
619
934
|
shadowMode: config.value.shadowMode,
|
|
620
935
|
principal: access?.user,
|
|
936
|
+
bodyChanged: false,
|
|
621
937
|
});
|
|
622
938
|
}
|
|
623
939
|
// ---------------------------------------------------------------------------
|
|
@@ -630,6 +946,9 @@ export async function vaultDeprecate(vaultRoot, args, access) {
|
|
|
630
946
|
const ready = requireIndexReady();
|
|
631
947
|
if (!ready.ok)
|
|
632
948
|
return ready;
|
|
949
|
+
const proposeGate = denyIfProposeOnly(access, "vault_deprecate");
|
|
950
|
+
if (!proposeGate.ok)
|
|
951
|
+
return proposeGate;
|
|
633
952
|
const path = requireString(args, "path", "vault_deprecate");
|
|
634
953
|
if (!path.ok)
|
|
635
954
|
return path;
|
|
@@ -652,7 +971,7 @@ export async function vaultDeprecate(vaultRoot, args, access) {
|
|
|
652
971
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
653
972
|
if (!resolved.ok)
|
|
654
973
|
return resolved;
|
|
655
|
-
const existing = await readFile(resolved.value);
|
|
974
|
+
const existing = await readFile(resolved.value.absPath);
|
|
656
975
|
if (!existing.ok) {
|
|
657
976
|
return err(new Error(`vault_deprecate: document not found: ${path.value}`));
|
|
658
977
|
}
|
|
@@ -680,8 +999,11 @@ export async function vaultDeprecate(vaultRoot, args, access) {
|
|
|
680
999
|
};
|
|
681
1000
|
return performWrite({
|
|
682
1001
|
vaultRoot,
|
|
683
|
-
|
|
684
|
-
|
|
1002
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
1003
|
+
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
1004
|
+
// spellings of one file must contend on one lock (#127/#128).
|
|
1005
|
+
relPath: resolved.value.relPath,
|
|
1006
|
+
absPath: resolved.value.absPath,
|
|
685
1007
|
agent: agent.value,
|
|
686
1008
|
tool: "vault_deprecate",
|
|
687
1009
|
action: "deprecate",
|
|
@@ -696,6 +1018,7 @@ export async function vaultDeprecate(vaultRoot, args, access) {
|
|
|
696
1018
|
baseVersion: baseVersion.value,
|
|
697
1019
|
shadowMode: config.value.shadowMode,
|
|
698
1020
|
principal: access?.user,
|
|
1021
|
+
bodyChanged: false,
|
|
699
1022
|
});
|
|
700
1023
|
}
|
|
701
1024
|
// ---------------------------------------------------------------------------
|
|
@@ -709,6 +1032,9 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
709
1032
|
const ready = requireIndexReady();
|
|
710
1033
|
if (!ready.ok)
|
|
711
1034
|
return ready;
|
|
1035
|
+
const proposeGate = denyIfProposeOnly(access, "vault_set_confidence");
|
|
1036
|
+
if (!proposeGate.ok)
|
|
1037
|
+
return proposeGate;
|
|
712
1038
|
const path = requireString(args, "path", "vault_set_confidence");
|
|
713
1039
|
if (!path.ok)
|
|
714
1040
|
return path;
|
|
@@ -730,7 +1056,7 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
730
1056
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
731
1057
|
if (!resolved.ok)
|
|
732
1058
|
return resolved;
|
|
733
|
-
const existing = await readFile(resolved.value);
|
|
1059
|
+
const existing = await readFile(resolved.value.absPath);
|
|
734
1060
|
if (!existing.ok) {
|
|
735
1061
|
return err(new Error(`vault_set_confidence: document not found: ${path.value}`));
|
|
736
1062
|
}
|
|
@@ -772,8 +1098,11 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
772
1098
|
};
|
|
773
1099
|
return performWrite({
|
|
774
1100
|
vaultRoot,
|
|
775
|
-
|
|
776
|
-
|
|
1101
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
1102
|
+
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
1103
|
+
// spellings of one file must contend on one lock (#127/#128).
|
|
1104
|
+
relPath: resolved.value.relPath,
|
|
1105
|
+
absPath: resolved.value.absPath,
|
|
777
1106
|
agent: agent.value,
|
|
778
1107
|
tool: "vault_set_confidence",
|
|
779
1108
|
action: "confidence-set",
|
|
@@ -788,6 +1117,112 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
788
1117
|
baseVersion: baseVersion.value,
|
|
789
1118
|
shadowMode: config.value.shadowMode,
|
|
790
1119
|
principal: access?.user,
|
|
1120
|
+
bodyChanged: false,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
// vault_set_tier (#141)
|
|
1125
|
+
// ---------------------------------------------------------------------------
|
|
1126
|
+
// Changes only a document's write-protection tier, leaving body and status
|
|
1127
|
+
// untouched. This is the escape hatch for the tier guards — a separate,
|
|
1128
|
+
// reason-carrying, provenance-logged act rather than an inline force flag on
|
|
1129
|
+
// the destructive call. Moving a doc AWAY from `manual` requires a human:*
|
|
1130
|
+
// identity (otherwise demote-then-write would bypass the consent boundary);
|
|
1131
|
+
// moving away from `source` is open to any identity — deliberate demotions
|
|
1132
|
+
// are allowed, and vault_lint surfaces them for review.
|
|
1133
|
+
export async function vaultSetTier(vaultRoot, args, access) {
|
|
1134
|
+
const ready = requireIndexReady();
|
|
1135
|
+
if (!ready.ok)
|
|
1136
|
+
return ready;
|
|
1137
|
+
const proposeGate = denyIfProposeOnly(access, "vault_set_tier");
|
|
1138
|
+
if (!proposeGate.ok)
|
|
1139
|
+
return proposeGate;
|
|
1140
|
+
const path = requireString(args, "path", "vault_set_tier");
|
|
1141
|
+
if (!path.ok)
|
|
1142
|
+
return path;
|
|
1143
|
+
const agent = requireString(args, "agent", "vault_set_tier");
|
|
1144
|
+
if (!agent.ok)
|
|
1145
|
+
return agent;
|
|
1146
|
+
const reason = requireString(args, "reason", "vault_set_tier");
|
|
1147
|
+
if (!reason.ok)
|
|
1148
|
+
return reason;
|
|
1149
|
+
const tier = requireString(args, "tier", "vault_set_tier");
|
|
1150
|
+
if (!tier.ok)
|
|
1151
|
+
return tier;
|
|
1152
|
+
if (!TIERS.includes(tier.value)) {
|
|
1153
|
+
return err(new Error(`vault_set_tier: 'tier' must be one of: ${TIERS.join(", ")}`));
|
|
1154
|
+
}
|
|
1155
|
+
const newTier = tier.value;
|
|
1156
|
+
const baseVersion = readBaseVersion(args, "vault_set_tier");
|
|
1157
|
+
if (!baseVersion.ok)
|
|
1158
|
+
return baseVersion;
|
|
1159
|
+
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
1160
|
+
if (!resolved.ok)
|
|
1161
|
+
return resolved;
|
|
1162
|
+
const existing = await readFile(resolved.value.absPath);
|
|
1163
|
+
if (!existing.ok) {
|
|
1164
|
+
return err(new Error(`vault_set_tier: document not found: ${path.value}`));
|
|
1165
|
+
}
|
|
1166
|
+
const parsed = parseDocument(existing.value);
|
|
1167
|
+
if (!parsed.ok)
|
|
1168
|
+
return parsed;
|
|
1169
|
+
const config = loadConfig(vaultRoot);
|
|
1170
|
+
if (!config.ok)
|
|
1171
|
+
return config;
|
|
1172
|
+
const extensions = config.value.schemaExtensions;
|
|
1173
|
+
const oldFrontmatter = parsed.value.frontmatter;
|
|
1174
|
+
if (access) {
|
|
1175
|
+
const collection = collectionOf(path.value, oldFrontmatter);
|
|
1176
|
+
if (!canWrite(access.role, collection)) {
|
|
1177
|
+
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
|
|
1178
|
+
`collection '${collection}'`));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// The manual consent boundary: only a human identity may lift `manual`.
|
|
1182
|
+
if (oldFrontmatter.tier === "manual" &&
|
|
1183
|
+
newTier !== "manual" &&
|
|
1184
|
+
!agent.value.startsWith("human:")) {
|
|
1185
|
+
return err(new Error(`vault_set_tier: ${path.value} is tier 'manual' — moving it away from ` +
|
|
1186
|
+
`'manual' requires a human:* identity (got '${agent.value}').`));
|
|
1187
|
+
}
|
|
1188
|
+
// No-op guard, same shape as vault_set_confidence: compare against the raw
|
|
1189
|
+
// on-disk value so an invalid raw tier (validated down to null) can still be
|
|
1190
|
+
// set to any member.
|
|
1191
|
+
const rawTier = parsed.value.raw.tier;
|
|
1192
|
+
const currentTier = typeof rawTier === "string" && TIERS.includes(rawTier)
|
|
1193
|
+
? rawTier
|
|
1194
|
+
: undefined;
|
|
1195
|
+
if (currentTier === newTier) {
|
|
1196
|
+
return err(new Error(`vault_set_tier: ${path.value} tier is already '${newTier}'`));
|
|
1197
|
+
}
|
|
1198
|
+
const newFrontmatter = {
|
|
1199
|
+
...oldFrontmatter,
|
|
1200
|
+
tier: newTier,
|
|
1201
|
+
updated: todayISO(),
|
|
1202
|
+
updated_by: agent.value,
|
|
1203
|
+
};
|
|
1204
|
+
return performWrite({
|
|
1205
|
+
vaultRoot,
|
|
1206
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
1207
|
+
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
1208
|
+
// spellings of one file must contend on one lock (#127/#128).
|
|
1209
|
+
relPath: resolved.value.relPath,
|
|
1210
|
+
absPath: resolved.value.absPath,
|
|
1211
|
+
agent: agent.value,
|
|
1212
|
+
tool: "vault_set_tier",
|
|
1213
|
+
action: "tier-set",
|
|
1214
|
+
fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
|
|
1215
|
+
newFrontmatter,
|
|
1216
|
+
oldFrontmatter,
|
|
1217
|
+
validation: parsed.value.validation,
|
|
1218
|
+
commitMessage: `vault_set_tier: ${path.value} ${oldFrontmatter.tier ?? "unset"}→${newTier} ` +
|
|
1219
|
+
`by ${agent.value} — ${reason.value}`,
|
|
1220
|
+
autoCommit: config.value.autoCommit,
|
|
1221
|
+
gitDir: config.value.gitDir,
|
|
1222
|
+
baseVersion: baseVersion.value,
|
|
1223
|
+
shadowMode: config.value.shadowMode,
|
|
1224
|
+
principal: access?.user,
|
|
1225
|
+
bodyChanged: false,
|
|
791
1226
|
});
|
|
792
1227
|
}
|
|
793
1228
|
// ---------------------------------------------------------------------------
|
|
@@ -801,6 +1236,9 @@ export async function vaultSupersede(vaultRoot, args, access) {
|
|
|
801
1236
|
const ready = requireIndexReady();
|
|
802
1237
|
if (!ready.ok)
|
|
803
1238
|
return ready;
|
|
1239
|
+
const proposeGate = denyIfProposeOnly(access, "vault_supersede");
|
|
1240
|
+
if (!proposeGate.ok)
|
|
1241
|
+
return proposeGate;
|
|
804
1242
|
const oldPath = requireString(args, "old_path", "vault_supersede");
|
|
805
1243
|
if (!oldPath.ok)
|
|
806
1244
|
return oldPath;
|
|
@@ -831,12 +1269,12 @@ export async function vaultSupersede(vaultRoot, args, access) {
|
|
|
831
1269
|
const resolvedNew = resolveVaultPath(vaultRoot, newPath.value);
|
|
832
1270
|
if (!resolvedNew.ok)
|
|
833
1271
|
return resolvedNew;
|
|
834
|
-
const existing = await readFile(resolvedOld.value);
|
|
1272
|
+
const existing = await readFile(resolvedOld.value.absPath);
|
|
835
1273
|
if (!existing.ok) {
|
|
836
1274
|
return err(new Error(`vault_supersede: document not found: ${oldPath.value}`));
|
|
837
1275
|
}
|
|
838
1276
|
// The successor must be a real document — superseded_by must never dangle.
|
|
839
|
-
const successor = await readFile(resolvedNew.value);
|
|
1277
|
+
const successor = await readFile(resolvedNew.value.absPath);
|
|
840
1278
|
if (!successor.ok) {
|
|
841
1279
|
return err(new Error(`vault_supersede: successor not found: ${newPath.value}`));
|
|
842
1280
|
}
|
|
@@ -864,8 +1302,9 @@ export async function vaultSupersede(vaultRoot, args, access) {
|
|
|
864
1302
|
};
|
|
865
1303
|
return performWrite({
|
|
866
1304
|
vaultRoot,
|
|
867
|
-
relPath
|
|
868
|
-
|
|
1305
|
+
// Canonical relPath keys the lock/provenance/commit (#127/#128).
|
|
1306
|
+
relPath: resolvedOld.value.relPath,
|
|
1307
|
+
absPath: resolvedOld.value.absPath,
|
|
869
1308
|
agent: agent.value,
|
|
870
1309
|
tool: "vault_supersede",
|
|
871
1310
|
action: "supersede",
|
|
@@ -880,6 +1319,7 @@ export async function vaultSupersede(vaultRoot, args, access) {
|
|
|
880
1319
|
baseVersion: baseVersion.value,
|
|
881
1320
|
shadowMode: config.value.shadowMode,
|
|
882
1321
|
principal: access?.user,
|
|
1322
|
+
bodyChanged: false,
|
|
883
1323
|
});
|
|
884
1324
|
}
|
|
885
1325
|
// Combines two source documents into a target and supersedes both sources to
|
|
@@ -896,6 +1336,9 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
896
1336
|
const ready = requireIndexReady();
|
|
897
1337
|
if (!ready.ok)
|
|
898
1338
|
return ready;
|
|
1339
|
+
const proposeGate = denyIfProposeOnly(access, "vault_merge");
|
|
1340
|
+
if (!proposeGate.ok)
|
|
1341
|
+
return proposeGate;
|
|
899
1342
|
const pathA = requireString(args, "path_a", "vault_merge");
|
|
900
1343
|
if (!pathA.ok)
|
|
901
1344
|
return pathA;
|
|
@@ -921,11 +1364,11 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
921
1364
|
? args.frontmatter
|
|
922
1365
|
: {};
|
|
923
1366
|
// Resolve all three paths up front and run every identity check against the
|
|
924
|
-
//
|
|
925
|
-
// `./pricing/a.md
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
// itself).
|
|
1367
|
+
// CANONICAL relPath, never the raw caller strings: `pricing/a.md`,
|
|
1368
|
+
// `./pricing/a.md`, and a symlink alias all name the same file but differ as
|
|
1369
|
+
// strings (and as absPaths, for a symlink), which would otherwise defeat the
|
|
1370
|
+
// path_a≠path_b guard and the source-is-target skip below (writing one file
|
|
1371
|
+
// twice in a single commit and superseding it against itself).
|
|
929
1372
|
const resolvedA = resolveVaultPath(vaultRoot, pathA.value);
|
|
930
1373
|
if (!resolvedA.ok)
|
|
931
1374
|
return resolvedA;
|
|
@@ -935,13 +1378,13 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
935
1378
|
const resolvedTarget = resolveVaultPath(vaultRoot, targetPath.value);
|
|
936
1379
|
if (!resolvedTarget.ok)
|
|
937
1380
|
return resolvedTarget;
|
|
938
|
-
if (resolvedA.value === resolvedB.value) {
|
|
1381
|
+
if (resolvedA.value.relPath === resolvedB.value.relPath) {
|
|
939
1382
|
return err(new Error("vault_merge: path_a and path_b must differ"));
|
|
940
1383
|
}
|
|
941
|
-
const existingA = await readFile(resolvedA.value);
|
|
1384
|
+
const existingA = await readFile(resolvedA.value.absPath);
|
|
942
1385
|
if (!existingA.ok)
|
|
943
1386
|
return err(new Error(`vault_merge: document not found: ${pathA.value}`));
|
|
944
|
-
const existingB = await readFile(resolvedB.value);
|
|
1387
|
+
const existingB = await readFile(resolvedB.value.absPath);
|
|
945
1388
|
if (!existingB.ok)
|
|
946
1389
|
return err(new Error(`vault_merge: document not found: ${pathB.value}`));
|
|
947
1390
|
const parsedA = parseDocument(existingA.value);
|
|
@@ -957,20 +1400,17 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
957
1400
|
// RBAC: a merge writes/mutates all three docs, so the caller needs write on
|
|
958
1401
|
// each one's collection.
|
|
959
1402
|
if (access) {
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
|
|
1403
|
+
// path_a and path_b are gated on their existing on-disk collections. The
|
|
1404
|
+
// target is gated on the directory the merged bytes physically land in — the
|
|
1405
|
+
// resolved target path's top-level dir — NOT the caller-declared collection
|
|
1406
|
+
// (override or path_a's raw value). Keying the target gate off a declared
|
|
1407
|
+
// string let a role with write on collection A write the merge result into
|
|
1408
|
+
// collection B by pointing target_path at B while declaring `collection: A`
|
|
1409
|
+
// (S1, same shape as vault_write).
|
|
966
1410
|
const collections = [
|
|
967
1411
|
collectionOf(pathA.value, parsedA.value.frontmatter),
|
|
968
1412
|
collectionOf(pathB.value, parsedB.value.frontmatter),
|
|
969
|
-
|
|
970
|
-
? frontmatterOverrides.collection
|
|
971
|
-
: typeof rawACollection === "string" && rawACollection
|
|
972
|
-
? rawACollection
|
|
973
|
-
: (targetPath.value.split("/")[0] ?? ""),
|
|
1413
|
+
targetCollection(vaultRoot, targetPath.value),
|
|
974
1414
|
];
|
|
975
1415
|
for (const collection of collections) {
|
|
976
1416
|
if (!canWrite(access.role, collection)) {
|
|
@@ -983,16 +1423,33 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
983
1423
|
// overrides, stamp provenance/updated/updated_by. If the target file already
|
|
984
1424
|
// exists, preserve its `created` (the vault_write update idiom); otherwise
|
|
985
1425
|
// inherit path_a's. The supplied body replaces whatever was there.
|
|
986
|
-
const existingTarget = await readFile(resolvedTarget.value);
|
|
1426
|
+
const existingTarget = await readFile(resolvedTarget.value.absPath);
|
|
987
1427
|
let targetOldFrontmatter = null;
|
|
1428
|
+
let targetOldContent = "";
|
|
988
1429
|
let targetCreated = parsedA.value.frontmatter.created;
|
|
989
1430
|
if (existingTarget.ok) {
|
|
990
1431
|
const parsedTarget = parseDocument(existingTarget.value);
|
|
991
1432
|
if (parsedTarget.ok) {
|
|
992
1433
|
targetOldFrontmatter = parsedTarget.value.frontmatter;
|
|
1434
|
+
targetOldContent = parsedTarget.value.content;
|
|
993
1435
|
targetCreated = parsedTarget.value.frontmatter.created;
|
|
994
1436
|
}
|
|
995
1437
|
}
|
|
1438
|
+
// Tier guard (#141): the merged body wholly replaces the target's. path_a
|
|
1439
|
+
// and path_b only receive a frontmatter-level supersede, so their tiers
|
|
1440
|
+
// don't gate the merge.
|
|
1441
|
+
if (targetOldFrontmatter) {
|
|
1442
|
+
const guard = checkTierGuard({
|
|
1443
|
+
tool: "vault_merge",
|
|
1444
|
+
path: targetPath.value,
|
|
1445
|
+
tier: targetOldFrontmatter.tier,
|
|
1446
|
+
oldBody: targetOldContent,
|
|
1447
|
+
newBody: body,
|
|
1448
|
+
agent: agent.value,
|
|
1449
|
+
});
|
|
1450
|
+
if (!guard.ok)
|
|
1451
|
+
return guard;
|
|
1452
|
+
}
|
|
996
1453
|
const targetRaw = {
|
|
997
1454
|
...parsedA.value.raw,
|
|
998
1455
|
provenance: "synthesized",
|
|
@@ -1016,21 +1473,35 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
1016
1473
|
// the target is superseded to point at the target.
|
|
1017
1474
|
const writes = [];
|
|
1018
1475
|
writes.push({
|
|
1019
|
-
relPath
|
|
1020
|
-
|
|
1476
|
+
// Canonical relPath keys the per-file lock, provenance, and commit path so
|
|
1477
|
+
// aliased spellings collapse to one lock (#127/#128).
|
|
1478
|
+
relPath: resolvedTarget.value.relPath,
|
|
1479
|
+
absPath: resolvedTarget.value.absPath,
|
|
1021
1480
|
fileText: serializeDocument(stampedTarget, body, extensions, applyExtensionDefaults(targetRaw, extensions)),
|
|
1022
1481
|
newFrontmatter: stampedTarget,
|
|
1023
1482
|
oldFrontmatter: targetOldFrontmatter,
|
|
1024
1483
|
action: "merge",
|
|
1025
1484
|
});
|
|
1026
1485
|
for (const source of [
|
|
1027
|
-
{
|
|
1028
|
-
|
|
1486
|
+
{
|
|
1487
|
+
path: pathA.value,
|
|
1488
|
+
abs: resolvedA.value.absPath,
|
|
1489
|
+
rel: resolvedA.value.relPath,
|
|
1490
|
+
parsed: parsedA.value,
|
|
1491
|
+
},
|
|
1492
|
+
{
|
|
1493
|
+
path: pathB.value,
|
|
1494
|
+
abs: resolvedB.value.absPath,
|
|
1495
|
+
rel: resolvedB.value.relPath,
|
|
1496
|
+
parsed: parsedB.value,
|
|
1497
|
+
},
|
|
1029
1498
|
]) {
|
|
1030
|
-
// Compare
|
|
1031
|
-
// the target
|
|
1032
|
-
// fold-into-A case
|
|
1033
|
-
|
|
1499
|
+
// Compare canonical relPaths, not raw strings or absPaths: a source that
|
|
1500
|
+
// aliases the target — lexically (`./pricing/a.md`) OR via a symlink — is
|
|
1501
|
+
// the fold-into-A case: write it once with the merged body, never supersede
|
|
1502
|
+
// it. A symlink alias has a distinct absPath, so an absPath compare would
|
|
1503
|
+
// miss it and clobber the merged body with the superseded write (#127/#128).
|
|
1504
|
+
if (source.rel === resolvedTarget.value.relPath)
|
|
1034
1505
|
continue;
|
|
1035
1506
|
const supersededFm = {
|
|
1036
1507
|
...source.parsed.frontmatter,
|
|
@@ -1040,7 +1511,7 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
1040
1511
|
updated_by: agent.value,
|
|
1041
1512
|
};
|
|
1042
1513
|
writes.push({
|
|
1043
|
-
relPath: source.
|
|
1514
|
+
relPath: source.rel,
|
|
1044
1515
|
absPath: source.abs,
|
|
1045
1516
|
fileText: serializeDocument(supersededFm, source.parsed.content, extensions, source.parsed.raw),
|
|
1046
1517
|
newFrontmatter: supersededFm,
|
|
@@ -1128,6 +1599,9 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
1128
1599
|
agent: agent.value,
|
|
1129
1600
|
...(access?.user ? { principal: access.user } : {}),
|
|
1130
1601
|
action: w.action,
|
|
1602
|
+
// The merge target gets a new body; the superseded sources only get
|
|
1603
|
+
// frontmatter stamps.
|
|
1604
|
+
body_changed: w.action === "merge",
|
|
1131
1605
|
frontmatter_diff: frontmatterDiff(w.oldFrontmatter, w.newFrontmatter),
|
|
1132
1606
|
});
|
|
1133
1607
|
}
|
|
@@ -1173,6 +1647,11 @@ const baseVersionProperty = {
|
|
|
1173
1647
|
"write is rejected as stale and nothing is changed. Omit for " +
|
|
1174
1648
|
"last-write-wins behavior.",
|
|
1175
1649
|
};
|
|
1650
|
+
const runIdProperty = {
|
|
1651
|
+
type: "string",
|
|
1652
|
+
description: "Optional trace/run identifier of the calling run. Recorded in the " +
|
|
1653
|
+
"provenance log so one run's writes correlate.",
|
|
1654
|
+
};
|
|
1176
1655
|
// Built-in frontmatter properties, projected from the canonical TS constants
|
|
1177
1656
|
// in src/frontmatter/types.ts so the MCP input schema, the runtime validator,
|
|
1178
1657
|
// and the source-of-truth enums never drift. `additionalProperties: true`
|
|
@@ -1262,7 +1741,15 @@ export const writeTools = [
|
|
|
1262
1741
|
"full frontmatter and markdown body; the server stamps 'updated' and " +
|
|
1263
1742
|
"'updated_by' (omit them — anything supplied is overwritten), preserves " +
|
|
1264
1743
|
"'created' on updates, refreshes the search index, and auto-commits the " +
|
|
1265
|
-
"change to git." +
|
|
1744
|
+
"change to git. If the caller's role is propose-only, nothing is " +
|
|
1745
|
+
"written: the payload lands as a staged 'write' proposal awaiting " +
|
|
1746
|
+
"vault_ratify, and the result carries action: 'staged' with the " +
|
|
1747
|
+
"proposal id (plus any competing pending proposals on the same target). " +
|
|
1748
|
+
"Overwriting an existing document returns an advisory supersede_hint — " +
|
|
1749
|
+
"when the write records a changed fact, vault_supersede preserves the " +
|
|
1750
|
+
"prior version and its lineage instead. An accumulation-domain write " +
|
|
1751
|
+
"that cites or links generative-domain docs returns advisory " +
|
|
1752
|
+
"domain_warnings naming them; the write still lands." +
|
|
1266
1753
|
shadowNote,
|
|
1267
1754
|
inputSchema: {
|
|
1268
1755
|
type: "object",
|
|
@@ -1275,6 +1762,12 @@ export const writeTools = [
|
|
|
1275
1762
|
frontmatter: frontmatterProperty,
|
|
1276
1763
|
agent: agentProperty,
|
|
1277
1764
|
base_version: baseVersionProperty,
|
|
1765
|
+
run_id: runIdProperty,
|
|
1766
|
+
reason: {
|
|
1767
|
+
type: "string",
|
|
1768
|
+
description: "Optional rationale. Used as the proposal rationale when a " +
|
|
1769
|
+
"propose-only role's write is staged for ratification.",
|
|
1770
|
+
},
|
|
1278
1771
|
},
|
|
1279
1772
|
required: ["path", "body", "frontmatter", "agent"],
|
|
1280
1773
|
additionalProperties: false,
|
|
@@ -1286,7 +1779,10 @@ export const writeTools = [
|
|
|
1286
1779
|
title: "Append to a document",
|
|
1287
1780
|
annotations: { destructiveHint: true },
|
|
1288
1781
|
description: "Append a markdown section to an existing vault document. Frontmatter " +
|
|
1289
|
-
"is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits." +
|
|
1782
|
+
"is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits. " +
|
|
1783
|
+
"Appending links to generative-domain docs onto an accumulation-domain " +
|
|
1784
|
+
"doc returns advisory domain_warnings naming them; the append still " +
|
|
1785
|
+
"lands." +
|
|
1290
1786
|
shadowNote,
|
|
1291
1787
|
inputSchema: {
|
|
1292
1788
|
type: "object",
|
|
@@ -1301,6 +1797,7 @@ export const writeTools = [
|
|
|
1301
1797
|
},
|
|
1302
1798
|
agent: agentProperty,
|
|
1303
1799
|
base_version: baseVersionProperty,
|
|
1800
|
+
run_id: runIdProperty,
|
|
1304
1801
|
},
|
|
1305
1802
|
required: ["path", "section", "agent"],
|
|
1306
1803
|
additionalProperties: false,
|
|
@@ -1392,6 +1889,43 @@ export const writeTools = [
|
|
|
1392
1889
|
},
|
|
1393
1890
|
handler: (vaultRoot, args, access) => vaultSetConfidence(vaultRoot, args, access),
|
|
1394
1891
|
},
|
|
1892
|
+
{
|
|
1893
|
+
name: "vault_set_tier",
|
|
1894
|
+
title: "Set a document's write-protection tier",
|
|
1895
|
+
annotations: { destructiveHint: true },
|
|
1896
|
+
description: "Change only a document's write-protection tier (source | compiled | " +
|
|
1897
|
+
"manual), leaving its body untouched. `source` bodies are immutable; " +
|
|
1898
|
+
"`manual` bodies only accept rewrites from a human:* identity; " +
|
|
1899
|
+
"`compiled` and unset are unenforced. This is the escape hatch for the " +
|
|
1900
|
+
"tier guards: demote first (a reason is required and the change is " +
|
|
1901
|
+
"logged for lint review), then write. Moving a doc away from 'manual' " +
|
|
1902
|
+
"requires a human:* identity. Rejects if the tier is already at the " +
|
|
1903
|
+
"target. Auto-commits." +
|
|
1904
|
+
shadowNote,
|
|
1905
|
+
inputSchema: {
|
|
1906
|
+
type: "object",
|
|
1907
|
+
properties: {
|
|
1908
|
+
path: {
|
|
1909
|
+
type: "string",
|
|
1910
|
+
description: "Vault-relative path of the document",
|
|
1911
|
+
},
|
|
1912
|
+
tier: {
|
|
1913
|
+
type: "string",
|
|
1914
|
+
enum: [...TIERS],
|
|
1915
|
+
description: "The new tier",
|
|
1916
|
+
},
|
|
1917
|
+
reason: {
|
|
1918
|
+
type: "string",
|
|
1919
|
+
description: "Why the tier is changing (recorded in the commit and provenance)",
|
|
1920
|
+
},
|
|
1921
|
+
agent: agentProperty,
|
|
1922
|
+
base_version: baseVersionProperty,
|
|
1923
|
+
},
|
|
1924
|
+
required: ["path", "tier", "reason", "agent"],
|
|
1925
|
+
additionalProperties: false,
|
|
1926
|
+
},
|
|
1927
|
+
handler: (vaultRoot, args, access) => vaultSetTier(vaultRoot, args, access),
|
|
1928
|
+
},
|
|
1395
1929
|
{
|
|
1396
1930
|
name: "vault_supersede",
|
|
1397
1931
|
title: "Supersede a document",
|