daftari 1.19.0 → 1.21.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 (65) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +4 -1
  3. package/dist/access/rbac.d.ts +1 -0
  4. package/dist/access/rbac.d.ts.map +1 -1
  5. package/dist/access/rbac.js +7 -0
  6. package/dist/access/rbac.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +3 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/curation/edges.d.ts +57 -0
  11. package/dist/curation/edges.d.ts.map +1 -0
  12. package/dist/curation/edges.js +410 -0
  13. package/dist/curation/edges.js.map +1 -0
  14. package/dist/curation/lint.d.ts +2 -0
  15. package/dist/curation/lint.d.ts.map +1 -1
  16. package/dist/curation/lint.js +5 -0
  17. package/dist/curation/lint.js.map +1 -1
  18. package/dist/curation/provenance.d.ts +1 -0
  19. package/dist/curation/provenance.d.ts.map +1 -1
  20. package/dist/curation/provenance.js +1 -0
  21. package/dist/curation/provenance.js.map +1 -1
  22. package/dist/curation/shadow.d.ts +58 -0
  23. package/dist/curation/shadow.d.ts.map +1 -0
  24. package/dist/curation/shadow.js +211 -0
  25. package/dist/curation/shadow.js.map +1 -0
  26. package/dist/curation/staged-actions.d.ts +0 -1
  27. package/dist/curation/staged-actions.d.ts.map +1 -1
  28. package/dist/curation/staged-actions.js +7 -11
  29. package/dist/curation/staged-actions.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +8 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/search/reindex.d.ts.map +1 -1
  34. package/dist/search/reindex.js +4 -0
  35. package/dist/search/reindex.js.map +1 -1
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +2 -0
  38. package/dist/server.js.map +1 -1
  39. package/dist/storage/index-db.d.ts +13 -0
  40. package/dist/storage/index-db.d.ts.map +1 -1
  41. package/dist/storage/index-db.js +43 -1
  42. package/dist/storage/index-db.js.map +1 -1
  43. package/dist/tools/curation.d.ts +2 -0
  44. package/dist/tools/curation.d.ts.map +1 -1
  45. package/dist/tools/curation.js +5 -1
  46. package/dist/tools/curation.js.map +1 -1
  47. package/dist/tools/edges.d.ts +17 -0
  48. package/dist/tools/edges.d.ts.map +1 -0
  49. package/dist/tools/edges.js +351 -0
  50. package/dist/tools/edges.js.map +1 -0
  51. package/dist/tools/staged-actions.d.ts +1 -1
  52. package/dist/tools/staged-actions.d.ts.map +1 -1
  53. package/dist/tools/staged-actions.js +110 -54
  54. package/dist/tools/staged-actions.js.map +1 -1
  55. package/dist/tools/write.d.ts +5 -1
  56. package/dist/tools/write.d.ts.map +1 -1
  57. package/dist/tools/write.js +612 -4
  58. package/dist/tools/write.js.map +1 -1
  59. package/dist/utils/config.d.ts +2 -0
  60. package/dist/utils/config.d.ts.map +1 -1
  61. package/dist/utils/config.js +17 -1
  62. package/dist/utils/config.js.map +1 -1
  63. package/package.json +1 -1
  64. package/templates/config.yaml +3 -0
  65. package/templates/reviewer-vault/.daftari/config.yaml +3 -0
@@ -13,6 +13,7 @@ import matter from "gray-matter";
13
13
  import { acquireLock, openLockDb, releaseLock } from "../access/locks.js";
14
14
  import { canPromote, canWrite } from "../access/rbac.js";
15
15
  import { frontmatterDiff, recordProvenance } from "../curation/provenance.js";
16
+ import { recordShadowAction } from "../curation/shadow.js";
16
17
  import { parseDocument } from "../frontmatter/parser.js";
17
18
  import { validateFrontmatter } from "../frontmatter/schema.js";
18
19
  import { CONFIDENCES, DOMAINS, err, ok, PROVENANCES, STATUSES, } from "../frontmatter/types.js";
