daftari 1.31.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +58 -9
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +6 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/consolidate/index.d.ts.map +1 -1
  7. package/dist/consolidate/index.js +10 -12
  8. package/dist/consolidate/index.js.map +1 -1
  9. package/dist/curation/edges.d.ts.map +1 -1
  10. package/dist/curation/edges.js +36 -17
  11. package/dist/curation/edges.js.map +1 -1
  12. package/dist/curation/lint.d.ts +2 -0
  13. package/dist/curation/lint.d.ts.map +1 -1
  14. package/dist/curation/lint.js +26 -18
  15. package/dist/curation/lint.js.map +1 -1
  16. package/dist/curation/shadow.d.ts +1 -0
  17. package/dist/curation/shadow.d.ts.map +1 -1
  18. package/dist/curation/shadow.js +17 -11
  19. package/dist/curation/shadow.js.map +1 -1
  20. package/dist/curation/staged-actions.d.ts +1 -0
  21. package/dist/curation/staged-actions.d.ts.map +1 -1
  22. package/dist/curation/staged-actions.js +21 -24
  23. package/dist/curation/staged-actions.js.map +1 -1
  24. package/dist/eval/generate.d.ts.map +1 -1
  25. package/dist/eval/generate.js +16 -0
  26. package/dist/eval/generate.js.map +1 -1
  27. package/dist/eval/index.d.ts +1 -0
  28. package/dist/eval/index.d.ts.map +1 -1
  29. package/dist/eval/index.js +79 -28
  30. package/dist/eval/index.js.map +1 -1
  31. package/dist/eval/llm-openrouter.d.ts.map +1 -1
  32. package/dist/eval/llm-openrouter.js +158 -65
  33. package/dist/eval/llm-openrouter.js.map +1 -1
  34. package/dist/interview/index.d.ts +7 -0
  35. package/dist/interview/index.d.ts.map +1 -0
  36. package/dist/interview/index.js +219 -0
  37. package/dist/interview/index.js.map +1 -0
  38. package/dist/interview/questions.d.ts +18 -0
  39. package/dist/interview/questions.d.ts.map +1 -0
  40. package/dist/interview/questions.js +169 -0
  41. package/dist/interview/questions.js.map +1 -0
  42. package/dist/interview/transcript.d.ts +23 -0
  43. package/dist/interview/transcript.d.ts.map +1 -0
  44. package/dist/interview/transcript.js +155 -0
  45. package/dist/interview/transcript.js.map +1 -0
  46. package/dist/okf/export.d.ts.map +1 -1
  47. package/dist/okf/export.js +4 -2
  48. package/dist/okf/export.js.map +1 -1
  49. package/dist/okf/import.d.ts.map +1 -1
  50. package/dist/okf/import.js +11 -3
  51. package/dist/okf/import.js.map +1 -1
  52. package/dist/okf/index.d.ts.map +1 -1
  53. package/dist/okf/index.js +13 -1
  54. package/dist/okf/index.js.map +1 -1
  55. package/dist/okf/map.d.ts +11 -0
  56. package/dist/okf/map.d.ts.map +1 -1
  57. package/dist/okf/map.js +191 -13
  58. package/dist/okf/map.js.map +1 -1
  59. package/dist/okf/types.d.ts +6 -2
  60. package/dist/okf/types.d.ts.map +1 -1
  61. package/dist/okf/types.js +39 -5
  62. package/dist/okf/types.js.map +1 -1
  63. package/dist/search/hybrid.d.ts.map +1 -1
  64. package/dist/search/hybrid.js +2 -5
  65. package/dist/search/hybrid.js.map +1 -1
  66. package/dist/search/reindex.d.ts +3 -0
  67. package/dist/search/reindex.d.ts.map +1 -1
  68. package/dist/search/reindex.js +30 -10
  69. package/dist/search/reindex.js.map +1 -1
  70. package/dist/search/watcher.d.ts.map +1 -1
  71. package/dist/search/watcher.js +8 -17
  72. package/dist/search/watcher.js.map +1 -1
  73. package/dist/storage/index-db.d.ts.map +1 -1
  74. package/dist/storage/index-db.js +1 -0
  75. package/dist/storage/index-db.js.map +1 -1
  76. package/dist/tools/read.d.ts.map +1 -1
  77. package/dist/tools/read.js +69 -47
  78. package/dist/tools/read.js.map +1 -1
  79. package/dist/tools/staged-actions.d.ts.map +1 -1
  80. package/dist/tools/staged-actions.js +32 -34
  81. package/dist/tools/staged-actions.js.map +1 -1
  82. package/dist/tools/write.d.ts.map +1 -1
  83. package/dist/tools/write.js +152 -216
  84. package/dist/tools/write.js.map +1 -1
  85. package/dist/utils/config.d.ts.map +1 -1
  86. package/dist/utils/config.js +86 -86
  87. package/dist/utils/config.js.map +1 -1
  88. package/dist/utils/dates.d.ts +1 -0
  89. package/dist/utils/dates.d.ts.map +1 -1
  90. package/dist/utils/dates.js +5 -0
  91. package/dist/utils/dates.js.map +1 -1
  92. package/package.json +1 -1
