daftari 1.30.0 → 1.32.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 +176 -0
- package/README.md +188 -4
- 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/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/derive.d.ts.map +1 -1
- package/dist/backfill/derive.js +1 -0
- package/dist/backfill/derive.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +24 -0
- package/dist/cli.js.map +1 -1
- package/dist/consolidate/index.d.ts.map +1 -1
- package/dist/consolidate/index.js +10 -12
- package/dist/consolidate/index.js.map +1 -1
- package/dist/court/precedent.d.ts.map +1 -1
- package/dist/court/precedent.js +6 -1
- package/dist/court/precedent.js.map +1 -1
- 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 +190 -17
- package/dist/curation/edges.js.map +1 -1
- package/dist/curation/lint.d.ts +6 -1
- package/dist/curation/lint.d.ts.map +1 -1
- package/dist/curation/lint.js +87 -24
- 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/shadow.d.ts +1 -0
- package/dist/curation/shadow.d.ts.map +1 -1
- package/dist/curation/shadow.js +17 -11
- package/dist/curation/shadow.js.map +1 -1
- package/dist/curation/staged-actions.d.ts +12 -1
- package/dist/curation/staged-actions.d.ts.map +1 -1
- package/dist/curation/staged-actions.js +123 -48
- package/dist/curation/staged-actions.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-blast.d.ts +4 -0
- package/dist/curation/tension-blast.d.ts.map +1 -1
- package/dist/curation/tension-blast.js +9 -0
- package/dist/curation/tension-blast.js.map +1 -1
- package/dist/curation/tension.d.ts +5 -2
- package/dist/curation/tension.d.ts.map +1 -1
- package/dist/curation/tension.js +68 -25
- 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 +14 -0
- package/dist/curation/vault-docs.js.map +1 -1
- package/dist/eval/generate.d.ts.map +1 -1
- package/dist/eval/generate.js +38 -5
- package/dist/eval/generate.js.map +1 -1
- package/dist/eval/index.d.ts +1 -0
- package/dist/eval/index.d.ts.map +1 -1
- package/dist/eval/index.js +249 -50
- package/dist/eval/index.js.map +1 -1
- package/dist/eval/llm-openrouter.d.ts.map +1 -1
- package/dist/eval/llm-openrouter.js +158 -65
- package/dist/eval/llm-openrouter.js.map +1 -1
- 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/schema.d.ts.map +1 -1
- package/dist/frontmatter/schema.js +19 -1
- 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/interview/index.d.ts +7 -0
- package/dist/interview/index.d.ts.map +1 -0
- package/dist/interview/index.js +219 -0
- package/dist/interview/index.js.map +1 -0
- package/dist/interview/questions.d.ts +18 -0
- package/dist/interview/questions.d.ts.map +1 -0
- package/dist/interview/questions.js +169 -0
- package/dist/interview/questions.js.map +1 -0
- package/dist/interview/transcript.d.ts +23 -0
- package/dist/interview/transcript.d.ts.map +1 -0
- package/dist/interview/transcript.js +155 -0
- package/dist/interview/transcript.js.map +1 -0
- 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 +117 -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 +115 -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 +159 -0
- package/dist/okf/index.js.map +1 -0
- package/dist/okf/map.d.ts +27 -0
- package/dist/okf/map.d.ts.map +1 -0
- package/dist/okf/map.js +327 -0
- package/dist/okf/map.js.map +1 -0
- package/dist/okf/types.d.ts +13 -0
- package/dist/okf/types.d.ts.map +1 -0
- package/dist/okf/types.js +82 -0
- package/dist/okf/types.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 +19 -0
- package/dist/search/hybrid.d.ts.map +1 -1
- package/dist/search/hybrid.js +60 -15
- package/dist/search/hybrid.js.map +1 -1
- package/dist/search/reindex.d.ts +4 -0
- package/dist/search/reindex.d.ts.map +1 -1
- package/dist/search/reindex.js +64 -14
- 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 +29 -27
- 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 +89 -14
- package/dist/server.js.map +1 -1
- package/dist/sleep/index.d.ts +2 -0
- package/dist/sleep/index.d.ts.map +1 -1
- package/dist/sleep/index.js +212 -21
- package/dist/sleep/index.js.map +1 -1
- package/dist/sleep/report.d.ts +3 -0
- package/dist/sleep/report.d.ts.map +1 -1
- package/dist/sleep/report.js +52 -0
- package/dist/sleep/report.js.map +1 -1
- 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 +16 -0
- package/dist/storage/index-db.d.ts.map +1 -1
- package/dist/storage/index-db.js +95 -5
- package/dist/storage/index-db.js.map +1 -1
- package/dist/storage/local.d.ts +1 -0
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +12 -0
- 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 +53 -15
- 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 +27 -21
- 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 +211 -76
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/receipt.d.ts.map +1 -1
- package/dist/tools/receipt.js +16 -7
- package/dist/tools/receipt.js.map +1 -1
- package/dist/tools/search.d.ts +1 -0
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +147 -5
- 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 +225 -22
- package/dist/tools/staged-actions.js.map +1 -1
- package/dist/tools/themes.d.ts +8 -0
- package/dist/tools/themes.d.ts.map +1 -1
- package/dist/tools/themes.js +208 -122
- 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/write.d.ts +9 -1
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +596 -174
- package/dist/tools/write.js.map +1 -1
- package/dist/utils/config.d.ts +52 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +394 -24
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/dates.d.ts +1 -0
- package/dist/utils/dates.d.ts.map +1 -1
- package/dist/utils/dates.js +5 -0
- package/dist/utils/dates.js.map +1 -1
- 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 +4 -1
- package/dist/utils/vault-gitignore.js.map +1 -1
- package/package.json +21 -1
- package/templates/config.yaml +8 -0
package/dist/tools/write.js
CHANGED
|
@@ -11,21 +11,29 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
11
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
|
// ---------------------------------------------------------------------------
|
|
@@ -112,6 +120,10 @@ export function serializeDocument(fm, body, extensions = [], raw = {}) {
|
|
|
112
120
|
updated: fm.updated,
|
|
113
121
|
updated_by: fm.updated_by,
|
|
114
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,
|
|
115
127
|
sources: fm.sources,
|
|
116
128
|
superseded_by: fm.superseded_by,
|
|
117
129
|
ttl_days: fm.ttl_days,
|
|
@@ -224,6 +236,7 @@ async function performWrite(params) {
|
|
|
224
236
|
file: params.relPath,
|
|
225
237
|
agent: params.agent,
|
|
226
238
|
...(params.principal ? { principal: params.principal } : {}),
|
|
239
|
+
...(params.runId ? { run_id: params.runId } : {}),
|
|
227
240
|
action: "rejected_stale",
|
|
228
241
|
reason: `stale: base_version ${shortHash(params.baseVersion)} != ` +
|
|
229
242
|
`current ${currentHash ? shortHash(currentHash) : "<absent>"}`,
|
|
@@ -254,9 +267,21 @@ async function performWrite(params) {
|
|
|
254
267
|
file: params.relPath,
|
|
255
268
|
agent: params.agent,
|
|
256
269
|
...(params.principal ? { principal: params.principal } : {}),
|
|
270
|
+
...(params.runId ? { run_id: params.runId } : {}),
|
|
271
|
+
...(params.bodyChanged !== undefined ? { body_changed: params.bodyChanged } : {}),
|
|
257
272
|
action: params.action,
|
|
258
273
|
frontmatter_diff: frontmatterDiff(params.oldFrontmatter, params.newFrontmatter),
|
|
259
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
|
+
}
|
|
260
285
|
return ok({
|
|
261
286
|
path: params.relPath,
|
|
262
287
|
action: params.action,
|
|
@@ -300,12 +325,176 @@ function readBaseVersion(args, tool) {
|
|
|
300
325
|
}
|
|
301
326
|
return ok(v.length === 0 ? undefined : v);
|
|
302
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
|
+
}
|
|
341
|
+
// The uniform RBAC write gate. One helper because the denial string is part
|
|
342
|
+
// of the tool contract — every write tool emits the identical message shape,
|
|
343
|
+
// and it must never leak more than the collection name.
|
|
344
|
+
function requireWriteAccess(access, collection) {
|
|
345
|
+
if (access && !canWrite(access.role, collection)) {
|
|
346
|
+
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` + `collection '${collection}'`));
|
|
347
|
+
}
|
|
348
|
+
return ok(undefined);
|
|
349
|
+
}
|
|
350
|
+
async function loadTargetDocument(vaultRoot, rawPath, tool) {
|
|
351
|
+
const resolved = resolveVaultPath(vaultRoot, rawPath);
|
|
352
|
+
if (!resolved.ok)
|
|
353
|
+
return resolved;
|
|
354
|
+
const existing = await readFile(resolved.value.absPath);
|
|
355
|
+
if (!existing.ok) {
|
|
356
|
+
return err(new Error(`${tool}: document not found: ${rawPath}`));
|
|
357
|
+
}
|
|
358
|
+
const parsed = parseDocument(existing.value);
|
|
359
|
+
if (!parsed.ok)
|
|
360
|
+
return parsed;
|
|
361
|
+
const config = loadConfig(vaultRoot);
|
|
362
|
+
if (!config.ok)
|
|
363
|
+
return config;
|
|
364
|
+
return ok({
|
|
365
|
+
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
366
|
+
// relPath, never the raw caller string: aliased spellings of one file
|
|
367
|
+
// must contend on one lock (#127/#128).
|
|
368
|
+
relPath: resolved.value.relPath,
|
|
369
|
+
absPath: resolved.value.absPath,
|
|
370
|
+
parsed: parsed.value,
|
|
371
|
+
config: config.value,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// The shared performWrite tail for frontmatter-only mutations: body preserved
|
|
375
|
+
// verbatim (bodyChanged false), file text re-serialized from the new
|
|
376
|
+
// frontmatter over the existing content.
|
|
377
|
+
function performFrontmatterWrite(opts) {
|
|
378
|
+
const { target } = opts;
|
|
379
|
+
return performWrite({
|
|
380
|
+
vaultRoot: opts.vaultRoot,
|
|
381
|
+
relPath: target.relPath,
|
|
382
|
+
absPath: target.absPath,
|
|
383
|
+
agent: opts.agent,
|
|
384
|
+
tool: opts.tool,
|
|
385
|
+
action: opts.action,
|
|
386
|
+
fileText: serializeDocument(opts.newFrontmatter, target.parsed.content, target.config.schemaExtensions, target.parsed.raw),
|
|
387
|
+
newFrontmatter: opts.newFrontmatter,
|
|
388
|
+
oldFrontmatter: target.parsed.frontmatter,
|
|
389
|
+
validation: target.parsed.validation,
|
|
390
|
+
commitMessage: opts.commitMessage,
|
|
391
|
+
autoCommit: target.config.autoCommit,
|
|
392
|
+
gitDir: target.config.gitDir,
|
|
393
|
+
baseVersion: opts.baseVersion,
|
|
394
|
+
shadowMode: target.config.shadowMode,
|
|
395
|
+
principal: opts.access?.user,
|
|
396
|
+
bodyChanged: false,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
303
399
|
// ---------------------------------------------------------------------------
|
|
304
400
|
// vault_write
|
|
305
401
|
// ---------------------------------------------------------------------------
|
|
306
402
|
// Creates a new document or overwrites an existing one. The caller supplies
|
|
307
403
|
// the full frontmatter; `updated` and `updated_by` are always stamped by the
|
|
308
404
|
// server, and `created` is preserved from the existing document on an update.
|
|
405
|
+
// --- tier write-protection (#141) ------------------------------------------
|
|
406
|
+
//
|
|
407
|
+
// `tier: source` bodies are immutable to EVERY writer; `tier: manual` bodies
|
|
408
|
+
// only accept rewrites from a human:* identity. The escape hatch is
|
|
409
|
+
// deliberately NOT an inline force flag — a refusal that names a bypass
|
|
410
|
+
// parameter teaches the calling model to pass it reflexively. Instead the
|
|
411
|
+
// caller must demote the tier first (vault_set_tier, reason required, change
|
|
412
|
+
// provenance-logged), then write. Frontmatter-only writes pass unchanged, so
|
|
413
|
+
// curation tools (promote, deprecate, supersede, set_confidence) and tag
|
|
414
|
+
// edits keep working on tiered docs.
|
|
415
|
+
// Body equality up to a leading-newline / trailing-whitespace difference:
|
|
416
|
+
// gray-matter's parsed content starts with the newline after the frontmatter
|
|
417
|
+
// fence and serializeDocument re-adds one, so a byte comparison would flag
|
|
418
|
+
// every round-tripped body as changed.
|
|
419
|
+
function sameBody(a, b) {
|
|
420
|
+
const norm = (s) => s.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
421
|
+
return norm(a) === norm(b);
|
|
422
|
+
}
|
|
423
|
+
function checkTierGuard(args) {
|
|
424
|
+
const { tool, path, tier, oldBody, newBody, agent } = args;
|
|
425
|
+
if (tier !== "source" && tier !== "manual")
|
|
426
|
+
return ok(undefined);
|
|
427
|
+
if (sameBody(oldBody, newBody))
|
|
428
|
+
return ok(undefined);
|
|
429
|
+
if (tier === "source") {
|
|
430
|
+
return err(new Error(`${tool}: ${path} is tier 'source' — its body is immutable. If this ` +
|
|
431
|
+
`rewrite is deliberate, demote the tier first with vault_set_tier ` +
|
|
432
|
+
`(a reason is required and the change is logged), then write. ` +
|
|
433
|
+
`vault_append can add to it without a tier change.`));
|
|
434
|
+
}
|
|
435
|
+
if (!agent.startsWith("human:")) {
|
|
436
|
+
return err(new Error(`${tool}: ${path} is tier 'manual' — body rewrites require a ` +
|
|
437
|
+
`human:* identity (got '${agent}'). vault_append can add to it.`));
|
|
438
|
+
}
|
|
439
|
+
return ok(undefined);
|
|
440
|
+
}
|
|
441
|
+
// #4: the generative-domain documents an accumulation-domain doc references
|
|
442
|
+
// (typed `sources` entries plus body links), resolved against the indexed
|
|
443
|
+
// path universe. Advisory only — callers attach the result as
|
|
444
|
+
// domain_warnings; index unavailability degrades to silence.
|
|
445
|
+
function generativeDomainRefs(vaultRoot, doc, access) {
|
|
446
|
+
if (doc.domain !== "accumulation")
|
|
447
|
+
return null;
|
|
448
|
+
// Most accumulation writes reference nothing; skip the index entirely
|
|
449
|
+
// rather than loading every vault path to resolve an empty candidate set.
|
|
450
|
+
const localSources = doc.sources.filter((s) => !EXTERNAL_REF.test(s));
|
|
451
|
+
if (localSources.length === 0 && extractLinks(doc.body).length === 0)
|
|
452
|
+
return null;
|
|
453
|
+
const db = openIndexForAccessOrNull(vaultRoot);
|
|
454
|
+
if (!db)
|
|
455
|
+
return null;
|
|
456
|
+
try {
|
|
457
|
+
const indexes = buildPathIndexes(allDocumentPaths(db).map((p) => ({ path: p })));
|
|
458
|
+
const candidates = new Set(outgoingLinkTargets(doc.body, doc.relPath, indexes));
|
|
459
|
+
for (const raw of localSources) {
|
|
460
|
+
const target = resolveLink(raw, doc.relPath, indexes.byPath, indexes.byBasename);
|
|
461
|
+
if (target && target !== doc.relPath)
|
|
462
|
+
candidates.add(target);
|
|
463
|
+
}
|
|
464
|
+
if (candidates.size === 0)
|
|
465
|
+
return null;
|
|
466
|
+
const generative = getDocumentsByPaths(db, [...candidates])
|
|
467
|
+
.filter((d) => d.domain === "generative")
|
|
468
|
+
.map((d) => d.path)
|
|
469
|
+
// Vantage rule (#217, security review on #261): a warning names the
|
|
470
|
+
// RESOLVED path and discloses the target's domain — metadata about a
|
|
471
|
+
// doc the caller may not read, and resolveLink's basename fallback can
|
|
472
|
+
// reveal a location the caller never typed. Targets outside the
|
|
473
|
+
// caller's read scope are omitted entirely, the same rule lint's
|
|
474
|
+
// domainLeaks inherits from runLint's pre-filtered doc set.
|
|
475
|
+
.filter((p) => !access || sourceReadable(db, access, p))
|
|
476
|
+
.sort();
|
|
477
|
+
if (generative.length === 0)
|
|
478
|
+
return null;
|
|
479
|
+
return generative.map((p) => `${p} is generative-domain — speculative material referenced from accumulation canon`);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// This runs after performWrite returned ok — the write is durable. A
|
|
483
|
+
// query failure here (index rebuilt mid-query, locked db) must degrade
|
|
484
|
+
// to "no warnings", never surface as a failed write to the caller.
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
db.close();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// The #169 nudge, verbatim on every vault_write overwrite. One fixed string:
|
|
492
|
+
// the signal is the field's presence, and a stable text is grep-able in
|
|
493
|
+
// agent traces.
|
|
494
|
+
const SUPERSEDE_HINT = "This write replaced an existing document in place. If it records a changed " +
|
|
495
|
+
"fact, prefer vault_supersede: it preserves the prior version and mints the " +
|
|
496
|
+
"superseded_by edge instead of erasing lineage. Advisory only — this write " +
|
|
497
|
+
"has already landed.";
|
|
309
498
|
export async function vaultWrite(vaultRoot, args, access) {
|
|
310
499
|
const ready = requireIndexReady();
|
|
311
500
|
if (!ready.ok)
|
|
@@ -319,6 +508,9 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
319
508
|
const baseVersion = readBaseVersion(args, "vault_write");
|
|
320
509
|
if (!baseVersion.ok)
|
|
321
510
|
return baseVersion;
|
|
511
|
+
const runId = readRunId(args, "vault_write");
|
|
512
|
+
if (!runId.ok)
|
|
513
|
+
return runId;
|
|
322
514
|
const body = args.body;
|
|
323
515
|
if (typeof body !== "string") {
|
|
324
516
|
return err(new Error("vault_write requires a string 'body' argument"));
|
|
@@ -336,19 +528,87 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
336
528
|
// collection is allowed to differ from the physical dir (e.g. a draft staged
|
|
337
529
|
// in _drafts/ that declares its destination collection); it just never widens
|
|
338
530
|
// access.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
|
|
343
|
-
`collection '${collection}'`));
|
|
344
|
-
}
|
|
345
|
-
}
|
|
531
|
+
const writeGate = requireWriteAccess(access, targetCollection(vaultRoot, path.value));
|
|
532
|
+
if (!writeGate.ok)
|
|
533
|
+
return writeGate;
|
|
346
534
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
347
535
|
if (!resolved.ok)
|
|
348
536
|
return resolved;
|
|
537
|
+
// #235: a propose-only role's write lands as a staged `write` proposal, not
|
|
538
|
+
// a mutation. Coerced EARLY — before file I/O, tier guards, hooks, or
|
|
539
|
+
// validation — because all of that runs for real at ratify time when
|
|
540
|
+
// vault_ratify dispatches the payload back through this tool with the
|
|
541
|
+
// ratifier's access. The write grant above still scopes which collections
|
|
542
|
+
// the role may propose into. Inter-proposal conflicts are checked the same
|
|
543
|
+
// way vault_stage_action checks them: both proposals stay pending, a
|
|
544
|
+
// tension is logged, the result names the contenders. Staged under the
|
|
545
|
+
// CANONICAL relPath, matching vault_stage_action — a raw caller spelling
|
|
546
|
+
// (`pricing/./x.md`) would dodge the exact-string conflict match and the
|
|
547
|
+
// ratify gate's existing-doc lookup (#127/#128 rule).
|
|
548
|
+
if (access && isProposeOnly(access.role)) {
|
|
549
|
+
// The validation report is REAL, not fabricated: run the payload through
|
|
550
|
+
// the same merge-then-validate the eventual dispatch will apply (merged
|
|
551
|
+
// under any existing frontmatter, server-stamped fields defaulted), so a
|
|
552
|
+
// propose-only agent gets its schema feedback NOW instead of at ratify
|
|
553
|
+
// time up to ttl_days later. Advisory here — staging proceeds regardless;
|
|
554
|
+
// the blocking check runs for real when vault_ratify dispatches.
|
|
555
|
+
const proposeConfig = loadConfig(vaultRoot);
|
|
556
|
+
if (!proposeConfig.ok)
|
|
557
|
+
return proposeConfig;
|
|
558
|
+
let previewRaw = { ...rawFrontmatter };
|
|
559
|
+
const onDisk = await readFile(resolved.value.absPath);
|
|
560
|
+
if (onDisk.ok) {
|
|
561
|
+
const parsedExisting = parseDocument(onDisk.value);
|
|
562
|
+
if (parsedExisting.ok) {
|
|
563
|
+
const merged = { ...parsedExisting.value.raw };
|
|
564
|
+
for (const [key, value] of Object.entries(rawFrontmatter)) {
|
|
565
|
+
if (value === null)
|
|
566
|
+
delete merged[key];
|
|
567
|
+
else
|
|
568
|
+
merged[key] = value;
|
|
569
|
+
}
|
|
570
|
+
previewRaw = merged;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (previewRaw.updated === undefined || previewRaw.updated === null) {
|
|
574
|
+
previewRaw.updated = todayISO();
|
|
575
|
+
}
|
|
576
|
+
if (previewRaw.updated_by === undefined || previewRaw.updated_by === null) {
|
|
577
|
+
previewRaw.updated_by = agent.value;
|
|
578
|
+
}
|
|
579
|
+
const preview = validateFrontmatter(previewRaw, proposeConfig.value.schemaExtensions);
|
|
580
|
+
const staged = await stageActionWithConflictCheck(vaultRoot, {
|
|
581
|
+
actionType: "write",
|
|
582
|
+
targetPath: resolved.value.relPath,
|
|
583
|
+
proposedBy: agent.value,
|
|
584
|
+
rationale: typeof args.reason === "string" && args.reason.trim().length > 0
|
|
585
|
+
? args.reason.trim()
|
|
586
|
+
: `propose-only role '${access.roleName}': write staged for ratification`,
|
|
587
|
+
proposedDiff: { frontmatter: rawFrontmatter, body },
|
|
588
|
+
...(runId.value !== undefined ? { runId: runId.value } : {}),
|
|
589
|
+
});
|
|
590
|
+
if (!staged.ok)
|
|
591
|
+
return staged;
|
|
592
|
+
return ok({
|
|
593
|
+
path: resolved.value.relPath,
|
|
594
|
+
action: "staged",
|
|
595
|
+
commit: null,
|
|
596
|
+
committed: false,
|
|
597
|
+
status: "pending",
|
|
598
|
+
updated: todayISO(),
|
|
599
|
+
validation: preview.report,
|
|
600
|
+
indexUpdated: false,
|
|
601
|
+
staged_id: staged.value.id,
|
|
602
|
+
expires_at: staged.value.expires_at,
|
|
603
|
+
conflicts_with: staged.value.conflicts_with,
|
|
604
|
+
tension_id: staged.value.tension_id,
|
|
605
|
+
...(staged.value.tension_error ? { tension_error: staged.value.tension_error } : {}),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
349
608
|
const existing = await readFile(resolved.value.absPath);
|
|
350
609
|
let oldFrontmatter = null;
|
|
351
610
|
let oldRaw = null;
|
|
611
|
+
let oldContent = "";
|
|
352
612
|
if (existing.ok) {
|
|
353
613
|
const parsed = parseDocument(existing.value);
|
|
354
614
|
if (!parsed.ok) {
|
|
@@ -363,9 +623,22 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
363
623
|
}
|
|
364
624
|
oldFrontmatter = parsed.value.frontmatter;
|
|
365
625
|
oldRaw = parsed.value.raw;
|
|
626
|
+
oldContent = parsed.value.content;
|
|
366
627
|
}
|
|
367
628
|
const isUpdate = oldFrontmatter !== null;
|
|
368
629
|
const hookOperation = isUpdate ? "update" : "create";
|
|
630
|
+
if (isUpdate && oldFrontmatter) {
|
|
631
|
+
const guard = checkTierGuard({
|
|
632
|
+
tool: "vault_write",
|
|
633
|
+
path: path.value,
|
|
634
|
+
tier: oldFrontmatter.tier,
|
|
635
|
+
oldBody: oldContent,
|
|
636
|
+
newBody: body,
|
|
637
|
+
agent: agent.value,
|
|
638
|
+
});
|
|
639
|
+
if (!guard.ok)
|
|
640
|
+
return guard;
|
|
641
|
+
}
|
|
369
642
|
// On update, merge the document's existing frontmatter under the payload, so
|
|
370
643
|
// a tool-mediated write never silently drops a field the author put there
|
|
371
644
|
// (#113). `oldRaw` is the frontmatter exactly as parsed — built-in fields,
|
|
@@ -388,6 +661,19 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
388
661
|
delete rawFrontmatter[key];
|
|
389
662
|
Object.assign(rawFrontmatter, merged);
|
|
390
663
|
}
|
|
664
|
+
// #141: a protected doc's tier only changes via vault_set_tier. Allowing it
|
|
665
|
+
// here would let a frontmatter-only write (which sameBody waves through)
|
|
666
|
+
// demote the tier — dodging set_tier's reason requirement and manual's
|
|
667
|
+
// human gate — and then rewrite the body in a second call.
|
|
668
|
+
if (isUpdate &&
|
|
669
|
+
oldFrontmatter &&
|
|
670
|
+
(oldFrontmatter.tier === "source" || oldFrontmatter.tier === "manual")) {
|
|
671
|
+
if (rawFrontmatter.tier !== oldFrontmatter.tier) {
|
|
672
|
+
return err(new Error(`vault_write: ${path.value} is tier '${oldFrontmatter.tier}' — changing ` +
|
|
673
|
+
`its tier requires vault_set_tier (a reason is required` +
|
|
674
|
+
`${oldFrontmatter.tier === "manual" ? ", and a human:* identity" : ""}).`));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
391
677
|
// Config-declared schema extensions participate in validation and
|
|
392
678
|
// serialization. A malformed config fails the write loudly, matching the
|
|
393
679
|
// server's loud-config contract.
|
|
@@ -454,7 +740,7 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
454
740
|
updated: todayISO(),
|
|
455
741
|
updated_by: agent.value,
|
|
456
742
|
};
|
|
457
|
-
|
|
743
|
+
const written = await performWrite({
|
|
458
744
|
vaultRoot,
|
|
459
745
|
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
460
746
|
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
@@ -474,6 +760,31 @@ export async function vaultWrite(vaultRoot, args, access) {
|
|
|
474
760
|
baseVersion: baseVersion.value,
|
|
475
761
|
shadowMode: config.value.shadowMode,
|
|
476
762
|
principal: access?.user,
|
|
763
|
+
...(runId.value !== undefined ? { runId: runId.value } : {}),
|
|
764
|
+
bodyChanged: !isUpdate || !sameBody(oldContent, body),
|
|
765
|
+
});
|
|
766
|
+
// #169: an in-place overwrite destroys the prior version's lineage, and
|
|
767
|
+
// daftari HAS the preserve-not-overwrite primitive — steer toward it at
|
|
768
|
+
// the moment of overwrite, the decay banner's channel: additive, advisory,
|
|
769
|
+
// never blocking, never auto-minting an edge. The agent still chooses.
|
|
770
|
+
// Attached here (not in performWrite) so vault_supersede and the other
|
|
771
|
+
// lifecycle tools never nudge about themselves. A shadow-mode result is
|
|
772
|
+
// excluded: nothing landed and nothing was replaced, so the hints' text
|
|
773
|
+
// would be false. #4's domain warnings ride the same channel.
|
|
774
|
+
if (!written.ok || written.value.shadow)
|
|
775
|
+
return written;
|
|
776
|
+
const warnings = generativeDomainRefs(vaultRoot, {
|
|
777
|
+
domain: stamped.domain,
|
|
778
|
+
sources: stamped.sources ?? [],
|
|
779
|
+
body,
|
|
780
|
+
relPath: resolved.value.relPath,
|
|
781
|
+
}, access);
|
|
782
|
+
if (!isUpdate && warnings === null)
|
|
783
|
+
return written;
|
|
784
|
+
return ok({
|
|
785
|
+
...written.value,
|
|
786
|
+
...(isUpdate ? { supersede_hint: SUPERSEDE_HINT } : {}),
|
|
787
|
+
...(warnings ? { domain_warnings: warnings } : {}),
|
|
477
788
|
});
|
|
478
789
|
}
|
|
479
790
|
// ---------------------------------------------------------------------------
|
|
@@ -497,6 +808,12 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
497
808
|
const baseVersion = readBaseVersion(args, "vault_append");
|
|
498
809
|
if (!baseVersion.ok)
|
|
499
810
|
return baseVersion;
|
|
811
|
+
const runId = readRunId(args, "vault_append");
|
|
812
|
+
if (!runId.ok)
|
|
813
|
+
return runId;
|
|
814
|
+
const proposeGate = denyIfProposeOnly(access, "vault_append");
|
|
815
|
+
if (!proposeGate.ok)
|
|
816
|
+
return proposeGate;
|
|
500
817
|
const resolved = resolveVaultPath(vaultRoot, path.value);
|
|
501
818
|
if (!resolved.ok)
|
|
502
819
|
return resolved;
|
|
@@ -512,13 +829,9 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
512
829
|
return config;
|
|
513
830
|
const extensions = config.value.schemaExtensions;
|
|
514
831
|
const oldFrontmatter = parsed.value.frontmatter;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
|
|
519
|
-
`collection '${collection}'`));
|
|
520
|
-
}
|
|
521
|
-
}
|
|
832
|
+
const rbac = requireWriteAccess(access, collectionOf(path.value, oldFrontmatter));
|
|
833
|
+
if (!rbac.ok)
|
|
834
|
+
return rbac;
|
|
522
835
|
const newFrontmatter = {
|
|
523
836
|
...oldFrontmatter,
|
|
524
837
|
updated: todayISO(),
|
|
@@ -546,7 +859,7 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
546
859
|
const summary = appendIssues.map((i) => `${i.field}: ${i.message}`).join("; ");
|
|
547
860
|
return err(new Error(`invalid frontmatter: ${summary}`));
|
|
548
861
|
}
|
|
549
|
-
|
|
862
|
+
const appended = await performWrite({
|
|
550
863
|
vaultRoot,
|
|
551
864
|
// Lock key, provenance, and commit path are all keyed on the CANONICAL
|
|
552
865
|
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
@@ -566,7 +879,26 @@ export async function vaultAppend(vaultRoot, args, access) {
|
|
|
566
879
|
baseVersion: baseVersion.value,
|
|
567
880
|
shadowMode: config.value.shadowMode,
|
|
568
881
|
principal: access?.user,
|
|
882
|
+
...(runId.value !== undefined ? { runId: runId.value } : {}),
|
|
883
|
+
bodyChanged: true,
|
|
569
884
|
});
|
|
885
|
+
// #4: an appended section can introduce body links into generative-domain
|
|
886
|
+
// docs — same advisory channel as vault_write, scoped to what THIS append
|
|
887
|
+
// added: only the new section is scanned (a doc that already leaned on
|
|
888
|
+
// generative material warned at write time; re-warning on every later,
|
|
889
|
+
// unrelated append would drown the signal), and sources are skipped
|
|
890
|
+
// entirely because an append cannot change frontmatter.
|
|
891
|
+
if (!appended.ok || appended.value.shadow)
|
|
892
|
+
return appended;
|
|
893
|
+
const appendWarnings = generativeDomainRefs(vaultRoot, {
|
|
894
|
+
domain: newFrontmatter.domain,
|
|
895
|
+
sources: [],
|
|
896
|
+
body: section.value,
|
|
897
|
+
relPath: resolved.value.relPath,
|
|
898
|
+
}, access);
|
|
899
|
+
if (appendWarnings === null)
|
|
900
|
+
return appended;
|
|
901
|
+
return ok({ ...appended.value, domain_warnings: appendWarnings });
|
|
570
902
|
}
|
|
571
903
|
// ---------------------------------------------------------------------------
|
|
572
904
|
// vault_promote
|
|
@@ -578,6 +910,9 @@ export async function vaultPromote(vaultRoot, args, access) {
|
|
|
578
910
|
const ready = requireIndexReady();
|
|
579
911
|
if (!ready.ok)
|
|
580
912
|
return ready;
|
|
913
|
+
const proposeGate = denyIfProposeOnly(access, "vault_promote");
|
|
914
|
+
if (!proposeGate.ok)
|
|
915
|
+
return proposeGate;
|
|
581
916
|
const path = requireString(args, "path", "vault_promote");
|
|
582
917
|
if (!path.ok)
|
|
583
918
|
return path;
|
|
@@ -590,64 +925,42 @@ export async function vaultPromote(vaultRoot, args, access) {
|
|
|
590
925
|
if (access && !canPromote(access.role)) {
|
|
591
926
|
return err(new Error(`access denied: role '${access.roleName}' cannot promote documents`));
|
|
592
927
|
}
|
|
593
|
-
const
|
|
594
|
-
if (!
|
|
595
|
-
return
|
|
596
|
-
const
|
|
597
|
-
if (!existing.ok) {
|
|
598
|
-
return err(new Error(`vault_promote: document not found: ${path.value}`));
|
|
599
|
-
}
|
|
600
|
-
const parsed = parseDocument(existing.value);
|
|
601
|
-
if (!parsed.ok)
|
|
602
|
-
return parsed;
|
|
603
|
-
const config = loadConfig(vaultRoot);
|
|
604
|
-
if (!config.ok)
|
|
605
|
-
return config;
|
|
606
|
-
const extensions = config.value.schemaExtensions;
|
|
607
|
-
const oldFrontmatter = parsed.value.frontmatter;
|
|
928
|
+
const target = await loadTargetDocument(vaultRoot, path.value, "vault_promote");
|
|
929
|
+
if (!target.ok)
|
|
930
|
+
return target;
|
|
931
|
+
const oldFrontmatter = target.value.parsed.frontmatter;
|
|
608
932
|
if (oldFrontmatter.status !== "draft") {
|
|
609
933
|
return err(new Error(`vault_promote: only draft documents can be promoted ` +
|
|
610
934
|
`(${path.value} is '${oldFrontmatter.status}')`));
|
|
611
935
|
}
|
|
612
|
-
if (!
|
|
613
|
-
const summary =
|
|
936
|
+
if (!target.value.parsed.validation.valid) {
|
|
937
|
+
const summary = target.value.parsed.validation.issues
|
|
614
938
|
.map((i) => `${i.field}: ${i.message}`)
|
|
615
939
|
.join("; ");
|
|
616
940
|
return err(new Error(`vault_promote: frontmatter is incomplete: ${summary}`));
|
|
617
941
|
}
|
|
618
942
|
// `confidence set` — the document must declare a confidence explicitly, not
|
|
619
943
|
// ride on the validator's default.
|
|
620
|
-
const rawConfidence =
|
|
944
|
+
const rawConfidence = target.value.parsed.raw.confidence;
|
|
621
945
|
if (typeof rawConfidence !== "string" ||
|
|
622
946
|
!CONFIDENCES.includes(rawConfidence)) {
|
|
623
947
|
return err(new Error("vault_promote: confidence must be set before promotion"));
|
|
624
948
|
}
|
|
625
|
-
|
|
626
|
-
...oldFrontmatter,
|
|
627
|
-
status: "canonical",
|
|
628
|
-
updated: todayISO(),
|
|
629
|
-
updated_by: agent.value,
|
|
630
|
-
};
|
|
631
|
-
return performWrite({
|
|
949
|
+
return performFrontmatterWrite({
|
|
632
950
|
vaultRoot,
|
|
633
|
-
|
|
634
|
-
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
635
|
-
// spellings of one file must contend on one lock (#127/#128).
|
|
636
|
-
relPath: resolved.value.relPath,
|
|
637
|
-
absPath: resolved.value.absPath,
|
|
951
|
+
target: target.value,
|
|
638
952
|
agent: agent.value,
|
|
639
953
|
tool: "vault_promote",
|
|
640
954
|
action: "promote",
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
955
|
+
newFrontmatter: {
|
|
956
|
+
...oldFrontmatter,
|
|
957
|
+
status: "canonical",
|
|
958
|
+
updated: todayISO(),
|
|
959
|
+
updated_by: agent.value,
|
|
960
|
+
},
|
|
645
961
|
commitMessage: `vault_promote: ${path.value} draft→canonical by ${agent.value}`,
|
|
646
|
-
autoCommit: config.value.autoCommit,
|
|
647
|
-
gitDir: config.value.gitDir,
|
|
648
962
|
baseVersion: baseVersion.value,
|
|
649
|
-
|
|
650
|
-
principal: access?.user,
|
|
963
|
+
access,
|
|
651
964
|
});
|
|
652
965
|
}
|
|
653
966
|
// ---------------------------------------------------------------------------
|
|
@@ -660,6 +973,9 @@ export async function vaultDeprecate(vaultRoot, args, access) {
|
|
|
660
973
|
const ready = requireIndexReady();
|
|
661
974
|
if (!ready.ok)
|
|
662
975
|
return ready;
|
|
976
|
+
const proposeGate = denyIfProposeOnly(access, "vault_deprecate");
|
|
977
|
+
if (!proposeGate.ok)
|
|
978
|
+
return proposeGate;
|
|
663
979
|
const path = requireString(args, "path", "vault_deprecate");
|
|
664
980
|
if (!path.ok)
|
|
665
981
|
return path;
|
|
@@ -679,56 +995,30 @@ export async function vaultDeprecate(vaultRoot, args, access) {
|
|
|
679
995
|
}
|
|
680
996
|
supersededBy = args.superseded_by;
|
|
681
997
|
}
|
|
682
|
-
const
|
|
683
|
-
if (!
|
|
684
|
-
return
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (!parsed.ok)
|
|
691
|
-
return parsed;
|
|
692
|
-
const config = loadConfig(vaultRoot);
|
|
693
|
-
if (!config.ok)
|
|
694
|
-
return config;
|
|
695
|
-
const extensions = config.value.schemaExtensions;
|
|
696
|
-
const oldFrontmatter = parsed.value.frontmatter;
|
|
697
|
-
if (access) {
|
|
698
|
-
const collection = collectionOf(path.value, oldFrontmatter);
|
|
699
|
-
if (!canWrite(access.role, collection)) {
|
|
700
|
-
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
|
|
701
|
-
`collection '${collection}'`));
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
const newFrontmatter = {
|
|
705
|
-
...oldFrontmatter,
|
|
706
|
-
status: "deprecated",
|
|
707
|
-
superseded_by: supersededBy,
|
|
708
|
-
updated: todayISO(),
|
|
709
|
-
updated_by: agent.value,
|
|
710
|
-
};
|
|
711
|
-
return performWrite({
|
|
998
|
+
const target = await loadTargetDocument(vaultRoot, path.value, "vault_deprecate");
|
|
999
|
+
if (!target.ok)
|
|
1000
|
+
return target;
|
|
1001
|
+
const oldFrontmatter = target.value.parsed.frontmatter;
|
|
1002
|
+
const rbac = requireWriteAccess(access, collectionOf(path.value, oldFrontmatter));
|
|
1003
|
+
if (!rbac.ok)
|
|
1004
|
+
return rbac;
|
|
1005
|
+
return performFrontmatterWrite({
|
|
712
1006
|
vaultRoot,
|
|
713
|
-
|
|
714
|
-
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
715
|
-
// spellings of one file must contend on one lock (#127/#128).
|
|
716
|
-
relPath: resolved.value.relPath,
|
|
717
|
-
absPath: resolved.value.absPath,
|
|
1007
|
+
target: target.value,
|
|
718
1008
|
agent: agent.value,
|
|
719
1009
|
tool: "vault_deprecate",
|
|
720
1010
|
action: "deprecate",
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1011
|
+
newFrontmatter: {
|
|
1012
|
+
...oldFrontmatter,
|
|
1013
|
+
status: "deprecated",
|
|
1014
|
+
superseded_by: supersededBy,
|
|
1015
|
+
updated: todayISO(),
|
|
1016
|
+
updated_by: agent.value,
|
|
1017
|
+
},
|
|
725
1018
|
commitMessage: `vault_deprecate: ${path.value} by ${agent.value} — ${reason.value}` +
|
|
726
1019
|
(supersededBy ? ` (superseded by ${supersededBy})` : ""),
|
|
727
|
-
autoCommit: config.value.autoCommit,
|
|
728
|
-
gitDir: config.value.gitDir,
|
|
729
1020
|
baseVersion: baseVersion.value,
|
|
730
|
-
|
|
731
|
-
principal: access?.user,
|
|
1021
|
+
access,
|
|
732
1022
|
});
|
|
733
1023
|
}
|
|
734
1024
|
// ---------------------------------------------------------------------------
|
|
@@ -742,6 +1032,9 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
742
1032
|
const ready = requireIndexReady();
|
|
743
1033
|
if (!ready.ok)
|
|
744
1034
|
return ready;
|
|
1035
|
+
const proposeGate = denyIfProposeOnly(access, "vault_set_confidence");
|
|
1036
|
+
if (!proposeGate.ok)
|
|
1037
|
+
return proposeGate;
|
|
745
1038
|
const path = requireString(args, "path", "vault_set_confidence");
|
|
746
1039
|
if (!path.ok)
|
|
747
1040
|
return path;
|
|
@@ -760,28 +1053,13 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
760
1053
|
const baseVersion = readBaseVersion(args, "vault_set_confidence");
|
|
761
1054
|
if (!baseVersion.ok)
|
|
762
1055
|
return baseVersion;
|
|
763
|
-
const
|
|
764
|
-
if (!
|
|
765
|
-
return
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const parsed = parseDocument(existing.value);
|
|
771
|
-
if (!parsed.ok)
|
|
772
|
-
return parsed;
|
|
773
|
-
const config = loadConfig(vaultRoot);
|
|
774
|
-
if (!config.ok)
|
|
775
|
-
return config;
|
|
776
|
-
const extensions = config.value.schemaExtensions;
|
|
777
|
-
const oldFrontmatter = parsed.value.frontmatter;
|
|
778
|
-
if (access) {
|
|
779
|
-
const collection = collectionOf(path.value, oldFrontmatter);
|
|
780
|
-
if (!canWrite(access.role, collection)) {
|
|
781
|
-
return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
|
|
782
|
-
`collection '${collection}'`));
|
|
783
|
-
}
|
|
784
|
-
}
|
|
1056
|
+
const target = await loadTargetDocument(vaultRoot, path.value, "vault_set_confidence");
|
|
1057
|
+
if (!target.ok)
|
|
1058
|
+
return target;
|
|
1059
|
+
const oldFrontmatter = target.value.parsed.frontmatter;
|
|
1060
|
+
const rbac = requireWriteAccess(access, collectionOf(path.value, oldFrontmatter));
|
|
1061
|
+
if (!rbac.ok)
|
|
1062
|
+
return rbac;
|
|
785
1063
|
// No-op guard: a confidence already at the target would churn a commit for no
|
|
786
1064
|
// change. Surface it as an error so a redundant staged confidence-up does not
|
|
787
1065
|
// silently no-op (and a caller learns the value was already set). Compare
|
|
@@ -790,40 +1068,107 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
|
|
|
790
1068
|
// validated value would wrongly reject set_confidence(…, "low") on a doc that
|
|
791
1069
|
// never declared one and never write the field (the trap vault_promote dodges
|
|
792
1070
|
// the same way, via parsed.value.raw.confidence).
|
|
793
|
-
const rawConfidence =
|
|
1071
|
+
const rawConfidence = target.value.parsed.raw.confidence;
|
|
794
1072
|
const currentConfidence = typeof rawConfidence === "string" && CONFIDENCES.includes(rawConfidence)
|
|
795
1073
|
? rawConfidence
|
|
796
1074
|
: undefined;
|
|
797
1075
|
if (currentConfidence === confidence.value) {
|
|
798
1076
|
return err(new Error(`vault_set_confidence: ${path.value} confidence is already '${confidence.value}'`));
|
|
799
1077
|
}
|
|
800
|
-
|
|
801
|
-
...oldFrontmatter,
|
|
802
|
-
confidence: confidence.value,
|
|
803
|
-
updated: todayISO(),
|
|
804
|
-
updated_by: agent.value,
|
|
805
|
-
};
|
|
806
|
-
return performWrite({
|
|
1078
|
+
return performFrontmatterWrite({
|
|
807
1079
|
vaultRoot,
|
|
808
|
-
|
|
809
|
-
// relPath (resolved.value.relPath), never the raw caller string: aliased
|
|
810
|
-
// spellings of one file must contend on one lock (#127/#128).
|
|
811
|
-
relPath: resolved.value.relPath,
|
|
812
|
-
absPath: resolved.value.absPath,
|
|
1080
|
+
target: target.value,
|
|
813
1081
|
agent: agent.value,
|
|
814
1082
|
tool: "vault_set_confidence",
|
|
815
1083
|
action: "confidence-set",
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1084
|
+
newFrontmatter: {
|
|
1085
|
+
...oldFrontmatter,
|
|
1086
|
+
confidence: confidence.value,
|
|
1087
|
+
updated: todayISO(),
|
|
1088
|
+
updated_by: agent.value,
|
|
1089
|
+
},
|
|
820
1090
|
commitMessage: `vault_set_confidence: ${path.value} ${oldFrontmatter.confidence}→${confidence.value} ` +
|
|
821
1091
|
`by ${agent.value} — ${reason.value}`,
|
|
822
|
-
autoCommit: config.value.autoCommit,
|
|
823
|
-
gitDir: config.value.gitDir,
|
|
824
1092
|
baseVersion: baseVersion.value,
|
|
825
|
-
|
|
826
|
-
|
|
1093
|
+
access,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
// ---------------------------------------------------------------------------
|
|
1097
|
+
// vault_set_tier (#141)
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
// Changes only a document's write-protection tier, leaving body and status
|
|
1100
|
+
// untouched. This is the escape hatch for the tier guards — a separate,
|
|
1101
|
+
// reason-carrying, provenance-logged act rather than an inline force flag on
|
|
1102
|
+
// the destructive call. Moving a doc AWAY from `manual` requires a human:*
|
|
1103
|
+
// identity (otherwise demote-then-write would bypass the consent boundary);
|
|
1104
|
+
// moving away from `source` is open to any identity — deliberate demotions
|
|
1105
|
+
// are allowed, and vault_lint surfaces them for review.
|
|
1106
|
+
export async function vaultSetTier(vaultRoot, args, access) {
|
|
1107
|
+
const ready = requireIndexReady();
|
|
1108
|
+
if (!ready.ok)
|
|
1109
|
+
return ready;
|
|
1110
|
+
const proposeGate = denyIfProposeOnly(access, "vault_set_tier");
|
|
1111
|
+
if (!proposeGate.ok)
|
|
1112
|
+
return proposeGate;
|
|
1113
|
+
const path = requireString(args, "path", "vault_set_tier");
|
|
1114
|
+
if (!path.ok)
|
|
1115
|
+
return path;
|
|
1116
|
+
const agent = requireString(args, "agent", "vault_set_tier");
|
|
1117
|
+
if (!agent.ok)
|
|
1118
|
+
return agent;
|
|
1119
|
+
const reason = requireString(args, "reason", "vault_set_tier");
|
|
1120
|
+
if (!reason.ok)
|
|
1121
|
+
return reason;
|
|
1122
|
+
const tier = requireString(args, "tier", "vault_set_tier");
|
|
1123
|
+
if (!tier.ok)
|
|
1124
|
+
return tier;
|
|
1125
|
+
if (!TIERS.includes(tier.value)) {
|
|
1126
|
+
return err(new Error(`vault_set_tier: 'tier' must be one of: ${TIERS.join(", ")}`));
|
|
1127
|
+
}
|
|
1128
|
+
const newTier = tier.value;
|
|
1129
|
+
const baseVersion = readBaseVersion(args, "vault_set_tier");
|
|
1130
|
+
if (!baseVersion.ok)
|
|
1131
|
+
return baseVersion;
|
|
1132
|
+
const target = await loadTargetDocument(vaultRoot, path.value, "vault_set_tier");
|
|
1133
|
+
if (!target.ok)
|
|
1134
|
+
return target;
|
|
1135
|
+
const oldFrontmatter = target.value.parsed.frontmatter;
|
|
1136
|
+
const rbac = requireWriteAccess(access, collectionOf(path.value, oldFrontmatter));
|
|
1137
|
+
if (!rbac.ok)
|
|
1138
|
+
return rbac;
|
|
1139
|
+
// The manual consent boundary: only a human identity may lift `manual`.
|
|
1140
|
+
if (oldFrontmatter.tier === "manual" &&
|
|
1141
|
+
newTier !== "manual" &&
|
|
1142
|
+
!agent.value.startsWith("human:")) {
|
|
1143
|
+
return err(new Error(`vault_set_tier: ${path.value} is tier 'manual' — moving it away from ` +
|
|
1144
|
+
`'manual' requires a human:* identity (got '${agent.value}').`));
|
|
1145
|
+
}
|
|
1146
|
+
// No-op guard, same shape as vault_set_confidence: compare against the raw
|
|
1147
|
+
// on-disk value so an invalid raw tier (validated down to null) can still be
|
|
1148
|
+
// set to any member.
|
|
1149
|
+
const rawTier = target.value.parsed.raw.tier;
|
|
1150
|
+
const currentTier = typeof rawTier === "string" && TIERS.includes(rawTier)
|
|
1151
|
+
? rawTier
|
|
1152
|
+
: undefined;
|
|
1153
|
+
if (currentTier === newTier) {
|
|
1154
|
+
return err(new Error(`vault_set_tier: ${path.value} tier is already '${newTier}'`));
|
|
1155
|
+
}
|
|
1156
|
+
return performFrontmatterWrite({
|
|
1157
|
+
vaultRoot,
|
|
1158
|
+
target: target.value,
|
|
1159
|
+
agent: agent.value,
|
|
1160
|
+
tool: "vault_set_tier",
|
|
1161
|
+
action: "tier-set",
|
|
1162
|
+
newFrontmatter: {
|
|
1163
|
+
...oldFrontmatter,
|
|
1164
|
+
tier: newTier,
|
|
1165
|
+
updated: todayISO(),
|
|
1166
|
+
updated_by: agent.value,
|
|
1167
|
+
},
|
|
1168
|
+
commitMessage: `vault_set_tier: ${path.value} ${oldFrontmatter.tier ?? "unset"}→${newTier} ` +
|
|
1169
|
+
`by ${agent.value} — ${reason.value}`,
|
|
1170
|
+
baseVersion: baseVersion.value,
|
|
1171
|
+
access,
|
|
827
1172
|
});
|
|
828
1173
|
}
|
|
829
1174
|
// ---------------------------------------------------------------------------
|
|
@@ -837,6 +1182,9 @@ export async function vaultSupersede(vaultRoot, args, access) {
|
|
|
837
1182
|
const ready = requireIndexReady();
|
|
838
1183
|
if (!ready.ok)
|
|
839
1184
|
return ready;
|
|
1185
|
+
const proposeGate = denyIfProposeOnly(access, "vault_supersede");
|
|
1186
|
+
if (!proposeGate.ok)
|
|
1187
|
+
return proposeGate;
|
|
840
1188
|
const oldPath = requireString(args, "old_path", "vault_supersede");
|
|
841
1189
|
if (!oldPath.ok)
|
|
842
1190
|
return oldPath;
|
|
@@ -882,41 +1230,33 @@ export async function vaultSupersede(vaultRoot, args, access) {
|
|
|
882
1230
|
const config = loadConfig(vaultRoot);
|
|
883
1231
|
if (!config.ok)
|
|
884
1232
|
return config;
|
|
885
|
-
const extensions = config.value.schemaExtensions;
|
|
886
1233
|
const oldFrontmatter = parsed.value.frontmatter;
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
`collection '${collection}'`));
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
const newFrontmatter = {
|
|
895
|
-
...oldFrontmatter,
|
|
896
|
-
status: "superseded",
|
|
897
|
-
superseded_by: newPath.value,
|
|
898
|
-
updated: todayISO(),
|
|
899
|
-
updated_by: agent.value,
|
|
900
|
-
};
|
|
901
|
-
return performWrite({
|
|
1234
|
+
const rbac = requireWriteAccess(access, collectionOf(oldPath.value, oldFrontmatter));
|
|
1235
|
+
if (!rbac.ok)
|
|
1236
|
+
return rbac;
|
|
1237
|
+
return performFrontmatterWrite({
|
|
902
1238
|
vaultRoot,
|
|
903
1239
|
// Canonical relPath keys the lock/provenance/commit (#127/#128).
|
|
904
|
-
|
|
905
|
-
|
|
1240
|
+
target: {
|
|
1241
|
+
relPath: resolvedOld.value.relPath,
|
|
1242
|
+
absPath: resolvedOld.value.absPath,
|
|
1243
|
+
parsed: parsed.value,
|
|
1244
|
+
config: config.value,
|
|
1245
|
+
},
|
|
906
1246
|
agent: agent.value,
|
|
907
1247
|
tool: "vault_supersede",
|
|
908
1248
|
action: "supersede",
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1249
|
+
newFrontmatter: {
|
|
1250
|
+
...oldFrontmatter,
|
|
1251
|
+
status: "superseded",
|
|
1252
|
+
superseded_by: newPath.value,
|
|
1253
|
+
updated: todayISO(),
|
|
1254
|
+
updated_by: agent.value,
|
|
1255
|
+
},
|
|
913
1256
|
commitMessage: `vault_supersede: ${oldPath.value} superseded by ${newPath.value} ` +
|
|
914
1257
|
`by ${agent.value}${reason ? ` — ${reason}` : ""}`,
|
|
915
|
-
autoCommit: config.value.autoCommit,
|
|
916
|
-
gitDir: config.value.gitDir,
|
|
917
1258
|
baseVersion: baseVersion.value,
|
|
918
|
-
|
|
919
|
-
principal: access?.user,
|
|
1259
|
+
access,
|
|
920
1260
|
});
|
|
921
1261
|
}
|
|
922
1262
|
// Combines two source documents into a target and supersedes both sources to
|
|
@@ -933,6 +1273,9 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
933
1273
|
const ready = requireIndexReady();
|
|
934
1274
|
if (!ready.ok)
|
|
935
1275
|
return ready;
|
|
1276
|
+
const proposeGate = denyIfProposeOnly(access, "vault_merge");
|
|
1277
|
+
if (!proposeGate.ok)
|
|
1278
|
+
return proposeGate;
|
|
936
1279
|
const pathA = requireString(args, "path_a", "vault_merge");
|
|
937
1280
|
if (!pathA.ok)
|
|
938
1281
|
return pathA;
|
|
@@ -1007,10 +1350,9 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
1007
1350
|
targetCollection(vaultRoot, targetPath.value),
|
|
1008
1351
|
];
|
|
1009
1352
|
for (const collection of collections) {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
}
|
|
1353
|
+
const gate = requireWriteAccess(access, collection);
|
|
1354
|
+
if (!gate.ok)
|
|
1355
|
+
return gate;
|
|
1014
1356
|
}
|
|
1015
1357
|
}
|
|
1016
1358
|
// Target frontmatter: inherit path_a's raw frontmatter, apply caller
|
|
@@ -1019,14 +1361,31 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
1019
1361
|
// inherit path_a's. The supplied body replaces whatever was there.
|
|
1020
1362
|
const existingTarget = await readFile(resolvedTarget.value.absPath);
|
|
1021
1363
|
let targetOldFrontmatter = null;
|
|
1364
|
+
let targetOldContent = "";
|
|
1022
1365
|
let targetCreated = parsedA.value.frontmatter.created;
|
|
1023
1366
|
if (existingTarget.ok) {
|
|
1024
1367
|
const parsedTarget = parseDocument(existingTarget.value);
|
|
1025
1368
|
if (parsedTarget.ok) {
|
|
1026
1369
|
targetOldFrontmatter = parsedTarget.value.frontmatter;
|
|
1370
|
+
targetOldContent = parsedTarget.value.content;
|
|
1027
1371
|
targetCreated = parsedTarget.value.frontmatter.created;
|
|
1028
1372
|
}
|
|
1029
1373
|
}
|
|
1374
|
+
// Tier guard (#141): the merged body wholly replaces the target's. path_a
|
|
1375
|
+
// and path_b only receive a frontmatter-level supersede, so their tiers
|
|
1376
|
+
// don't gate the merge.
|
|
1377
|
+
if (targetOldFrontmatter) {
|
|
1378
|
+
const guard = checkTierGuard({
|
|
1379
|
+
tool: "vault_merge",
|
|
1380
|
+
path: targetPath.value,
|
|
1381
|
+
tier: targetOldFrontmatter.tier,
|
|
1382
|
+
oldBody: targetOldContent,
|
|
1383
|
+
newBody: body,
|
|
1384
|
+
agent: agent.value,
|
|
1385
|
+
});
|
|
1386
|
+
if (!guard.ok)
|
|
1387
|
+
return guard;
|
|
1388
|
+
}
|
|
1030
1389
|
const targetRaw = {
|
|
1031
1390
|
...parsedA.value.raw,
|
|
1032
1391
|
provenance: "synthesized",
|
|
@@ -1176,6 +1535,9 @@ export async function vaultMerge(vaultRoot, args, access) {
|
|
|
1176
1535
|
agent: agent.value,
|
|
1177
1536
|
...(access?.user ? { principal: access.user } : {}),
|
|
1178
1537
|
action: w.action,
|
|
1538
|
+
// The merge target gets a new body; the superseded sources only get
|
|
1539
|
+
// frontmatter stamps.
|
|
1540
|
+
body_changed: w.action === "merge",
|
|
1179
1541
|
frontmatter_diff: frontmatterDiff(w.oldFrontmatter, w.newFrontmatter),
|
|
1180
1542
|
});
|
|
1181
1543
|
}
|
|
@@ -1221,6 +1583,11 @@ const baseVersionProperty = {
|
|
|
1221
1583
|
"write is rejected as stale and nothing is changed. Omit for " +
|
|
1222
1584
|
"last-write-wins behavior.",
|
|
1223
1585
|
};
|
|
1586
|
+
const runIdProperty = {
|
|
1587
|
+
type: "string",
|
|
1588
|
+
description: "Optional trace/run identifier of the calling run. Recorded in the " +
|
|
1589
|
+
"provenance log so one run's writes correlate.",
|
|
1590
|
+
};
|
|
1224
1591
|
// Built-in frontmatter properties, projected from the canonical TS constants
|
|
1225
1592
|
// in src/frontmatter/types.ts so the MCP input schema, the runtime validator,
|
|
1226
1593
|
// and the source-of-truth enums never drift. `additionalProperties: true`
|
|
@@ -1310,7 +1677,15 @@ export const writeTools = [
|
|
|
1310
1677
|
"full frontmatter and markdown body; the server stamps 'updated' and " +
|
|
1311
1678
|
"'updated_by' (omit them — anything supplied is overwritten), preserves " +
|
|
1312
1679
|
"'created' on updates, refreshes the search index, and auto-commits the " +
|
|
1313
|
-
"change to git." +
|
|
1680
|
+
"change to git. If the caller's role is propose-only, nothing is " +
|
|
1681
|
+
"written: the payload lands as a staged 'write' proposal awaiting " +
|
|
1682
|
+
"vault_ratify, and the result carries action: 'staged' with the " +
|
|
1683
|
+
"proposal id (plus any competing pending proposals on the same target). " +
|
|
1684
|
+
"Overwriting an existing document returns an advisory supersede_hint — " +
|
|
1685
|
+
"when the write records a changed fact, vault_supersede preserves the " +
|
|
1686
|
+
"prior version and its lineage instead. An accumulation-domain write " +
|
|
1687
|
+
"that cites or links generative-domain docs returns advisory " +
|
|
1688
|
+
"domain_warnings naming them; the write still lands." +
|
|
1314
1689
|
shadowNote,
|
|
1315
1690
|
inputSchema: {
|
|
1316
1691
|
type: "object",
|
|
@@ -1323,6 +1698,12 @@ export const writeTools = [
|
|
|
1323
1698
|
frontmatter: frontmatterProperty,
|
|
1324
1699
|
agent: agentProperty,
|
|
1325
1700
|
base_version: baseVersionProperty,
|
|
1701
|
+
run_id: runIdProperty,
|
|
1702
|
+
reason: {
|
|
1703
|
+
type: "string",
|
|
1704
|
+
description: "Optional rationale. Used as the proposal rationale when a " +
|
|
1705
|
+
"propose-only role's write is staged for ratification.",
|
|
1706
|
+
},
|
|
1326
1707
|
},
|
|
1327
1708
|
required: ["path", "body", "frontmatter", "agent"],
|
|
1328
1709
|
additionalProperties: false,
|
|
@@ -1334,7 +1715,10 @@ export const writeTools = [
|
|
|
1334
1715
|
title: "Append to a document",
|
|
1335
1716
|
annotations: { destructiveHint: true },
|
|
1336
1717
|
description: "Append a markdown section to an existing vault document. Frontmatter " +
|
|
1337
|
-
"is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits." +
|
|
1718
|
+
"is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits. " +
|
|
1719
|
+
"Appending links to generative-domain docs onto an accumulation-domain " +
|
|
1720
|
+
"doc returns advisory domain_warnings naming them; the append still " +
|
|
1721
|
+
"lands." +
|
|
1338
1722
|
shadowNote,
|
|
1339
1723
|
inputSchema: {
|
|
1340
1724
|
type: "object",
|
|
@@ -1349,6 +1733,7 @@ export const writeTools = [
|
|
|
1349
1733
|
},
|
|
1350
1734
|
agent: agentProperty,
|
|
1351
1735
|
base_version: baseVersionProperty,
|
|
1736
|
+
run_id: runIdProperty,
|
|
1352
1737
|
},
|
|
1353
1738
|
required: ["path", "section", "agent"],
|
|
1354
1739
|
additionalProperties: false,
|
|
@@ -1440,6 +1825,43 @@ export const writeTools = [
|
|
|
1440
1825
|
},
|
|
1441
1826
|
handler: (vaultRoot, args, access) => vaultSetConfidence(vaultRoot, args, access),
|
|
1442
1827
|
},
|
|
1828
|
+
{
|
|
1829
|
+
name: "vault_set_tier",
|
|
1830
|
+
title: "Set a document's write-protection tier",
|
|
1831
|
+
annotations: { destructiveHint: true },
|
|
1832
|
+
description: "Change only a document's write-protection tier (source | compiled | " +
|
|
1833
|
+
"manual), leaving its body untouched. `source` bodies are immutable; " +
|
|
1834
|
+
"`manual` bodies only accept rewrites from a human:* identity; " +
|
|
1835
|
+
"`compiled` and unset are unenforced. This is the escape hatch for the " +
|
|
1836
|
+
"tier guards: demote first (a reason is required and the change is " +
|
|
1837
|
+
"logged for lint review), then write. Moving a doc away from 'manual' " +
|
|
1838
|
+
"requires a human:* identity. Rejects if the tier is already at the " +
|
|
1839
|
+
"target. Auto-commits." +
|
|
1840
|
+
shadowNote,
|
|
1841
|
+
inputSchema: {
|
|
1842
|
+
type: "object",
|
|
1843
|
+
properties: {
|
|
1844
|
+
path: {
|
|
1845
|
+
type: "string",
|
|
1846
|
+
description: "Vault-relative path of the document",
|
|
1847
|
+
},
|
|
1848
|
+
tier: {
|
|
1849
|
+
type: "string",
|
|
1850
|
+
enum: [...TIERS],
|
|
1851
|
+
description: "The new tier",
|
|
1852
|
+
},
|
|
1853
|
+
reason: {
|
|
1854
|
+
type: "string",
|
|
1855
|
+
description: "Why the tier is changing (recorded in the commit and provenance)",
|
|
1856
|
+
},
|
|
1857
|
+
agent: agentProperty,
|
|
1858
|
+
base_version: baseVersionProperty,
|
|
1859
|
+
},
|
|
1860
|
+
required: ["path", "tier", "reason", "agent"],
|
|
1861
|
+
additionalProperties: false,
|
|
1862
|
+
},
|
|
1863
|
+
handler: (vaultRoot, args, access) => vaultSetTier(vaultRoot, args, access),
|
|
1864
|
+
},
|
|
1443
1865
|
{
|
|
1444
1866
|
name: "vault_supersede",
|
|
1445
1867
|
title: "Supersede a document",
|