@@ -148,6 +149,36 @@ function shortHash(h) {
148
149
  // entry is logged. When `baseVersion` is omitted the check is skipped entirely
149
150
  // and last-write-wins behavior is preserved.
150
151
  async function performWrite(params) {
152
+ // Shadow mode (spec §11.5): everything up to here ran exactly as live —
153
+ // validation, RBAC, frontmatter assembly, diff — so the logged do() is one
154
+ // that WOULD have executed. Log it with its impact/budget verdict and stop:
155
+ // no lock, no file, no commit, no index, no provenance. base_version is
156
+ // intentionally not checked — stale-write rejection guards a mutation, and
157
+ // there is none.
158
+ if (params.shadowMode) {
159
+ const recorded = await recordShadowAction(params.vaultRoot, {
160
+ tool: params.tool,
161
+ action: params.action,
162
+ targetPath: params.relPath,
163
+ agent: params.agent,
164
+ ...(params.principal ? { principal: params.principal } : {}),
165
+ frontmatterDiff: frontmatterDiff(params.oldFrontmatter, params.newFrontmatter),
166
+ commitMessage: params.commitMessage,
167
+ });
168
+ if (!recorded.ok)
169
+ return recorded;
170
+ return ok({
171
+ path: params.relPath,
172
+ action: params.action,
173
+ commit: null,
174
+ committed: false,
175
+ status: params.newFrontmatter.status,
176
+ updated: params.newFrontmatter.updated,
177
+ validation: params.validation,
178
+ indexUpdated: false,
179
+ shadow: true,
180
+ });
181
+ }
151
182
  const lockDbResult = openLockDb(params.vaultRoot);
152
183
  if (!lockDbResult.ok)
153
184
  return lockDbResult;
@@ -167,6 +198,7 @@ async function performWrite(params) {
167
198
  tool: params.tool,
168
199
  file: params.relPath,
169
200
  agent: params.agent,
201
+ ...(params.principal ? { principal: params.principal } : {}),
170
202
  action: "rejected_stale",
171
203
  reason: `stale: base_version ${shortHash(params.baseVersion)} != ` +
172
204
  `current ${currentHash ? shortHash(currentHash) : "<absent>"}`,
@@ -196,6 +228,7 @@ async function performWrite(params) {
196
228
  tool: params.tool,
197
229
  file: params.relPath,
198
230
  agent: params.agent,
231
+ ...(params.principal ? { principal: params.principal } : {}),
199
232
  action: params.action,
200
233
  frontmatter_diff: frontmatterDiff(params.oldFrontmatter, params.newFrontmatter),
201
234
  });
@@ -406,6 +439,8 @@ export async function vaultWrite(vaultRoot, args, access) {
406
439
  commitMessage: `vault_write: ${isUpdate ? "update" : "create"} ${path.value} ` + `by ${agent.value}`,
407
440
  autoCommit: config.value.autoCommit,
408
441
  baseVersion: baseVersion.value,
442
+ shadowMode: config.value.shadowMode,
443
+ principal: access?.user,
409
444
  });
410
445
  }
411
446
  // ---------------------------------------------------------------------------
@@ -492,6 +527,8 @@ export async function vaultAppend(vaultRoot, args, access) {
492
527
  commitMessage: `vault_append: ${path.value} by ${agent.value}`,
493
528
  autoCommit: config.value.autoCommit,
494
529
  baseVersion: baseVersion.value,
530
+ shadowMode: config.value.shadowMode,
531
+ principal: access?.user,
495
532
  });
496
533
  }
497
534
  // ---------------------------------------------------------------------------
@@ -568,6 +605,8 @@ export async function vaultPromote(vaultRoot, args, access) {
568
605
  commitMessage: `vault_promote: ${path.value} draft→canonical by ${agent.value}`,
569
606
  autoCommit: config.value.autoCommit,
570
607
  baseVersion: baseVersion.value,
608
+ shadowMode: config.value.shadowMode,
609
+ principal: access?.user,
571
610
  });
572
611
  }
573
612
  // ---------------------------------------------------------------------------