@@ -338,6 +338,64 @@ function denyIfProposeOnly(access, tool) {
338
338
  }
339
339
  return ok(undefined);
340
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
+ }
341
399
  // ---------------------------------------------------------------------------
342
400
  // vault_write
343
401
  // ---------------------------------------------------------------------------
@@ -470,13 +528,9 @@ export async function vaultWrite(vaultRoot, args, access) {
470
528
  // collection is allowed to differ from the physical dir (e.g. a draft staged
471
529
  // in _drafts/ that declares its destination collection); it just never widens
472
530
  // access.
473
- if (access) {
474
- const collection = targetCollection(vaultRoot, path.value);
475
- if (!canWrite(access.role, collection)) {
476
- return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
477
- `collection '${collection}'`));
478
- }
479
- }
531
+ const writeGate = requireWriteAccess(access, targetCollection(vaultRoot, path.value));
532
+ if (!writeGate.ok)
533
+ return writeGate;
480
534
  const resolved = resolveVaultPath(vaultRoot, path.value);
481
535
  if (!resolved.ok)
482
536
  return resolved;
@@ -775,13 +829,9 @@ export async function vaultAppend(vaultRoot, args, access) {
775
829
  return config;
776
830
  const extensions = config.value.schemaExtensions;
777
831
  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
- }
832
+ const rbac = requireWriteAccess(access, collectionOf(path.value, oldFrontmatter));
833
+ if (!rbac.ok)
834
+ return rbac;
785
835
  const newFrontmatter = {
786
836
  ...oldFrontmatter,
787
837
  updated: todayISO(),
@@ -875,65 +925,42 @@ export async function vaultPromote(vaultRoot, args, access) {
875
925
  if (access && !canPromote(access.role)) {
876
926
  return err(new Error(`access denied: role '${access.roleName}' cannot promote documents`));
877
927
  }
878
- const resolved = resolveVaultPath(vaultRoot, path.value);
879
- if (!resolved.ok)
880
- return resolved;
881
- const existing = await readFile(resolved.value.absPath);
882
- if (!existing.ok) {
883
- return err(new Error(`vault_promote: document not found: ${path.value}`));
884
- }
885
- const parsed = parseDocument(existing.value);
886
- if (!parsed.ok)
887
- return parsed;
888
- const config = loadConfig(vaultRoot);
889
- if (!config.ok)
890
- return config;
891
- const extensions = config.value.schemaExtensions;
892
- 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;
893
932
  if (oldFrontmatter.status !== "draft") {
894
933
  return err(new Error(`vault_promote: only draft documents can be promoted ` +
895
934
  `(${path.value} is '${oldFrontmatter.status}')`));
896
935
  }
897
- if (!parsed.value.validation.valid) {
898
- const summary = parsed.value.validation.issues
936
+ if (!target.value.parsed.validation.valid) {
937
+ const summary = target.value.parsed.validation.issues
899
938
  .map((i) => `${i.field}: ${i.message}`)
900
939
  .join("; ");
901
940
  return err(new Error(`vault_promote: frontmatter is incomplete: ${summary}`));
902
941
  }
903
942
  // `confidence set` — the document must declare a confidence explicitly, not
904
943
  // ride on the validator's default.
905
- const rawConfidence = parsed.value.raw.confidence;
944
+ const rawConfidence = target.value.parsed.raw.confidence;
906
945
  if (typeof rawConfidence !== "string" ||
907
946
  !CONFIDENCES.includes(rawConfidence)) {
908
947
  return err(new Error("vault_promote: confidence must be set before promotion"));
909
948
  }
910
- const newFrontmatter = {
911
- ...oldFrontmatter,
912
- status: "canonical",
913
- updated: todayISO(),
914
- updated_by: agent.value,
915
- };
916
- return performWrite({
949
+ return performFrontmatterWrite({
917
950
  vaultRoot,
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,
951
+ target: target.value,
923
952
  agent: agent.value,
924
953
  tool: "vault_promote",
925
954
  action: "promote",
926
- fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
927
- newFrontmatter,
928
- oldFrontmatter,
929
- validation: parsed.value.validation,
955
+ newFrontmatter: {
956
+ ...oldFrontmatter,
957
+ status: "canonical",
958
+ updated: todayISO(),
959
+ updated_by: agent.value,
960
+ },
930
961
  commitMessage: `vault_promote: ${path.value} draft→canonical by ${agent.value}`,
931
- autoCommit: config.value.autoCommit,
932
- gitDir: config.value.gitDir,
933
962
  baseVersion: baseVersion.value,
934
- shadowMode: config.value.shadowMode,
935
- principal: access?.user,
936
- bodyChanged: false,
963
+ access,
937
964
  });
938
965
  }
939
966
  // ---------------------------------------------------------------------------
@@ -968,57 +995,30 @@ export async function vaultDeprecate(vaultRoot, args, access) {
968
995
  }
969
996
  supersededBy = args.superseded_by;
970
997
  }
971
- const resolved = resolveVaultPath(vaultRoot, path.value);
972
- if (!resolved.ok)
973
- return resolved;
974
- const existing = await readFile(resolved.value.absPath);
975
- if (!existing.ok) {
976
- return err(new Error(`vault_deprecate: document not found: ${path.value}`));
977
- }
978
- const parsed = parseDocument(existing.value);
979
- if (!parsed.ok)
980
- return parsed;
981
- const config = loadConfig(vaultRoot);
982
- if (!config.ok)
983
- return config;
984
- const extensions = config.value.schemaExtensions;
985
- const oldFrontmatter = parsed.value.frontmatter;
986
- if (access) {
987
- const collection = collectionOf(path.value, oldFrontmatter);
988
- if (!canWrite(access.role, collection)) {
989
- return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
990
- `collection '${collection}'`));
991
- }
992
- }
993
- const newFrontmatter = {
994
- ...oldFrontmatter,
995
- status: "deprecated",
996
- superseded_by: supersededBy,
997
- updated: todayISO(),
998
- updated_by: agent.value,
999
- };
1000
- 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({
1001
1006
  vaultRoot,
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,
1007
+ target: target.value,
1007
1008
  agent: agent.value,
1008
1009
  tool: "vault_deprecate",
1009
1010
  action: "deprecate",
1010
- fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
1011
- newFrontmatter,
1012
- oldFrontmatter,
1013
- validation: parsed.value.validation,
1011
+ newFrontmatter: {
1012
+ ...oldFrontmatter,
1013
+ status: "deprecated",
1014
+ superseded_by: supersededBy,
1015
+ updated: todayISO(),
1016
+ updated_by: agent.value,
1017
+ },
1014
1018
  commitMessage: `vault_deprecate: ${path.value} by ${agent.value} — ${reason.value}` +
1015
1019
  (supersededBy ? ` (superseded by ${supersededBy})` : ""),
1016
- autoCommit: config.value.autoCommit,
1017
- gitDir: config.value.gitDir,
1018
1020
  baseVersion: baseVersion.value,
1019
- shadowMode: config.value.shadowMode,
1020
- principal: access?.user,
1021
- bodyChanged: false,
1021
+ access,
1022
1022
  });
1023
1023
  }
