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.
- package/CHANGELOG.md +99 -0
- package/README.md +4 -1
- 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/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/curation/edges.d.ts +57 -0
- package/dist/curation/edges.d.ts.map +1 -0
- package/dist/curation/edges.js +410 -0
- package/dist/curation/edges.js.map +1 -0
- package/dist/curation/lint.d.ts +2 -0
- package/dist/curation/lint.d.ts.map +1 -1
- package/dist/curation/lint.js +5 -0
- package/dist/curation/lint.js.map +1 -1
- package/dist/curation/provenance.d.ts +1 -0
- package/dist/curation/provenance.d.ts.map +1 -1
- package/dist/curation/provenance.js +1 -0
- package/dist/curation/provenance.js.map +1 -1
- package/dist/curation/shadow.d.ts +58 -0
- package/dist/curation/shadow.d.ts.map +1 -0
- package/dist/curation/shadow.js +211 -0
- package/dist/curation/shadow.js.map +1 -0
- package/dist/curation/staged-actions.d.ts +0 -1
- package/dist/curation/staged-actions.d.ts.map +1 -1
- package/dist/curation/staged-actions.js +7 -11
- package/dist/curation/staged-actions.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/search/reindex.d.ts.map +1 -1
- package/dist/search/reindex.js +4 -0
- package/dist/search/reindex.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/storage/index-db.d.ts +13 -0
- package/dist/storage/index-db.d.ts.map +1 -1
- package/dist/storage/index-db.js +43 -1
- package/dist/storage/index-db.js.map +1 -1
- package/dist/tools/curation.d.ts +2 -0
- package/dist/tools/curation.d.ts.map +1 -1
- package/dist/tools/curation.js +5 -1
- package/dist/tools/curation.js.map +1 -1
- package/dist/tools/edges.d.ts +17 -0
- package/dist/tools/edges.d.ts.map +1 -0
- package/dist/tools/edges.js +351 -0
- package/dist/tools/edges.js.map +1 -0
- package/dist/tools/staged-actions.d.ts +1 -1
- package/dist/tools/staged-actions.d.ts.map +1 -1
- package/dist/tools/staged-actions.js +110 -54
- package/dist/tools/staged-actions.js.map +1 -1
- package/dist/tools/write.d.ts +5 -1
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +612 -4
- package/dist/tools/write.js.map +1 -1
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +17 -1
- package/dist/utils/config.js.map +1 -1
- package/package.json +1 -1
- package/templates/config.yaml +3 -0
- package/templates/reviewer-vault/.daftari/config.yaml +3 -0
package/dist/tools/write.js
CHANGED
|
@@ -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
|