@@ -643,7 +682,461 @@ export async function vaultDeprecate(vaultRoot, args, access) {
643
682
  (supersededBy ? ` (superseded by ${supersededBy})` : ""),
644
683
  autoCommit: config.value.autoCommit,
645
684
  baseVersion: baseVersion.value,
685
+ shadowMode: config.value.shadowMode,
686
+ principal: access?.user,
687
+ });
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // vault_set_confidence (§11.4)
691
+ // ---------------------------------------------------------------------------
692
+ // Changes only a document's `confidence`, leaving status and body untouched.
693
+ // A narrow tool so a calibration nudge never rides a full-document overwrite.
694
+ // A reason is mandatory — a confidence change is a claim about the document's
695
+ // trustworthiness and earns an audit trail.
696
+ export async function vaultSetConfidence(vaultRoot, args, access) {
697
+ const ready = requireIndexReady();
698
+ if (!ready.ok)
699
+ return ready;
700
+ const path = requireString(args, "path", "vault_set_confidence");
701
+ if (!path.ok)
702
+ return path;
703
+ const agent = requireString(args, "agent", "vault_set_confidence");
704
+ if (!agent.ok)
705
+ return agent;
706
+ const reason = requireString(args, "reason", "vault_set_confidence");
707
+ if (!reason.ok)
708
+ return reason;
709
+ const confidence = requireString(args, "confidence", "vault_set_confidence");
710
+ if (!confidence.ok)
711
+ return confidence;
712
+ if (!CONFIDENCES.includes(confidence.value)) {
713
+ return err(new Error(`vault_set_confidence: 'confidence' must be one of: ${CONFIDENCES.join(", ")}`));
714
+ }
715
+ const baseVersion = readBaseVersion(args, "vault_set_confidence");
716
+ if (!baseVersion.ok)
717
+ return baseVersion;
718
+ const resolved = resolveVaultPath(vaultRoot, path.value);
719
+ if (!resolved.ok)
720
+ return resolved;
721
+ const existing = await readFile(resolved.value);
722
+ if (!existing.ok) {
723
+ return err(new Error(`vault_set_confidence: document not found: ${path.value}`));
724
+ }
725
+ const parsed = parseDocument(existing.value);
726
+ if (!parsed.ok)
727
+ return parsed;
728
+ const config = loadConfig(vaultRoot);
729
+ if (!config.ok)
730
+ return config;
731
+ const extensions = config.value.schemaExtensions;
732
+ const oldFrontmatter = parsed.value.frontmatter;
733
+ if (access) {
734
+ const collection = collectionOf(path.value, oldFrontmatter);
735
+ if (!canWrite(access.role, collection)) {
736
+ return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
737
+ `collection '${collection}'`));
738
+ }
739
+ }
740
+ // No-op guard: a confidence already at the target would churn a commit for no
741
+ // change. Surface it as an error so a redundant staged confidence-up does not
742
+ // silently no-op (and a caller learns the value was already set). Compare
743
+ // against the *raw* on-disk value, not the validated frontmatter — the
744
+ // validator defaults a missing confidence to "low", so comparing the
745
+ // validated value would wrongly reject set_confidence(…, "low") on a doc that
746
+ // never declared one and never write the field (the trap vault_promote dodges
747
+ // the same way, via parsed.value.raw.confidence).
748
+ const rawConfidence = parsed.value.raw.confidence;
749
+ const currentConfidence = typeof rawConfidence === "string" && CONFIDENCES.includes(rawConfidence)
750
+ ? rawConfidence
751
+ : undefined;
752
+ if (currentConfidence === confidence.value) {
753
+ return err(new Error(`vault_set_confidence: ${path.value} confidence is already '${confidence.value}'`));
754
+ }
755
+ const newFrontmatter = {
756
+ ...oldFrontmatter,
757
+ confidence: confidence.value,
758
+ updated: todayISO(),
759
+ updated_by: agent.value,
760
+ };
761
+ return performWrite({
762
+ vaultRoot,
763
+ relPath: path.value,
764
+ absPath: resolved.value,
765
+ agent: agent.value,
766
+ tool: "vault_set_confidence",
767
+ action: "confidence-set",
768
+ fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
769
+ newFrontmatter,
770
+ oldFrontmatter,
771
+ validation: parsed.value.validation,
772
+ commitMessage: `vault_set_confidence: ${path.value} ${oldFrontmatter.confidence}→${confidence.value} ` +
773
+ `by ${agent.value} — ${reason.value}`,
774
+ autoCommit: config.value.autoCommit,
775
+ baseVersion: baseVersion.value,
776
+ shadowMode: config.value.shadowMode,
777
+ principal: access?.user,
778
+ });
779
+ }
780
+ // ---------------------------------------------------------------------------
781
+ // vault_supersede (§11.4)
782
+ // ---------------------------------------------------------------------------
783
+ // Marks a document explicitly superseded by a named successor. Distinct from
784
+ // vault_deprecate: deprecate sets status="deprecated" with an *optional*
785
+ // successor; supersede sets status="superseded" and *requires* a successor that
786
+ // must already exist. Permissive on source status in v1 (last-write-wins).
787
+ export async function vaultSupersede(vaultRoot, args, access) {
788
+ const ready = requireIndexReady();
789
+ if (!ready.ok)
790
+ return ready;
791
+ const oldPath = requireString(args, "old_path", "vault_supersede");
792
+ if (!oldPath.ok)
793
+ return oldPath;
794
+ const newPath = requireString(args, "new_path", "vault_supersede");
795
+ if (!newPath.ok)
796
+ return newPath;
797
+ const agent = requireString(args, "agent", "vault_supersede");
798
+ if (!agent.ok)
799
+ return agent;
800
+ const baseVersion = readBaseVersion(args, "vault_supersede");
801
+ if (!baseVersion.ok)
802
+ return baseVersion;
803
+ let reason;
804
+ if (args.reason !== undefined && args.reason !== null) {
805
+ if (typeof args.reason !== "string") {
806
+ return err(new Error("vault_supersede: 'reason' must be a string"));
807
+ }
808
+ const trimmed = args.reason.trim();
809
+ if (trimmed.length > 0)
810
+ reason = trimmed;
811
+ }
812
+ if (oldPath.value === newPath.value) {
813
+ return err(new Error("vault_supersede: a document cannot supersede itself"));
814
+ }
815
+ const resolvedOld = resolveVaultPath(vaultRoot, oldPath.value);
816
+ if (!resolvedOld.ok)
817
+ return resolvedOld;
818
+ const resolvedNew = resolveVaultPath(vaultRoot, newPath.value);
819
+ if (!resolvedNew.ok)
820
+ return resolvedNew;
821
+ const existing = await readFile(resolvedOld.value);
822
+ if (!existing.ok) {
823
+ return err(new Error(`vault_supersede: document not found: ${oldPath.value}`));
824
+ }
825
+ // The successor must be a real document — superseded_by must never dangle.
826
+ const successor = await readFile(resolvedNew.value);
827
+ if (!successor.ok) {
828
+ return err(new Error(`vault_supersede: successor not found: ${newPath.value}`));
829
+ }
830
+ const parsed = parseDocument(existing.value);
831
+ if (!parsed.ok)
832
+ return parsed;
833
+ const config = loadConfig(vaultRoot);
834
+ if (!config.ok)
835
+ return config;
836
+ const extensions = config.value.schemaExtensions;
837
+ const oldFrontmatter = parsed.value.frontmatter;
838
+ if (access) {
839
+ const collection = collectionOf(oldPath.value, oldFrontmatter);
840
+ if (!canWrite(access.role, collection)) {
841
+ return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
842
+ `collection '${collection}'`));
843
+ }
844
+ }
845
+ const newFrontmatter = {
846
+ ...oldFrontmatter,
847
+ status: "superseded",
848
+ superseded_by: newPath.value,
849
+ updated: todayISO(),
850
+ updated_by: agent.value,
851
+ };
852
+ return performWrite({
853
+ vaultRoot,
854
+ relPath: oldPath.value,
855
+ absPath: resolvedOld.value,
856
+ agent: agent.value,
857
+ tool: "vault_supersede",
858
+ action: "supersede",
859
+ fileText: serializeDocument(newFrontmatter, parsed.value.content, extensions, parsed.value.raw),
860
+ newFrontmatter,
861
+ oldFrontmatter,
862
+ validation: parsed.value.validation,
863
+ commitMessage: `vault_supersede: ${oldPath.value} superseded by ${newPath.value} ` +
864
+ `by ${agent.value}${reason ? ` — ${reason}` : ""}`,
865
+ autoCommit: config.value.autoCommit,
866
+ baseVersion: baseVersion.value,
867
+ shadowMode: config.value.shadowMode,
868
+ principal: access?.user,
869
+ });
870
+ }
871
+ // Combines two source documents into a target and supersedes both sources to
872
+ // point at it. Mechanical, not generative: the merged body is supplied by the
873
+ // caller (a human at ratification, or the loop) — vault_merge never synthesizes
874
+ // prose (that would be an LLM call; the write layer is LLM-free).
875
+ //
876
+ // Unlike the single-file write tools this touches up to three files, so it does
877
+ // not use performWrite. It mirrors the backfill apply pattern: write each file,
878
+ // index each, then one git commit for the whole set (src/backfill/apply.ts). A
879
+ // source that *is* the target is written once (with the merged body), not also
880
+ // superseded. base_version optimistic concurrency is not offered for merge.
881
+ export async function vaultMerge(vaultRoot, args, access) {
882
+ const ready = requireIndexReady();
883
+ if (!ready.ok)
884
+ return ready;
885
+ const pathA = requireString(args, "path_a", "vault_merge");
886
+ if (!pathA.ok)
887
+ return pathA;
888
+ const pathB = requireString(args, "path_b", "vault_merge");
889
+ if (!pathB.ok)
890
+ return pathB;
891
+ const targetPath = requireString(args, "target_path", "vault_merge");
892
+ if (!targetPath.ok)
893
+ return targetPath;
894
+ const agent = requireString(args, "agent", "vault_merge");
895
+ if (!agent.ok)
896
+ return agent;
897
+ const body = args.body;
898
+ if (typeof body !== "string" || body.trim().length === 0) {
899
+ return err(new Error("vault_merge requires a non-empty string 'body' argument"));
900
+ }
901
+ if (args.frontmatter !== undefined && args.frontmatter !== null) {
902
+ if (typeof args.frontmatter !== "object") {
903
+ return err(new Error("vault_merge: 'frontmatter' must be an object"));
904
+ }
905
+ }
906
+ const frontmatterOverrides = args.frontmatter && typeof args.frontmatter === "object"
907
+ ? args.frontmatter
908
+ : {};
909
+ // Resolve all three paths up front and run every identity check against the
910
+ // resolved absolute paths, never the raw caller strings: `pricing/a.md` and
911
+ // `./pricing/a.md` name the same file but differ as strings, which would
912
+ // otherwise defeat the path_a≠path_b guard and the source-is-target skip
913
+ // below (writing one file twice in a single commit and superseding it against
914
+ // itself).
915
+ const resolvedA = resolveVaultPath(vaultRoot, pathA.value);
916
+ if (!resolvedA.ok)
917
+ return resolvedA;
918
+ const resolvedB = resolveVaultPath(vaultRoot, pathB.value);
919
+ if (!resolvedB.ok)
920
+ return resolvedB;
921
+ const resolvedTarget = resolveVaultPath(vaultRoot, targetPath.value);
922
+ if (!resolvedTarget.ok)
923
+ return resolvedTarget;
924
+ if (resolvedA.value === resolvedB.value) {
925
+ return err(new Error("vault_merge: path_a and path_b must differ"));
926
+ }
927
+ const existingA = await readFile(resolvedA.value);
928
+ if (!existingA.ok)
929
+ return err(new Error(`vault_merge: document not found: ${pathA.value}`));
930
+ const existingB = await readFile(resolvedB.value);
931
+ if (!existingB.ok)
932
+ return err(new Error(`vault_merge: document not found: ${pathB.value}`));
933
+ const parsedA = parseDocument(existingA.value);
934
+ if (!parsedA.ok)
935
+ return parsedA;
936
+ const parsedB = parseDocument(existingB.value);
937
+ if (!parsedB.ok)
938
+ return parsedB;
939
+ const config = loadConfig(vaultRoot);
940
+ if (!config.ok)
941
+ return config;
942
+ const extensions = config.value.schemaExtensions;
943
+ // RBAC: a merge writes/mutates all three docs, so the caller needs write on
944
+ // each one's collection.
945
+ if (access) {
946
+ // The target's collection is the override, else path_a's *raw* collection
947
+ // (the value actually serialized into the target below — not the validated
948
+ // frontmatter, which may differ if path_a's raw collection is malformed),
949
+ // else the target path's top-level dir. Deriving it from the same source
950
+ // the write uses keeps the gate and the write in agreement.
951
+ const rawACollection = parsedA.value.raw.collection;
952
+ const collections = [
953
+ collectionOf(pathA.value, parsedA.value.frontmatter),
954
+ collectionOf(pathB.value, parsedB.value.frontmatter),
955
+ typeof frontmatterOverrides.collection === "string" && frontmatterOverrides.collection
956
+ ? frontmatterOverrides.collection
957
+ : typeof rawACollection === "string" && rawACollection
958
+ ? rawACollection
959
+ : (targetPath.value.split("/")[0] ?? ""),
960
+ ];
961
+ for (const collection of collections) {
962
+ if (!canWrite(access.role, collection)) {
963
+ return err(new Error(`access denied: role '${access.roleName}' cannot write to ` +
964
+ `collection '${collection}'`));
965
+ }
966
+ }
967
+ }
968
+ // Target frontmatter: inherit path_a's raw frontmatter, apply caller
969
+ // overrides, stamp provenance/updated/updated_by. If the target file already
970
+ // exists, preserve its `created` (the vault_write update idiom); otherwise
971
+ // inherit path_a's. The supplied body replaces whatever was there.
972
+ const existingTarget = await readFile(resolvedTarget.value);
973
+ let targetOldFrontmatter = null;
974
+ let targetCreated = parsedA.value.frontmatter.created;
975
+ if (existingTarget.ok) {
976
+ const parsedTarget = parseDocument(existingTarget.value);
977
+ if (parsedTarget.ok) {
978
+ targetOldFrontmatter = parsedTarget.value.frontmatter;
979
+ targetCreated = parsedTarget.value.frontmatter.created;
980
+ }
981
+ }
982
+ const targetRaw = {
983
+ ...parsedA.value.raw,
984
+ provenance: "synthesized",
985
+ ...frontmatterOverrides,
986
+ created: targetCreated,
987
+ updated: todayISO(),
988
+ updated_by: agent.value,
989
+ };
990
+ const { frontmatter: targetFm, report: targetReport } = validateFrontmatter(targetRaw, extensions);
991
+ if (!targetReport.valid) {
992
+ const summary = targetReport.issues.map((i) => `${i.field}: ${i.message}`).join("; ");
993
+ return err(new Error(`vault_merge: target frontmatter is invalid: ${summary}`));
994
+ }
995
+ const stampedTarget = {
996
+ ...targetFm,
997
+ created: targetCreated,
998
+ updated: todayISO(),
999
+ updated_by: agent.value,
1000
+ };
1001
+ // Build the write set. The target is always written. Each source that is NOT
1002
+ // the target is superseded to point at the target.
1003
+ const writes = [];
1004
+ writes.push({
1005
+ relPath: targetPath.value,
1006
+ absPath: resolvedTarget.value,
1007
+ fileText: serializeDocument(stampedTarget, body, extensions, applyExtensionDefaults(targetRaw, extensions)),
1008
+ newFrontmatter: stampedTarget,
1009
+ oldFrontmatter: targetOldFrontmatter,
1010
+ action: "merge",
646
1011
  });
1012
+ for (const source of [
1013
+ { path: pathA.value, abs: resolvedA.value, parsed: parsedA.value },
1014
+ { path: pathB.value, abs: resolvedB.value, parsed: parsedB.value },
1015
+ ]) {
1016
+ // Compare resolved absolute paths, not raw strings: a source that aliases
1017
+ // the target (e.g. target `./pricing/a.md`, source `pricing/a.md`) is the
1018
+ // fold-into-A case — write it once with the merged body, never supersede it.
1019
+ if (source.abs === resolvedTarget.value)
1020
+ continue;
1021
+ const supersededFm = {
1022
+ ...source.parsed.frontmatter,
1023
+ status: "superseded",
1024
+ superseded_by: targetPath.value,
1025
+ updated: todayISO(),
1026
+ updated_by: agent.value,
1027
+ };
1028
+ writes.push({
1029
+ relPath: source.path,
1030
+ absPath: source.abs,
1031
+ fileText: serializeDocument(supersededFm, source.parsed.content, extensions, source.parsed.raw),
1032
+ newFrontmatter: supersededFm,
1033
+ oldFrontmatter: source.parsed.frontmatter,
1034
+ action: "supersede",
1035
+ });
1036
+ }
1037
+ // Shadow mode (spec §11.5): the full write set is assembled and validated —
1038
+ // the do() that WOULD have executed. Log one merge record whose blast seeds
1039
+ // every touched path, write nothing.
1040
+ if (config.value.shadowMode) {
1041
+ const recorded = await recordShadowAction(vaultRoot, {
1042
+ tool: "vault_merge",
1043
+ action: "merge",
1044
+ targetPath: targetPath.value,
1045
+ touchedPaths: [...new Set(writes.map((w) => w.relPath))],
1046
+ agent: agent.value,
1047
+ ...(access?.user ? { principal: access.user } : {}),
1048
+ frontmatterDiff: frontmatterDiff(targetOldFrontmatter, stampedTarget),
1049
+ commitMessage: `vault_merge: ${pathA.value} + ${pathB.value} → ${targetPath.value} by ${agent.value}`,
1050
+ });
1051
+ if (!recorded.ok)
1052
+ return recorded;
1053
+ return ok({
1054
+ path: targetPath.value,
1055
+ action: "merge",
1056
+ commit: null,
1057
+ committed: false,
1058
+ status: stampedTarget.status,
1059
+ updated: stampedTarget.updated,
1060
+ validation: targetReport,
1061
+ indexUpdated: false,
1062
+ shadow: true,
1063
+ });
1064
+ }
1065
+ // Acquire file locks on every distinct path, in a deterministic sorted order
1066
+ // (defensive against self-deadlock if two merges ever overlapped — the
1067
+ // one-process invariant makes that theoretical). Release all in finally.
1068
+ const lockDbResult = openLockDb(vaultRoot);
1069
+ if (!lockDbResult.ok)
1070
+ return lockDbResult;
1071
+ const lockDb = lockDbResult.value;
1072
+ const lockPaths = [...new Set(writes.map((w) => w.relPath))].sort();
1073
+ const held = [];
1074
+ try {
1075
+ for (const relPath of lockPaths) {
1076
+ const lock = acquireLock(lockDb, relPath, agent.value);
1077
+ if (!lock.ok)
1078
+ return lock;
1079
+ held.push(relPath);
1080
+ }
1081
+ // Write all files, then a single git commit. This is NOT crash-atomic on
1082
+ // the disk-write phase: like the single-file write path (performWrite), a
1083
+ // throw mid-loop — or a commit() failure after the files are on disk —
1084
+ // leaves the working tree dirty and uncommitted. For merge the dirty state
1085
+ // can be a *partial* merge (target written, a source not yet superseded),
1086
+ // which a later reindex/watcher would pick up. The git commit itself is
1087
+ // atomic, and the one-process invariant plus human-gated ratification keep
1088
+ // this window small; a full pre-write snapshot/rollback is deferred.
1089
+ for (const w of writes) {
1090
+ await mkdir(dirname(w.absPath), { recursive: true });
1091
+ await writeFile(w.absPath, w.fileText, "utf-8");
1092
+ }
1093
+ let commitHash = null;
1094
+ if (config.value.autoCommit) {
1095
+ const committed = await commit(vaultRoot, writes.map((w) => w.relPath), `vault_merge: ${pathA.value} + ${pathB.value} → ${targetPath.value} by ${agent.value}`, agent.value);
1096
+ if (!committed.ok)
1097
+ return committed;
1098
+ commitHash = committed.value.hash;
1099
+ }
1100
+ // Index each written doc and log provenance per file. Both are best-effort
1101
+ // (the index is a rebuildable cache; the log is advisory) so a failure here
1102
+ // does not unwind the durable commit. noteSelfWrite runs here, after the
1103
+ // index lands, so the fs.watch reactive indexer drops the redundant change
1104
+ // event for our own write (mirrors performWrite's ordering).
1105
+ let allIndexed = true;
1106
+ for (const w of writes) {
1107
+ const indexed = await indexDocument(vaultRoot, w.relPath);
1108
+ if (!indexed.ok)
1109
+ allIndexed = false;
1110
+ noteSelfWrite(w.absPath);
1111
+ await recordProvenance(vaultRoot, {
1112
+ tool: "vault_merge",
1113
+ file: w.relPath,
1114
+ agent: agent.value,
1115
+ ...(access?.user ? { principal: access.user } : {}),
1116
+ action: w.action,
1117
+ frontmatter_diff: frontmatterDiff(w.oldFrontmatter, w.newFrontmatter),
1118
+ });
1119
+ }
1120
+ return ok({
1121
+ path: targetPath.value,
1122
+ action: "merge",
1123
+ commit: commitHash,
1124
+ committed: config.value.autoCommit,
1125
+ status: stampedTarget.status,
1126
+ updated: stampedTarget.updated,
1127
+ validation: targetReport,
1128
+ indexUpdated: allIndexed,
1129
+ });
1130
+ }
1131
+ catch (e) {
1132
+ const reason = e instanceof Error ? e.message : String(e);
1133
+ return err(new Error(`vault_merge failed: ${reason}`));
1134
+ }
1135
+ finally {
1136
+ for (const relPath of held)
1137
+ releaseLock(lockDb, relPath, agent.value);
1138
+ lockDb.close();
1139
+ }
647
1140
  }