1024
1024
  // ---------------------------------------------------------------------------
@@ -1053,28 +1053,13 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
1053
1053
  const baseVersion = readBaseVersion(args, "vault_set_confidence");
1054
1054
  if (!baseVersion.ok)
1055
1055
  return baseVersion;
1056
- const resolved = resolveVaultPath(vaultRoot, path.value);
1057
- if (!resolved.ok)
1058
- return resolved;
1059
- const existing = await readFile(resolved.value.absPath);
1060
- if (!existing.ok) {
1061
- return err(new Error(`vault_set_confidence: document not found: ${path.value}`));
1062
- }
1063
- const parsed = parseDocument(existing.value);
1064
- if (!parsed.ok)
1065
- return parsed;
1066
- const config = loadConfig(vaultRoot);
1067
- if (!config.ok)
1068
- return config;
1069
- const extensions = config.value.schemaExtensions;
1070
- const oldFrontmatter = parsed.value.frontmatter;
1071
- if (access) {
1072
- const collection = collectionOf(path.value, oldFrontmatter);
1073
- if (!canWrite(access.role, collection)) {
1074
- return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
1075
- `collection '${collection}'`));
1076
- }
1077
- }
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;
1078
1063
  // No-op guard: a confidence already at the target would churn a commit for no
1079
1064
  // change. Surface it as an error so a redundant staged confidence-up does not
1080
1065
  // silently no-op (and a caller learns the value was already set). Compare
@@ -1083,41 +1068,29 @@ export async function vaultSetConfidence(vaultRoot, args, access) {
1083
1068
  // validated value would wrongly reject set_confidence(…, "low") on a doc that
1084
1069
  // never declared one and never write the field (the trap vault_promote dodges
1085
1070
  // the same way, via parsed.value.raw.confidence).
1086
- const rawConfidence = parsed.value.raw.confidence;
1071
+ const rawConfidence = target.value.parsed.raw.confidence;
1087
1072
  const currentConfidence = typeof rawConfidence === "string" && CONFIDENCES.includes(rawConfidence)
1088
1073
  ? rawConfidence
1089
1074
  : undefined;
1090
1075
  if (currentConfidence === confidence.value) {
1091
1076
  return err(new Error(`vault_set_confidence: ${path.value} confidence is already '${confidence.value}'`));
1092
1077
  }
1093
- const newFrontmatter = {
1094
- ...oldFrontmatter,
1095
- confidence: confidence.value,
1096
- updated: todayISO(),
1097
- updated_by: agent.value,
1098
- };
1099
- return performWrite({
1078
+ return performFrontmatterWrite({
1100
1079
  vaultRoot,
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,
1080
+ target: target.value,
1106
1081
  agent: agent.value,
1107
1082
  tool: "vault_set_confidence",
1108
1083
  action: "confidence-set",
1109
- fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
1110
- newFrontmatter,
1111
- oldFrontmatter,
1112
- validation: parsed.value.validation,
1084
+ newFrontmatter: {
1085
+ ...oldFrontmatter,
1086
+ confidence: confidence.value,
1087
+ updated: todayISO(),
1088
+ updated_by: agent.value,
1089
+ },
1113
1090
  commitMessage: `vault_set_confidence: ${path.value} ${oldFrontmatter.confidence}→${confidence.value} ` +
1114
1091
  `by ${agent.value} — ${reason.value}`,
1115
- autoCommit: config.value.autoCommit,
1116
- gitDir: config.value.gitDir,
1117
1092
  baseVersion: baseVersion.value,
1118
- shadowMode: config.value.shadowMode,
1119
- principal: access?.user,
1120
- bodyChanged: false,
1093
+ access,
1121
1094
  });
1122
1095
  }
1123
1096
  // ---------------------------------------------------------------------------
@@ -1156,28 +1129,13 @@ export async function vaultSetTier(vaultRoot, args, access) {
1156
1129
  const baseVersion = readBaseVersion(args, "vault_set_tier");
1157
1130
  if (!baseVersion.ok)
1158
1131
  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
- }
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;
1181
1139
  // The manual consent boundary: only a human identity may lift `manual`.
1182
1140
  if (oldFrontmatter.tier === "manual" &&
1183
1141
  newTier !== "manual" &&
@@ -1188,41 +1146,29 @@ export async function vaultSetTier(vaultRoot, args, access) {
1188
1146
  // No-op guard, same shape as vault_set_confidence: compare against the raw
1189
1147
  // on-disk value so an invalid raw tier (validated down to null) can still be
1190
1148
  // set to any member.
1191
- const rawTier = parsed.value.raw.tier;
1149
+ const rawTier = target.value.parsed.raw.tier;
1192
1150
  const currentTier = typeof rawTier === "string" && TIERS.includes(rawTier)
1193
1151
  ? rawTier
1194
1152
  : undefined;
1195
1153
  if (currentTier === newTier) {
1196
1154
  return err(new Error(`vault_set_tier: ${path.value} tier is already '${newTier}'`));
1197
1155
  }
1198
- const newFrontmatter = {
1199
- ...oldFrontmatter,
1200
- tier: newTier,
1201
- updated: todayISO(),
1202
- updated_by: agent.value,
1203
- };
1204
- return performWrite({
1156
+ return performFrontmatterWrite({
1205
1157
  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,
1158
+ target: target.value,
1211
1159
  agent: agent.value,
1212
1160
  tool: "vault_set_tier",
1213
1161
  action: "tier-set",
1214
- fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
1215
- newFrontmatter,
1216
- oldFrontmatter,
1217
- validation: parsed.value.validation,
1162
+ newFrontmatter: {
1163
+ ...oldFrontmatter,
1164
+ tier: newTier,
1165
+ updated: todayISO(),
1166
+ updated_by: agent.value,
1167
+ },
1218
1168
  commitMessage: `vault_set_tier: ${path.value} ${oldFrontmatter.tier ?? "unset"}→${newTier} ` +
1219
1169
  `by ${agent.value} — ${reason.value}`,
1220
- autoCommit: config.value.autoCommit,
1221
- gitDir: config.value.gitDir,
1222
1170
  baseVersion: baseVersion.value,
1223
- shadowMode: config.value.shadowMode,
1224
- principal: access?.user,
1225
- bodyChanged: false,
1171
+ access,
1226
1172
  });
1227
1173
  }
1228
1174
  // ---------------------------------------------------------------------------
@@ -1284,42 +1230,33 @@ export async function vaultSupersede(vaultRoot, args, access) {
1284
1230
  const config = loadConfig(vaultRoot);
1285
1231
  if (!config.ok)
1286
1232
  return config;
1287
- const extensions = config.value.schemaExtensions;
1288
1233
  const oldFrontmatter = parsed.value.frontmatter;
1289
- if (access) {
1290
- const collection = collectionOf(oldPath.value, oldFrontmatter);
1291
- if (!canWrite(access.role, collection)) {
1292
- return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
1293
- `collection '${collection}'`));
1294
- }
1295
- }
1296
- const newFrontmatter = {
1297
- ...oldFrontmatter,
1298
- status: "superseded",
1299
- superseded_by: newPath.value,
1300
- updated: todayISO(),
1301
- updated_by: agent.value,
1302
- };
1303
- return performWrite({
1234
+ const rbac = requireWriteAccess(access, collectionOf(oldPath.value, oldFrontmatter));
1235
+ if (!rbac.ok)
1236
+ return rbac;
1237
+ return performFrontmatterWrite({
1304
1238
  vaultRoot,
1305
1239
  // Canonical relPath keys the lock/provenance/commit (#127/#128).
1306
- relPath: resolvedOld.value.relPath,
1307
- absPath: resolvedOld.value.absPath,
1240
+ target: {
1241
+ relPath: resolvedOld.value.relPath,
1242
+ absPath: resolvedOld.value.absPath,
1243
+ parsed: parsed.value,
1244
+ config: config.value,
1245
+ },
1308
1246
  agent: agent.value,
1309
1247
  tool: "vault_supersede",
1310
1248
  action: "supersede",
1311
- fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
1312
- newFrontmatter,
1313
- oldFrontmatter,
1314
- validation: parsed.value.validation,
1249
+ newFrontmatter: {
1250
+ ...oldFrontmatter,
1251
+ status: "superseded",
1252
+ superseded_by: newPath.value,
1253
+ updated: todayISO(),
1254
+ updated_by: agent.value,
1255
+ },
1315
1256
  commitMessage: `vault_supersede: ${oldPath.value} superseded by ${newPath.value} ` +
1316
1257
  `by ${agent.value}${reason ? ` — ${reason}` : ""}`,
1317
- autoCommit: config.value.autoCommit,
1318
- gitDir: config.value.gitDir,
1319
1258
  baseVersion: baseVersion.value,
1320
- shadowMode: config.value.shadowMode,
1321
- principal: access?.user,
1322
- bodyChanged: false,
1259
+ access,
1323
1260
  });
1324
1261
  }
1325
1262
  // Combines two source documents into a target and supersedes both sources to
@@ -1413,10 +1350,9 @@ export async function vaultMerge(vaultRoot, args, access) {
1413
1350
  targetCollection(vaultRoot, targetPath.value),
1414
1351
  ];
1415
1352
  for (const collection of collections) {
1416
- if (!canWrite(access.role, collection)) {
1417
- return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
1418
- `collection '${collection}'`));
1419
- }
1353
+ const gate = requireWriteAccess(access, collection);
1354
+ if (!gate.ok)
1355
+ return gate;
1420
1356
  }
1421
1357
  }
1422
1358
  // Target frontmatter: inherit path_a's raw frontmatter, apply caller