648
1141
  // ---------------------------------------------------------------------------
649
1142
  // MCP tool definitions
@@ -653,6 +1146,11 @@ const agentProperty = {
653
1146
  description: "Acting identity, e.g. 'agent:claude-code' or 'human:mihir'. Recorded " +
654
1147
  "as updated_by, the git author, and in the provenance log.",
655
1148
  };
1149
+ // Appended to every write-tool description: shadow_mode (§11.5) makes the
1150
+ // "auto-commits" claim conditionally false, and the description is the only
1151
+ // surface a caller reads before invoking.
1152
+ const shadowNote = " If the vault runs shadow_mode, the write is computed and logged to the " +
1153
+ "shadow store but NOT applied; the result carries shadow: true.";
656
1154
  const baseVersionProperty = {
657
1155
  type: "string",
658
1156
  description: "Optional optimistic-concurrency token: the 'version' that vault_read " +
@@ -750,7 +1248,8 @@ export const writeTools = [
750
1248
  "full frontmatter and markdown body; the server stamps 'updated' and " +
751
1249
  "'updated_by' (omit them — anything supplied is overwritten), preserves " +
752
1250
  "'created' on updates, refreshes the search index, and auto-commits the " +
753
- "change to git.",
1251
+ "change to git." +
1252
+ shadowNote,
754
1253
  inputSchema: {
755
1254
  type: "object",
756
1255
  properties: {
@@ -773,7 +1272,8 @@ export const writeTools = [
773
1272
  title: "Append to a document",
774
1273
  annotations: { destructiveHint: true },
775
1274
  description: "Append a markdown section to an existing vault document. Frontmatter " +
776
- "is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits.",
1275
+ "is preserved; 'updated' and 'updated_by' are re-stamped. Auto-commits." +
1276
+ shadowNote,
777
1277
  inputSchema: {
778
1278
  type: "object",
779
1279
  properties: {
@@ -799,7 +1299,8 @@ export const writeTools = [
799
1299
  annotations: { destructiveHint: true },
800
1300
  description: "Promote a draft document to canonical status. Refuses unless the " +
801
1301
  "document is currently a draft, its frontmatter is complete, and a " +
802
- "confidence level has been explicitly set. Auto-commits.",
1302
+ "confidence level has been explicitly set. Auto-commits." +
1303
+ shadowNote,
803
1304
  inputSchema: {
804
1305
  type: "object",
805
1306
  properties: {
@@ -820,7 +1321,8 @@ export const writeTools = [
820
1321
  title: "Deprecate a document",
821
1322
  annotations: { destructiveHint: true },
822
1323
  description: "Mark a document deprecated. A reason is required; optionally record " +
823
- "the document that supersedes it. Auto-commits.",
1324
+ "the document that supersedes it. Auto-commits." +
1325
+ shadowNote,
824
1326
  inputSchema: {
825
1327
  type: "object",
826
1328
  properties: {
@@ -844,5 +1346,111 @@ export const writeTools = [
844
1346
  },
845
1347
  handler: (vaultRoot, args, access) => vaultDeprecate(vaultRoot, args, access),
846
1348
  },
1349
+ {
1350
+ name: "vault_set_confidence",
1351
+ title: "Set a document's confidence",
1352
+ annotations: { destructiveHint: true },
1353
+ description: "Change only a document's confidence level (low | medium | high), leaving " +
1354
+ "its status and body untouched. A reason is required and recorded. Rejects " +
1355
+ "if the confidence is already at the target. Auto-commits." +
1356
+ shadowNote,
1357
+ inputSchema: {
1358
+ type: "object",
1359
+ properties: {
1360
+ path: {
1361
+ type: "string",
1362
+ description: "Vault-relative path of the document",
1363
+ },
1364
+ confidence: {
1365
+ type: "string",
1366
+ enum: [...CONFIDENCES],
1367
+ description: "The new confidence level",
1368
+ },
1369
+ reason: {
1370
+ type: "string",
1371
+ description: "Why the confidence is changing (recorded in the commit and provenance)",
1372
+ },
1373
+ agent: agentProperty,
1374
+ base_version: baseVersionProperty,
1375
+ },
1376
+ required: ["path", "confidence", "reason", "agent"],
1377
+ additionalProperties: false,
1378
+ },
1379
+ handler: (vaultRoot, args, access) => vaultSetConfidence(vaultRoot, args, access),
1380
+ },
1381
+ {
1382
+ name: "vault_supersede",
1383
+ title: "Supersede a document",
1384
+ annotations: { destructiveHint: true },
1385
+ description: "Mark a document superseded by a named successor. Sets status=superseded " +
1386
+ "and superseded_by; the successor must already exist. Distinct from " +
1387
+ "vault_deprecate (which sets status=deprecated with an optional " +
1388
+ "successor). Auto-commits." +
1389
+ shadowNote,
1390
+ inputSchema: {
1391
+ type: "object",
1392
+ properties: {
1393
+ old_path: {
1394
+ type: "string",
1395
+ description: "Vault-relative path of the document being superseded",
1396
+ },
1397
+ new_path: {
1398
+ type: "string",
1399
+ description: "Vault-relative path of the successor that replaces it (must exist)",
1400
+ },
1401
+ reason: {
1402
+ type: "string",
1403
+ description: "Optional reason recorded in the commit message",
1404
+ },
1405
+ agent: agentProperty,
1406
+ base_version: baseVersionProperty,
1407
+ },
1408
+ required: ["old_path", "new_path", "agent"],
1409
+ additionalProperties: false,
1410
+ },
1411
+ handler: (vaultRoot, args, access) => vaultSupersede(vaultRoot, args, access),
1412
+ },
1413
+ {
1414
+ name: "vault_merge",
1415
+ title: "Merge two documents into one",
1416
+ annotations: { destructiveHint: true },
1417
+ description: "Combine two source documents into a target and supersede both sources to " +
1418
+ "point at it, all in one commit. The merged body is supplied by the caller " +
1419
+ "— vault_merge does not synthesize prose. target_path may equal path_a (to " +
1420
+ "fold B into A) or be a new path. The target's frontmatter inherits " +
1421
+ "path_a's (provenance becomes 'synthesized') unless overridden. Auto-commits." +
1422
+ shadowNote,
1423
+ inputSchema: {
1424
+ type: "object",
1425
+ properties: {
1426
+ path_a: {
1427
+ type: "string",
1428
+ description: "Vault-relative path of the first source document",
1429
+ },
1430
+ path_b: {
1431
+ type: "string",
1432
+ description: "Vault-relative path of the second source document",
1433
+ },
1434
+ target_path: {
1435
+ type: "string",
1436
+ description: "Vault-relative path of the merge target (may equal path_a, or be new)",
1437
+ },
1438
+ body: {
1439
+ type: "string",
1440
+ description: "The merged markdown body for the target (no frontmatter)",
1441
+ },
1442
+ frontmatter: {
1443
+ type: "object",
1444
+ description: "Optional frontmatter overrides for the target. Defaults to path_a's " +
1445
+ "frontmatter with provenance set to 'synthesized'.",
1446
+ additionalProperties: true,
1447
+ },
1448
+ agent: agentProperty,
1449
+ },
1450
+ required: ["path_a", "path_b", "target_path", "body", "agent"],
1451
+ additionalProperties: false,
1452
+ },
1453
+ handler: (vaultRoot, args, access) => vaultMerge(vaultRoot, args, access),
1454
+ },
847
1455
  ];
848
1456
  //# sourceMappingURL=write.js.map