weir-harness 2.1.2 → 2.1.3
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/lib/client.capability-manager.js +467 -2
- package/lib/client.capability-model.js +77 -0
- package/lib/client.js +25 -4
- package/package.json +1 -1
- package/src/capabilities/capability-remote.js +97 -10
- package/src/capabilities/skill-inventory.js +18 -2
- package/src/capabilities/skill-scan-path-registry.js +238 -0
- package/src/capabilities/skill-selection-plugin.js +6 -0
- package/src/capabilities/store/paths.js +3 -0
|
@@ -63,7 +63,15 @@ window.__ModuleLoader__.load({
|
|
|
63
63
|
if (el && el.style) Object.assign(el.style, styles);
|
|
64
64
|
};
|
|
65
65
|
const ring = {
|
|
66
|
-
onFocus: (event) =>
|
|
66
|
+
onFocus: (event) => {
|
|
67
|
+
const el = event?.currentTarget;
|
|
68
|
+
const focused = event?.target ?? el;
|
|
69
|
+
const visible = typeof focused?.matches !== "function" || focused.matches(":focus-visible");
|
|
70
|
+
paint(event, visible
|
|
71
|
+
? { outline: "2px solid var(--dsw-alias-focus-ring, currentColor)", outlineOffset: "1px" }
|
|
72
|
+
: { outline: "none", outlineOffset: "" });
|
|
73
|
+
},
|
|
74
|
+
onPointerDown: (event) => paint(event, { outline: "none", outlineOffset: "" }),
|
|
67
75
|
onBlur: (event) => paint(event, { outline: "none", outlineOffset: "" }),
|
|
68
76
|
};
|
|
69
77
|
const hover = (over, out) => ({
|
|
@@ -115,7 +123,7 @@ window.__ModuleLoader__.load({
|
|
|
115
123
|
borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.10))",
|
|
116
124
|
flex: "none"
|
|
117
125
|
},
|
|
118
|
-
summary: { fontSize: "11px", lineHeight: "16px", color: MUTED, whiteSpace: "nowrap", flex: "
|
|
126
|
+
summary: { fontSize: "11px", lineHeight: "16px", color: MUTED, whiteSpace: "nowrap", flex: "0 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", marginLeft: "2px" },
|
|
119
127
|
close: {
|
|
120
128
|
width: "22px",
|
|
121
129
|
height: "22px",
|
|
@@ -132,6 +140,54 @@ window.__ModuleLoader__.load({
|
|
|
132
140
|
transition: TRANSITION
|
|
133
141
|
}
|
|
134
142
|
},
|
|
143
|
+
path: {
|
|
144
|
+
trigger: {
|
|
145
|
+
minWidth: "22px",
|
|
146
|
+
height: "22px",
|
|
147
|
+
display: "inline-flex",
|
|
148
|
+
alignItems: "center",
|
|
149
|
+
justifyContent: "center",
|
|
150
|
+
gap: "3px",
|
|
151
|
+
flex: "none",
|
|
152
|
+
padding: "0 4px",
|
|
153
|
+
cursor: "pointer",
|
|
154
|
+
background: "none",
|
|
155
|
+
border: "none",
|
|
156
|
+
borderRadius: "var(--dsw-radius-sm, 6px)",
|
|
157
|
+
color: MUTED,
|
|
158
|
+
transition: TRANSITION
|
|
159
|
+
},
|
|
160
|
+
count: {
|
|
161
|
+
fontSize: "9px",
|
|
162
|
+
lineHeight: "12px",
|
|
163
|
+
fontWeight: 600,
|
|
164
|
+
color: LABEL2,
|
|
165
|
+
background: HOVER_BG,
|
|
166
|
+
borderRadius: "var(--dsw-radius-xl, 999px)",
|
|
167
|
+
padding: "0 4px",
|
|
168
|
+
minWidth: "12px",
|
|
169
|
+
textAlign: "center",
|
|
170
|
+
flex: "none"
|
|
171
|
+
},
|
|
172
|
+
overlay: (position) => ({
|
|
173
|
+
position: "fixed",
|
|
174
|
+
zIndex: 1000,
|
|
175
|
+
top: position.top,
|
|
176
|
+
left: position.left,
|
|
177
|
+
width: position.width,
|
|
178
|
+
maxHeight: position.maxHeight,
|
|
179
|
+
boxSizing: "border-box",
|
|
180
|
+
overflowY: "auto",
|
|
181
|
+
overflowX: "hidden",
|
|
182
|
+
background: "var(--dsw-alias-bg-overlay, #fff)",
|
|
183
|
+
border: `1px solid ${BORDER}`,
|
|
184
|
+
borderRadius: "var(--dsw-radius-lg, 12px)",
|
|
185
|
+
boxShadow: "var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,.14))",
|
|
186
|
+
padding: "12px",
|
|
187
|
+
fontSize: "12px",
|
|
188
|
+
color: LABEL1
|
|
189
|
+
})
|
|
190
|
+
},
|
|
135
191
|
tabs: {
|
|
136
192
|
// Segmented control (host SegmentedControl recipe, containerless):
|
|
137
193
|
// inactive segments carry the track fill, the active segment is a
|
|
@@ -446,6 +502,10 @@ window.__ModuleLoader__.load({
|
|
|
446
502
|
// ---- 16x16 stroke icons (worktree-view style, stroke: currentColor) ----
|
|
447
503
|
const ICON_PATHS = {
|
|
448
504
|
search: [["circle", { cx: 7, cy: 7, r: 4.6 }], ["path", { d: "M10.6 10.6L14 14" }]],
|
|
505
|
+
folder: [
|
|
506
|
+
["path", { d: "M2.2 4.2c0-.6.4-1 1-1h3l1.5 1.8h5.1c.6 0 1 .4 1 1v5.8c0 .6-.4 1-1 1H3.2c-.6 0-1-.4-1-1V4.2z" }],
|
|
507
|
+
["path", { d: "M2.2 6h11.6" }]
|
|
508
|
+
],
|
|
449
509
|
x: [["path", { d: "M4 4l8 8M12 4l-8 8" }]],
|
|
450
510
|
warn: [
|
|
451
511
|
["path", { d: "M8 2.6l6 10.4H2l6-10.4z" }],
|
|
@@ -518,6 +578,338 @@ window.__ModuleLoader__.load({
|
|
|
518
578
|
}
|
|
519
579
|
return react_jsx_runtime.jsx(impl, { ...props });
|
|
520
580
|
}
|
|
581
|
+
/** Skills-only path management. All hooks live here so CapabilityManagerPanel
|
|
582
|
+
* keeps its pinned 7/1 hook structure in both shells. */
|
|
583
|
+
function SkillPathManager(props) {
|
|
584
|
+
const model = props.model;
|
|
585
|
+
const t = props.t;
|
|
586
|
+
const [registry, setRegistry] = react.useState(null);
|
|
587
|
+
const [input, setInput] = react.useState("");
|
|
588
|
+
const [reading, setReading] = react.useState(false);
|
|
589
|
+
const [busy, setBusy] = react.useState(null);
|
|
590
|
+
const [feedback, setFeedback] = react.useState(null);
|
|
591
|
+
const [pendingRemove, setPendingRemove] = react.useState(null);
|
|
592
|
+
const [returnFocusTo, setReturnFocusTo] = react.useState(null);
|
|
593
|
+
const [open, setOpen] = react.useState(false);
|
|
594
|
+
const [position, setPosition] = react.useState({ top: 12, left: 12, width: 360, maxHeight: 420 });
|
|
595
|
+
const readRequest = react.useRef(0);
|
|
596
|
+
const operationRequest = react.useRef(0);
|
|
597
|
+
const operationRunning = react.useRef(false);
|
|
598
|
+
const alive = react.useRef(true);
|
|
599
|
+
const triggerRef = react.useRef(null);
|
|
600
|
+
const overlayRef = react.useRef(null);
|
|
601
|
+
const inputRef = react.useRef(null);
|
|
602
|
+
|
|
603
|
+
const refreshPaths = async () => {
|
|
604
|
+
const request = ++readRequest.current;
|
|
605
|
+
setReading(true);
|
|
606
|
+
let response;
|
|
607
|
+
try {
|
|
608
|
+
response = typeof props.fetchSkillPaths === "function"
|
|
609
|
+
? await props.fetchSkillPaths(props.sessionId)
|
|
610
|
+
: { status: "unavailable", saved: false, error: { code: "unavailable", message: "Skill scan path reads are unavailable on this client." } };
|
|
611
|
+
} catch (error) {
|
|
612
|
+
response = { status: "call-threw", saved: false, error: { code: "call-threw", message: error instanceof Error ? error.message : String(error) } };
|
|
613
|
+
}
|
|
614
|
+
if (!alive.current || request !== readRequest.current) return null;
|
|
615
|
+
setRegistry(current => model.skillPathsAfterRead(current, response));
|
|
616
|
+
setReading(false);
|
|
617
|
+
return response;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const closeManager = () => {
|
|
621
|
+
if (busy !== null) return;
|
|
622
|
+
setOpen(false);
|
|
623
|
+
triggerRef.current?.focus();
|
|
624
|
+
};
|
|
625
|
+
const openManager = () => {
|
|
626
|
+
if (open) return;
|
|
627
|
+
const viewportWidth = window.innerWidth;
|
|
628
|
+
const viewportHeight = window.innerHeight;
|
|
629
|
+
const rect = triggerRef.current?.getBoundingClientRect();
|
|
630
|
+
const width = Math.max(0, Math.min(360, viewportWidth - 24));
|
|
631
|
+
const left = Math.max(12, Math.min((rect?.right ?? viewportWidth - 12) - width, viewportWidth - width - 12));
|
|
632
|
+
const top = Math.max(12, Math.min((rect?.bottom ?? 12) + 8, Math.max(12, viewportHeight - 12)));
|
|
633
|
+
setPosition({ top, left, width, maxHeight: Math.max(0, viewportHeight - top - 12) });
|
|
634
|
+
setOpen(true);
|
|
635
|
+
void refreshPaths();
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
react.useEffect(() => {
|
|
639
|
+
alive.current = true;
|
|
640
|
+
setRegistry(null);
|
|
641
|
+
setInput("");
|
|
642
|
+
setOpen(false);
|
|
643
|
+
setFeedback(null);
|
|
644
|
+
setPendingRemove(null);
|
|
645
|
+
setReturnFocusTo(null);
|
|
646
|
+
setBusy(null);
|
|
647
|
+
operationRunning.current = false;
|
|
648
|
+
void refreshPaths();
|
|
649
|
+
return () => {
|
|
650
|
+
alive.current = false;
|
|
651
|
+
++readRequest.current;
|
|
652
|
+
++operationRequest.current;
|
|
653
|
+
operationRunning.current = false;
|
|
654
|
+
};
|
|
655
|
+
}, [props.sessionId]);
|
|
656
|
+
|
|
657
|
+
react.useEffect(() => {
|
|
658
|
+
if (!open) return undefined;
|
|
659
|
+
const document = window.document;
|
|
660
|
+
const onOutsidePointer = (event) => {
|
|
661
|
+
if (overlayRef.current?.contains(event.target) || triggerRef.current?.contains(event.target)) return;
|
|
662
|
+
closeManager();
|
|
663
|
+
};
|
|
664
|
+
const onViewportChange = () => { closeManager(); };
|
|
665
|
+
const onScroll = (event) => {
|
|
666
|
+
if (overlayRef.current?.contains(event.target)) return;
|
|
667
|
+
closeManager();
|
|
668
|
+
};
|
|
669
|
+
document.addEventListener("pointerdown", onOutsidePointer);
|
|
670
|
+
window.addEventListener("resize", onViewportChange);
|
|
671
|
+
window.addEventListener("scroll", onScroll, true);
|
|
672
|
+
return () => {
|
|
673
|
+
document.removeEventListener("pointerdown", onOutsidePointer);
|
|
674
|
+
window.removeEventListener("resize", onViewportChange);
|
|
675
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
676
|
+
};
|
|
677
|
+
}, [open, busy]);
|
|
678
|
+
|
|
679
|
+
react.useEffect(() => {
|
|
680
|
+
if (open && busy === null && !reading && registry?.status === "ok") inputRef.current?.focus();
|
|
681
|
+
}, [open, busy, reading, registry?.status]);
|
|
682
|
+
|
|
683
|
+
const cancelRemove = (path) => {
|
|
684
|
+
if (busy !== null) return;
|
|
685
|
+
setPendingRemove(null);
|
|
686
|
+
setReturnFocusTo(path);
|
|
687
|
+
setFeedback(null);
|
|
688
|
+
};
|
|
689
|
+
const runMutation = async (kind, path) => {
|
|
690
|
+
if (operationRunning.current || busy !== null || reading || registry?.status !== "ok") return;
|
|
691
|
+
const verb = kind === "add" ? props.skillPathAdd : props.skillPathRemove;
|
|
692
|
+
if (typeof verb !== "function") {
|
|
693
|
+
setFeedback({ kind: "error", operation: kind, outcome: model.skillPathMutationOutcomeOf({ status: "unavailable", saved: false, error: { message: "Skill scan path mutations are unavailable on this client." } }) });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const request = ++operationRequest.current;
|
|
697
|
+
operationRunning.current = true;
|
|
698
|
+
setBusy(kind);
|
|
699
|
+
setFeedback(null);
|
|
700
|
+
setReturnFocusTo(null);
|
|
701
|
+
let response;
|
|
702
|
+
try {
|
|
703
|
+
response = await verb(props.sessionId, { path, expectedRevision: registry.revision });
|
|
704
|
+
} catch (error) {
|
|
705
|
+
response = { status: "call-threw", saved: false, error: { code: "call-threw", message: error instanceof Error ? error.message : String(error) } };
|
|
706
|
+
}
|
|
707
|
+
if (!alive.current || request !== operationRequest.current) return;
|
|
708
|
+
const outcome = model.skillPathMutationOutcomeOf(response);
|
|
709
|
+
if (outcome.snapshot !== null) setRegistry(outcome.snapshot);
|
|
710
|
+
if (outcome.saved) {
|
|
711
|
+
if (kind === "add") setInput("");
|
|
712
|
+
else setPendingRemove(null);
|
|
713
|
+
}
|
|
714
|
+
setFeedback({ kind: outcome.saved ? (outcome.phase === "degraded" ? "warning" : "success") : "error", operation: kind, outcome });
|
|
715
|
+
try {
|
|
716
|
+
const refreshes = [];
|
|
717
|
+
if (outcome.refreshPaths) refreshes.push(refreshPaths());
|
|
718
|
+
if (outcome.refreshCapabilities && typeof props.refreshCapabilities === "function") refreshes.push(props.refreshCapabilities());
|
|
719
|
+
await Promise.all(refreshes);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (alive.current && request === operationRequest.current) setFeedback(current => ({ ...current, refreshError: error instanceof Error ? error.message : String(error) }));
|
|
722
|
+
} finally {
|
|
723
|
+
if (alive.current && request === operationRequest.current) {
|
|
724
|
+
operationRunning.current = false;
|
|
725
|
+
setBusy(null);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
const detail = (text) => {
|
|
730
|
+
const message = feedback?.outcome?.error?.message;
|
|
731
|
+
return typeof message === "string" && message !== "" && !text.includes(message) ? `${text} ${message}` : text;
|
|
732
|
+
};
|
|
733
|
+
let feedbackText = null;
|
|
734
|
+
if (feedback) {
|
|
735
|
+
const phase = feedback.outcome.phase;
|
|
736
|
+
if (phase === "saved") feedbackText = feedback.operation === "add"
|
|
737
|
+
? t("capability.skills.paths.added", "Directory added. Newly discovered Skills remain unselected until you select and Apply them.")
|
|
738
|
+
: t("capability.skills.paths.removed", "Directory removed. Selection drafts, applied capabilities, and defaults were not rewritten.");
|
|
739
|
+
else if (phase === "degraded") feedbackText = detail(t("capability.skills.paths.degraded", "The path change was saved, but the host reported a post-commit problem. Fresh registry, Skill listing, and receipt reads were requested; no rollback is claimed."));
|
|
740
|
+
else if (phase === "validation-failed") feedbackText = detail(t("capability.skills.paths.validation", "The server rejected this directory. Enter an existing readable absolute directory or ~/directory; your input was kept and nothing was saved."));
|
|
741
|
+
else if (phase === "duplicate") feedbackText = detail(t("capability.skills.paths.duplicate", "This canonical directory is already registered. Current paths were re-read; your input was kept and no duplicate was saved."));
|
|
742
|
+
else if (phase === "not-found") feedbackText = detail(t("capability.skills.paths.notFound", "That canonical directory is no longer registered. Current paths were re-read; nothing was removed."));
|
|
743
|
+
else if (phase === "revision-conflict") feedbackText = detail(t("capability.skills.paths.conflict", "The path registry changed elsewhere. Current paths were re-read; your input was kept and was not merged. Review the registry before retrying."));
|
|
744
|
+
else if (phase === "indeterminate") feedbackText = detail(t("capability.skills.paths.indeterminate", "The operation outcome could not be confirmed. Fresh registry, Skill listing, and receipt reads were requested; your input was kept and no rollback is claimed."));
|
|
745
|
+
else if (phase === "unavailable") feedbackText = detail(t("capability.skills.paths.unavailable", "The Skill scan path registry is unavailable. No path change was saved."));
|
|
746
|
+
else feedbackText = detail(t("capability.skills.paths.failed", "The path operation failed. No saved change is claimed; your input was kept."));
|
|
747
|
+
if (feedback.refreshError) feedbackText += ` ${t("capability.skills.paths.refreshFailed", "A follow-up refresh failed: {message}").replace("{message}", feedback.refreshError)}`;
|
|
748
|
+
}
|
|
749
|
+
const unavailable = registry?.status !== "ok";
|
|
750
|
+
const controlsDisabled = busy !== null || reading || unavailable;
|
|
751
|
+
const liveText = busy === "add" ? t("capability.skills.paths.adding", "Adding…")
|
|
752
|
+
: busy === "remove" ? t("capability.skills.paths.removing", "Removing…")
|
|
753
|
+
: reading && registry === null ? t("capability.skills.paths.loading", "Loading scan paths…") : null;
|
|
754
|
+
const pathRow = (path) => {
|
|
755
|
+
const confirming = pendingRemove === path;
|
|
756
|
+
return react_jsx_runtime.jsx("div", {
|
|
757
|
+
style: { ...S.list.card, margin: "4px 0", padding: "6px 8px" },
|
|
758
|
+
"data-weir-skill-path": path,
|
|
759
|
+
children: confirming ? react_jsx_runtime.jsxs("div", {
|
|
760
|
+
role: "group",
|
|
761
|
+
"aria-label": t("capability.skills.paths.confirmTitle", "Remove this Skill scan path?"),
|
|
762
|
+
onKeyDown: (event) => {
|
|
763
|
+
if (event.key === "Escape" && busy === null) {
|
|
764
|
+
event.preventDefault();
|
|
765
|
+
event.stopPropagation();
|
|
766
|
+
cancelRemove(path);
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
children: [
|
|
770
|
+
react_jsx_runtime.jsx("div", { style: S.form.title, children: t("capability.skills.paths.confirmTitle", "Remove this Skill scan path?") }),
|
|
771
|
+
react_jsx_runtime.jsxs("div", { style: { fontSize: "11px", lineHeight: "16px", color: LABEL2, overflowWrap: "anywhere" }, children: [
|
|
772
|
+
t("capability.skills.paths.confirmBody", "Remove this canonical directory from the Profile? Selection drafts, applied capabilities, and defaults are not rewritten: "),
|
|
773
|
+
react_jsx_runtime.jsx("code", { style: { color: LABEL1, fontFamily: "var(--dsw-font-markdown-code-block-font-family, ui-monospace, monospace)" }, children: path })
|
|
774
|
+
] }),
|
|
775
|
+
react_jsx_runtime.jsxs("div", { style: { ...S.form.actions, marginTop: "6px" }, children: [
|
|
776
|
+
react_jsx_runtime.jsx("button", {
|
|
777
|
+
type: "button", autoFocus: true, disabled: busy !== null,
|
|
778
|
+
style: busy !== null ? { ...S.form.primary, ...S.footer.disabled } : S.form.primary,
|
|
779
|
+
...ring,
|
|
780
|
+
onClick: () => { void runMutation("remove", path); },
|
|
781
|
+
children: busy === "remove" ? t("capability.skills.paths.removing", "Removing…") : t("capability.skills.paths.confirmRemove", "Remove path")
|
|
782
|
+
}),
|
|
783
|
+
react_jsx_runtime.jsx("button", {
|
|
784
|
+
type: "button", disabled: busy !== null, style: S.form.ghost, ...ring,
|
|
785
|
+
onClick: () => cancelRemove(path),
|
|
786
|
+
children: t("capability.skills.paths.cancel", "Cancel")
|
|
787
|
+
})
|
|
788
|
+
] })
|
|
789
|
+
]
|
|
790
|
+
}) : react_jsx_runtime.jsxs("div", {
|
|
791
|
+
style: { display: "flex", alignItems: "center", gap: "8px", minWidth: 0 },
|
|
792
|
+
children: [
|
|
793
|
+
react_jsx_runtime.jsx("code", {
|
|
794
|
+
title: path,
|
|
795
|
+
style: { flex: 1, minWidth: 0, color: LABEL1, fontSize: "11px", lineHeight: "16px", fontFamily: "var(--dsw-font-markdown-code-block-font-family, ui-monospace, monospace)", overflowWrap: "anywhere" },
|
|
796
|
+
children: path
|
|
797
|
+
}),
|
|
798
|
+
react_jsx_runtime.jsx("button", {
|
|
799
|
+
type: "button", autoFocus: returnFocusTo === path, disabled: controlsDisabled,
|
|
800
|
+
style: controlsDisabled ? { ...S.form.ghost, ...S.footer.disabled } : S.form.ghost,
|
|
801
|
+
...ring,
|
|
802
|
+
"aria-label": t("capability.skills.paths.removeAria", "Remove scan path {path}").replace("{path}", path),
|
|
803
|
+
onClick: () => { setFeedback(null); setReturnFocusTo(null); setPendingRemove(path); },
|
|
804
|
+
children: t("capability.skills.paths.remove", "Remove")
|
|
805
|
+
})
|
|
806
|
+
]
|
|
807
|
+
})
|
|
808
|
+
}, path);
|
|
809
|
+
};
|
|
810
|
+
return react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
|
|
811
|
+
children: [
|
|
812
|
+
react_jsx_runtime.jsxs("button", {
|
|
813
|
+
type: "button",
|
|
814
|
+
ref: triggerRef,
|
|
815
|
+
style: S.path.trigger,
|
|
816
|
+
...ring,
|
|
817
|
+
...hover(HOVER_BG, "none"),
|
|
818
|
+
onClick: openManager,
|
|
819
|
+
"aria-label": t("capability.skills.paths.manage", "Manage Skill scan paths"),
|
|
820
|
+
title: t("capability.skills.paths.manage", "Manage Skill scan paths"),
|
|
821
|
+
"aria-haspopup": "dialog",
|
|
822
|
+
"aria-expanded": open,
|
|
823
|
+
"data-weir-skill-path-trigger": "",
|
|
824
|
+
children: [
|
|
825
|
+
react_jsx_runtime.jsx(Icon, { name: "folder", size: 13 }),
|
|
826
|
+
registry?.status === "ok" ? react_jsx_runtime.jsx("span", {
|
|
827
|
+
style: S.path.count,
|
|
828
|
+
"aria-hidden": "true",
|
|
829
|
+
"data-weir-skill-path-count": "",
|
|
830
|
+
children: String(registry.paths.length)
|
|
831
|
+
}) : null
|
|
832
|
+
]
|
|
833
|
+
}),
|
|
834
|
+
open ? react_jsx_runtime.jsxs("section", {
|
|
835
|
+
ref: overlayRef,
|
|
836
|
+
role: "dialog",
|
|
837
|
+
"aria-modal": false,
|
|
838
|
+
style: S.path.overlay(position),
|
|
839
|
+
"aria-label": t("capability.skills.paths.title", "Custom Skill scan paths"),
|
|
840
|
+
"aria-busy": busy !== null || reading,
|
|
841
|
+
"data-weir-skill-path-manager": "",
|
|
842
|
+
onKeyDown: (event) => {
|
|
843
|
+
if (event.key !== "Escape" || busy !== null) return;
|
|
844
|
+
event.preventDefault();
|
|
845
|
+
event.stopPropagation();
|
|
846
|
+
if (pendingRemove !== null) cancelRemove(pendingRemove);
|
|
847
|
+
else closeManager();
|
|
848
|
+
},
|
|
849
|
+
children: [
|
|
850
|
+
react_jsx_runtime.jsxs("div", { style: S.list.cardHead, children: [
|
|
851
|
+
react_jsx_runtime.jsx("span", { children: t("capability.skills.paths.title", "Custom Skill scan paths") }),
|
|
852
|
+
react_jsx_runtime.jsxs("div", { style: { display: "flex", alignItems: "center", gap: "4px" }, children: [
|
|
853
|
+
react_jsx_runtime.jsx("button", {
|
|
854
|
+
type: "button", onClick: () => { void refreshPaths(); }, disabled: busy !== null || reading,
|
|
855
|
+
style: busy !== null || reading ? { ...S.form.ghost, ...S.footer.disabled } : S.form.ghost,
|
|
856
|
+
...ring,
|
|
857
|
+
children: t("capability.skills.paths.refresh", "Refresh paths")
|
|
858
|
+
}),
|
|
859
|
+
react_jsx_runtime.jsx("button", {
|
|
860
|
+
type: "button", onClick: closeManager, disabled: busy !== null,
|
|
861
|
+
style: busy !== null ? { ...S.header.close, ...S.footer.disabled } : S.header.close,
|
|
862
|
+
...ring,
|
|
863
|
+
"aria-label": t("capability.close", "Close"),
|
|
864
|
+
title: t("capability.close", "Close"),
|
|
865
|
+
children: react_jsx_runtime.jsx(Icon, { name: "x", size: 12 })
|
|
866
|
+
})
|
|
867
|
+
] })
|
|
868
|
+
] }),
|
|
869
|
+
react_jsx_runtime.jsx("div", { style: { fontSize: "11px", lineHeight: "16px", color: MUTED, marginBottom: "6px" }, children: t("capability.skills.paths.description", "Profile-scoped directories shared by Weir sessions. Path changes never select Skills, Apply a draft, or rewrite defaults.") }),
|
|
870
|
+
react_jsx_runtime.jsxs("form", {
|
|
871
|
+
"aria-label": t("capability.skills.paths.addLabel", "Skill scan directory"),
|
|
872
|
+
onSubmit: (event) => { event.preventDefault(); void runMutation("add", input); },
|
|
873
|
+
children: [
|
|
874
|
+
react_jsx_runtime.jsx("label", {
|
|
875
|
+
style: { display: "flex", flexDirection: "column", gap: "3px", ...S.form.title },
|
|
876
|
+
children: [
|
|
877
|
+
t("capability.skills.paths.addLabel", "Skill scan directory"),
|
|
878
|
+
react_jsx_runtime.jsx("input", {
|
|
879
|
+
ref: inputRef,
|
|
880
|
+
value: input, onChange: (event) => setInput(event.target.value), disabled: controlsDisabled,
|
|
881
|
+
placeholder: t("capability.skills.paths.placeholder", "~/skills or /absolute/path"),
|
|
882
|
+
style: S.form.input, ...ring,
|
|
883
|
+
"aria-invalid": feedback?.kind === "error" && feedback.operation === "add" ? true : undefined
|
|
884
|
+
})
|
|
885
|
+
]
|
|
886
|
+
}),
|
|
887
|
+
react_jsx_runtime.jsx("div", { style: { ...S.form.actions, marginTop: "6px" }, children: react_jsx_runtime.jsx("button", {
|
|
888
|
+
type: "submit", disabled: controlsDisabled,
|
|
889
|
+
style: controlsDisabled ? { ...S.form.primary, ...S.footer.disabled } : S.form.primary,
|
|
890
|
+
...ring,
|
|
891
|
+
children: busy === "add" ? t("capability.skills.paths.adding", "Adding…") : t("capability.skills.paths.add", "Add path")
|
|
892
|
+
}) })
|
|
893
|
+
]
|
|
894
|
+
}),
|
|
895
|
+
liveText ? react_jsx_runtime.jsx("div", { role: "status", "aria-live": "polite", style: { ...S.form.title, marginTop: "6px" }, children: liveText }) : null,
|
|
896
|
+
registry?.error ? react_jsx_runtime.jsx("div", { role: "alert", style: { ...S.form.error, marginTop: "6px" }, children: `${t("capability.skills.paths.readFailed", "The path registry could not be read. Existing rows, when present, are stale.")} ${registry.error.message}` }) : null,
|
|
897
|
+
feedbackText ? react_jsx_runtime.jsx("div", {
|
|
898
|
+
role: feedback.kind === "success" ? "status" : "alert",
|
|
899
|
+
"aria-live": feedback.kind === "success" ? "polite" : "assertive",
|
|
900
|
+
"data-weir-skill-path-feedback": feedback.kind,
|
|
901
|
+
style: { ...(feedback.kind === "success" ? S.form.title : S.form.error), marginTop: "6px", color: feedback.kind === "success" ? SUCCESS : feedback.kind === "warning" ? WARN_TEXT : DANGER },
|
|
902
|
+
children: feedbackText
|
|
903
|
+
}) : null,
|
|
904
|
+
registry?.status === "ok" && registry.paths.length === 0
|
|
905
|
+
? react_jsx_runtime.jsx("div", { style: { ...S.form.title, marginTop: "6px" }, children: t("capability.skills.paths.empty", "No custom scan paths are registered.") })
|
|
906
|
+
: null,
|
|
907
|
+
...(registry?.paths ?? []).map(pathRow)
|
|
908
|
+
]
|
|
909
|
+
}) : null
|
|
910
|
+
]
|
|
911
|
+
});
|
|
912
|
+
}
|
|
521
913
|
function CapabilityManagerPanel(props) {
|
|
522
914
|
const model = props.model;
|
|
523
915
|
// Echo-guard like the Badge: an unregistered dictionary entry must
|
|
@@ -720,6 +1112,11 @@ window.__ModuleLoader__.load({
|
|
|
720
1112
|
];
|
|
721
1113
|
const selectionDisabled = draft === null || commit?.phase === "submitting";
|
|
722
1114
|
const addBlock = null;
|
|
1115
|
+
const pathManager = tab === "skills" ? react_jsx_runtime.jsx(SkillPathManager, {
|
|
1116
|
+
sessionId: props.sessionId, model, t,
|
|
1117
|
+
fetchSkillPaths: props.fetchSkillPaths, skillPathAdd: props.skillPathAdd, skillPathRemove: props.skillPathRemove,
|
|
1118
|
+
refreshCapabilities: refreshAll
|
|
1119
|
+
}) : null;
|
|
723
1120
|
const skillRow = (skill, index) => {
|
|
724
1121
|
const selected = skillKeys.has(model.skillEntryKeyOf(skill.identity ?? skill.name));
|
|
725
1122
|
const selectedConflict = selectedKeys.has(model.skillEntryKeyOf(skill.identity));
|
|
@@ -836,6 +1233,7 @@ window.__ModuleLoader__.load({
|
|
|
836
1233
|
react_jsx_runtime.jsx("input", { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t("capability.search", "Search"), style: S.search.input, ...ring })
|
|
837
1234
|
]
|
|
838
1235
|
}),
|
|
1236
|
+
pathManager,
|
|
839
1237
|
typeof props.onClose === "function"
|
|
840
1238
|
? react_jsx_runtime.jsx("button", {
|
|
841
1239
|
type: "button",
|
|
@@ -884,6 +1282,73 @@ window.__ModuleLoader__.load({
|
|
|
884
1282
|
] }) : null
|
|
885
1283
|
] });
|
|
886
1284
|
}
|
|
1285
|
+
// View-owned locale data face. The entry merges this with the model's
|
|
1286
|
+
// established capability dictionary when either shared shell arrives.
|
|
1287
|
+
const dictionaries = {
|
|
1288
|
+
en: {
|
|
1289
|
+
"capability.skills.paths.title": "Custom Skill scan paths",
|
|
1290
|
+
"capability.skills.paths.manage": "Manage Skill scan paths",
|
|
1291
|
+
"capability.skills.paths.description": "Profile-scoped directories shared by Weir sessions. Path changes never select Skills, Apply a draft, or rewrite defaults.",
|
|
1292
|
+
"capability.skills.paths.addLabel": "Skill scan directory",
|
|
1293
|
+
"capability.skills.paths.placeholder": "~/skills or /absolute/path",
|
|
1294
|
+
"capability.skills.paths.add": "Add path",
|
|
1295
|
+
"capability.skills.paths.adding": "Adding…",
|
|
1296
|
+
"capability.skills.paths.removing": "Removing…",
|
|
1297
|
+
"capability.skills.paths.loading": "Loading scan paths…",
|
|
1298
|
+
"capability.skills.paths.refresh": "Refresh paths",
|
|
1299
|
+
"capability.skills.paths.refreshFailed": "A follow-up refresh failed: {message}",
|
|
1300
|
+
"capability.skills.paths.readFailed": "The path registry could not be read. Existing rows, when present, are stale.",
|
|
1301
|
+
"capability.skills.paths.empty": "No custom scan paths are registered.",
|
|
1302
|
+
"capability.skills.paths.added": "Directory added. Newly discovered Skills remain unselected until you select and Apply them.",
|
|
1303
|
+
"capability.skills.paths.removed": "Directory removed. Selection drafts, applied capabilities, and defaults were not rewritten.",
|
|
1304
|
+
"capability.skills.paths.degraded": "The path change was saved, but the host reported a post-commit problem. Fresh registry, Skill listing, and receipt reads were requested; no rollback is claimed.",
|
|
1305
|
+
"capability.skills.paths.validation": "The server rejected this directory. Enter an existing readable absolute directory or ~/directory; your input was kept and nothing was saved.",
|
|
1306
|
+
"capability.skills.paths.duplicate": "This canonical directory is already registered. Current paths were re-read; your input was kept and no duplicate was saved.",
|
|
1307
|
+
"capability.skills.paths.notFound": "That canonical directory is no longer registered. Current paths were re-read; nothing was removed.",
|
|
1308
|
+
"capability.skills.paths.conflict": "The path registry changed elsewhere. Current paths were re-read; your input was kept and was not merged. Review the registry before retrying.",
|
|
1309
|
+
"capability.skills.paths.indeterminate": "The operation outcome could not be confirmed. Fresh registry, Skill listing, and receipt reads were requested; your input was kept and no rollback is claimed.",
|
|
1310
|
+
"capability.skills.paths.unavailable": "The Skill scan path registry is unavailable. No path change was saved.",
|
|
1311
|
+
"capability.skills.paths.failed": "The path operation failed. No saved change is claimed; your input was kept.",
|
|
1312
|
+
"capability.skills.paths.remove": "Remove",
|
|
1313
|
+
"capability.skills.paths.removeAria": "Remove scan path {path}",
|
|
1314
|
+
"capability.skills.paths.confirmTitle": "Remove this Skill scan path?",
|
|
1315
|
+
"capability.skills.paths.confirmBody": "Remove this canonical directory from the Profile? Selection drafts, applied capabilities, and defaults are not rewritten: ",
|
|
1316
|
+
"capability.skills.paths.confirmRemove": "Remove path",
|
|
1317
|
+
"capability.skills.paths.cancel": "Cancel"
|
|
1318
|
+
},
|
|
1319
|
+
zh: {
|
|
1320
|
+
"capability.skills.paths.title": "自定义技能扫描路径",
|
|
1321
|
+
"capability.skills.paths.manage": "管理技能扫描路径",
|
|
1322
|
+
"capability.skills.paths.description": "由 Weir 会话共享的 Profile 级目录。路径变更不会选择技能、应用草稿或改写默认值。",
|
|
1323
|
+
"capability.skills.paths.addLabel": "技能扫描目录",
|
|
1324
|
+
"capability.skills.paths.placeholder": "~/skills 或 /absolute/path",
|
|
1325
|
+
"capability.skills.paths.add": "添加路径",
|
|
1326
|
+
"capability.skills.paths.adding": "正在添加…",
|
|
1327
|
+
"capability.skills.paths.removing": "正在移除…",
|
|
1328
|
+
"capability.skills.paths.loading": "正在加载扫描路径…",
|
|
1329
|
+
"capability.skills.paths.refresh": "刷新路径",
|
|
1330
|
+
"capability.skills.paths.refreshFailed": "后续刷新失败:{message}",
|
|
1331
|
+
"capability.skills.paths.readFailed": "无法读取路径注册表;如有既有行,现已过期。",
|
|
1332
|
+
"capability.skills.paths.empty": "尚未登记自定义扫描路径。",
|
|
1333
|
+
"capability.skills.paths.added": "目录已添加。新发现的技能保持未选状态,需手动选择并应用后才会启用。",
|
|
1334
|
+
"capability.skills.paths.removed": "目录已移除。选择草稿、已应用能力和默认值均未被改写。",
|
|
1335
|
+
"capability.skills.paths.degraded": "路径变更已保存,但宿主报告了提交后检查问题。已请求重新读取注册表、技能清单和回执;不声称已回滚。",
|
|
1336
|
+
"capability.skills.paths.validation": "服务端拒绝了此目录。请输入存在且可读的绝对目录或 ~/directory;输入已保留,未保存任何变更。",
|
|
1337
|
+
"capability.skills.paths.duplicate": "此规范化目录已登记。已重新读取当前路径;输入已保留,未保存重复项。",
|
|
1338
|
+
"capability.skills.paths.notFound": "此规范化目录已不在注册表中。已重新读取当前路径;未移除任何项目。",
|
|
1339
|
+
"capability.skills.paths.conflict": "路径注册表已在别处变更。已重新读取当前路径;输入已保留且未合并。重试前请核对注册表。",
|
|
1340
|
+
"capability.skills.paths.indeterminate": "无法确认操作结果。已请求重新读取注册表、技能清单和回执;输入已保留,不声称已回滚。",
|
|
1341
|
+
"capability.skills.paths.unavailable": "技能扫描路径注册表不可用。未保存路径变更。",
|
|
1342
|
+
"capability.skills.paths.failed": "路径操作失败。不声称变更已保存;输入已保留。",
|
|
1343
|
+
"capability.skills.paths.remove": "移除",
|
|
1344
|
+
"capability.skills.paths.removeAria": "移除扫描路径 {path}",
|
|
1345
|
+
"capability.skills.paths.confirmTitle": "移除此技能扫描路径?",
|
|
1346
|
+
"capability.skills.paths.confirmBody": "从此 Profile 移除以下规范化目录?选择草稿、已应用能力和默认值均不会被改写:",
|
|
1347
|
+
"capability.skills.paths.confirmRemove": "移除路径",
|
|
1348
|
+
"capability.skills.paths.cancel": "取消"
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
exports.dictionaries = dictionaries;
|
|
887
1352
|
exports.CapabilityManagerPanel = CapabilityManagerPanel;
|
|
888
1353
|
return module.exports;
|
|
889
1354
|
}
|
|
@@ -83,6 +83,81 @@ window.__ModuleLoader__.load({
|
|
|
83
83
|
return "unsupported";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// ---- Profile-scoped Skill scan paths (add-custom-skill-scan-paths D5) ----
|
|
87
|
+
// This registry has its OWN revision/CAS. None of these helpers reads or
|
|
88
|
+
// writes the selection draft revision; receipt coordination remains solely
|
|
89
|
+
// draftAfterRefresh's job below.
|
|
90
|
+
const skillPathListOf = (value) => Array.isArray(value)
|
|
91
|
+
? value.filter(path => typeof path === "string" && path !== "")
|
|
92
|
+
: null;
|
|
93
|
+
const skillPathRevisionOf = (value, fallback = 0) => Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
94
|
+
const skillPathErrorOf = (response, status) => {
|
|
95
|
+
const error = response?.error;
|
|
96
|
+
return {
|
|
97
|
+
code: typeof error?.code === "string" && error.code !== "" ? error.code : status,
|
|
98
|
+
message: typeof error?.message === "string" && error.message !== "" ? error.message : "The Skill scan path registry is unavailable."
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
/** Normalize a registry read. Absent successful lists mean an empty registry;
|
|
102
|
+
* failed re-reads retain last-good paths only as explicitly stale rows. */
|
|
103
|
+
function skillPathsAfterRead(current, response) {
|
|
104
|
+
const currentPaths = skillPathListOf(current?.paths) ?? [];
|
|
105
|
+
const currentRevision = skillPathRevisionOf(current?.revision);
|
|
106
|
+
if (response?.status === "ok") {
|
|
107
|
+
return {
|
|
108
|
+
status: "ok",
|
|
109
|
+
revision: skillPathRevisionOf(response.revision, currentRevision),
|
|
110
|
+
paths: skillPathListOf(response.paths) ?? [],
|
|
111
|
+
error: null,
|
|
112
|
+
stale: false
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const status = typeof response?.status === "string" && response.status !== "" ? response.status : "unavailable";
|
|
116
|
+
return {
|
|
117
|
+
status,
|
|
118
|
+
revision: skillPathRevisionOf(response?.revision, currentRevision),
|
|
119
|
+
paths: skillPathListOf(response?.paths) ?? currentPaths,
|
|
120
|
+
error: skillPathErrorOf(response, status),
|
|
121
|
+
stale: current !== null && current !== undefined
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Normalize every path mutation without ever inferring a save from status
|
|
125
|
+
* alone. Duplicate/not-found/conflict re-read only the registry; durable
|
|
126
|
+
* (including landed-but-degraded) outcomes re-read registry + capabilities. */
|
|
127
|
+
function skillPathMutationOutcomeOf(response) {
|
|
128
|
+
const status = typeof response?.status === "string" && response.status !== "" ? response.status : "unavailable";
|
|
129
|
+
const saved = response?.saved === true && ["saved", "removed", "invalidation-failed", "owner-changed"].includes(status);
|
|
130
|
+
const degraded = saved && (status === "invalidation-failed" || status === "owner-changed");
|
|
131
|
+
const uncertain = status === "indeterminate" || status === "call-threw" || status === "call-failed" || status.startsWith("gateway/");
|
|
132
|
+
const phase = saved ? (degraded ? "degraded" : "saved")
|
|
133
|
+
: status === "revision-conflict" ? "revision-conflict"
|
|
134
|
+
: status === "validation-failed" ? "validation-failed"
|
|
135
|
+
: status === "duplicate" ? "duplicate"
|
|
136
|
+
: status === "not-found" ? "not-found"
|
|
137
|
+
: uncertain ? "indeterminate"
|
|
138
|
+
: ["unavailable", "unreadable", "unsupported", "locked", "write-failed", "storage-failed"].includes(status) ? "unavailable" : "failed";
|
|
139
|
+
const current = response?.current && typeof response.current === "object" && !Array.isArray(response.current) ? response.current : null;
|
|
140
|
+
const rawPaths = response?.paths === undefined ? current?.paths : response.paths;
|
|
141
|
+
const paths = skillPathListOf(rawPaths);
|
|
142
|
+
const rawRevision = response?.revision === undefined ? current?.revision : response.revision;
|
|
143
|
+
const hasRevision = Number.isSafeInteger(rawRevision) && rawRevision >= 0;
|
|
144
|
+
const snapshot = paths !== null && hasRevision
|
|
145
|
+
? { status: "ok", revision: rawRevision, paths, error: null, stale: false }
|
|
146
|
+
: null;
|
|
147
|
+
const refreshPaths = saved || phase === "revision-conflict" || phase === "duplicate" || phase === "not-found" || phase === "indeterminate";
|
|
148
|
+
return {
|
|
149
|
+
phase,
|
|
150
|
+
status,
|
|
151
|
+
saved,
|
|
152
|
+
revision: hasRevision ? rawRevision : null,
|
|
153
|
+
paths,
|
|
154
|
+
snapshot,
|
|
155
|
+
error: saved && !degraded ? null : skillPathErrorOf(response, status),
|
|
156
|
+
refreshPaths,
|
|
157
|
+
refreshCapabilities: saved || phase === "indeterminate"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
86
161
|
/**
|
|
87
162
|
* Second-window convergence (12.5): an agent-preset/selected frame
|
|
88
163
|
* refreshes THIS session's Badge only when the frame names the same
|
|
@@ -1347,6 +1422,8 @@ window.__ModuleLoader__.load({
|
|
|
1347
1422
|
exports.skillRowOf = skillRowOf;
|
|
1348
1423
|
exports.partitionManagerListing = partitionManagerListing;
|
|
1349
1424
|
exports.managerConditionState = managerConditionState;
|
|
1425
|
+
exports.skillPathsAfterRead = skillPathsAfterRead;
|
|
1426
|
+
exports.skillPathMutationOutcomeOf = skillPathMutationOutcomeOf;
|
|
1350
1427
|
exports.frameRefreshesSession = frameRefreshesSession;
|
|
1351
1428
|
exports.draftFromReceipt = draftFromReceipt;
|
|
1352
1429
|
exports.draftAfterRefresh = draftAfterRefresh;
|
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Weir client chunks: {"client.blackboard-model.js":"2e5abdd44edb599d10d9d547fa07133e84250d6007161c2c8704b58151896f93","client.blackboard-panel.js":"c9d6a0f02623075fc884f3519f982574ad33ad871f7bcd7e8c36f12512f0de39","client.capability-badge.js":"f5c5d21293360d00815446beb8455794a1778ae033e270af37e436329ac09185","client.capability-manager.js":"
|
|
1
|
+
// Weir client chunks: {"client.blackboard-model.js":"2e5abdd44edb599d10d9d547fa07133e84250d6007161c2c8704b58151896f93","client.blackboard-panel.js":"c9d6a0f02623075fc884f3519f982574ad33ad871f7bcd7e8c36f12512f0de39","client.capability-badge.js":"f5c5d21293360d00815446beb8455794a1778ae033e270af37e436329ac09185","client.capability-manager.js":"0c5b0e469b66b1ccdf720bb3db91ae15fd0be70c84287739de36a3bfceaed223","client.capability-model.js":"9beddda506617152c51d8159964fee74aecb99e8d50b50de82ab050d597ec9d1","client.capability-presets.js":"12ac8da3568d013abd76b67cfe473a329f4af2feaac9cd55a82d8881e5bc1017","client.chain-editor.js":"1e9a33bc68cabc43bc27b91ef83eab5028a33256596c147e15722d684687765b","client.chain-model.js":"2e885922cf8c2972725c6eef8bf20c7d1202d961edfb8ad41574dac062d74371","client.disabled-categories-editor.js":"f9103fd2e2691eda9afd199e1a5462189097a9f3dee0518be5fa3587d896e033","client.file-lease-badge.js":"cd30546307c91c94fe05484557e46f58c53fe55657f62b0a0415a072dfb3b582","client.file-lease-tool-model.js":"f72d87b049454ec489b4dffac1f32dabc273947b8a4c55fe07c91ddf320bd899","client.file-lease-tool-view.js":"7004caa06b36d8497a1d2e448b6ce5e4c8efd22ad903d8b39e26220de64bb1fe","client.hash-edit-model.js":"71727d47a6ef32ebd2abfb063fa93499a5e607d6716e3ec8983ae056c4fb317f","client.hash-edit-view.js":"fc0f4dabddab9a48527553ad4232b3715e46e6863aab75e02ae2dcd6687265c2","client.lsp-model.js":"30943ed1ed11c1f2770a724da855ea2417c11566c2778c79e17cfc2de499cd80","client.lsp-panel.js":"61629a4d317dae9ed01c1cc0355632fb752788dc23f7606257188050734cd030","client.lsp-toggle.js":"d6af81be5e5b854e98563bbb9349a6db89834a9d7dee9cf0e97759ecb32090e8","client.mcp-manager.js":"cfcd4792251fcca8bfa6a2c35b80881ab6f6480cf2ebce24a8ce304cddf940a7","client.model-picker.js":"57f96826e00b37ae6d6474a6750f31b4e128e5fdb37e302875a41cdb2e18f14a","client.notify-permissions.js":"2be9bdbeffef96baa3cbe4ce430ab30a1d1bf797782453d287d54602ea47db8e","client.notify-web.js":"0943f67d5526ff0fb7cb4f69580e0a9ea54ed06465c513afc51a5a7fb1c55ccf","client.robash-editor.js":"cd6a283b72cb02598e32ca4ce03a1b8de5f328ecdd1f5f920377524f3bdcbe1c","client.robash-model.js":"f4395338a773e8bb48e4f96aeb737cd634edcca0b90af992a7dbbe47be42e451","client.settings-page.js":"f3c04a5b33cdb963357bc653f7050e598f19efc4cc833694379a4ba055e949d3","client.worktree-model.js":"8bfbaaa080f20e90a0128afccb9b4e232514615a8f2fee3a32a42dde78b34a8f","client.worktree-view.js":"356c3f5f683b8509ae8679243a6460ca02957fd15e057a629b2ac4fdd12ed057"}
|
|
2
2
|
window.__ModuleLoader__.load({
|
|
3
3
|
id: "weir-harness",
|
|
4
4
|
factory: (require) => {
|
|
@@ -891,6 +891,10 @@ window.__ModuleLoader__.load({
|
|
|
891
891
|
const capabilityVerbs = (sessionId) => ({
|
|
892
892
|
sessionId,
|
|
893
893
|
fetchReceipt: (sid) => capabilityRead("receipt", sid, null),
|
|
894
|
+
fetchSkillPaths: (sid) => capabilityRead("skillPaths", sid, {
|
|
895
|
+
status: "unavailable", saved: false, revision: 0, paths: [],
|
|
896
|
+
error: { code: "unavailable", message: "Skill scan paths are unavailable." }
|
|
897
|
+
}),
|
|
894
898
|
// Forwarded host events arrive via ctx.remote.$on — NOT a
|
|
895
899
|
// remote.session.subscribe (that API does not exist; the old
|
|
896
900
|
// wiring silently never subscribed, which is why cold sessions
|
|
@@ -910,6 +914,8 @@ window.__ModuleLoader__.load({
|
|
|
910
914
|
mcpPreview: (sid, request) => capabilityMutation("mutate", sid, request),
|
|
911
915
|
mcpMutate: (sid, request) => capabilityMutation("mutate", sid, request),
|
|
912
916
|
mcpBatch: (sid, request) => capabilityMutation("batch", sid, request),
|
|
917
|
+
skillPathAdd: (sid, request) => capabilityMutation("skillPathAdd", sid, request),
|
|
918
|
+
skillPathRemove: (sid, request) => capabilityMutation("skillPathRemove", sid, request),
|
|
913
919
|
fetchListing: (sid) => capabilityRead("list", sid, { error: true }),
|
|
914
920
|
fetchConditions: (sid) => capabilityRead("conditions", sid, { error: true }),
|
|
915
921
|
// The manager forwards only its established props. Adapt the lazy view
|
|
@@ -935,6 +941,10 @@ window.__ModuleLoader__.load({
|
|
|
935
941
|
// True once the right-sidebar package mounted the capabilities tab
|
|
936
942
|
// (the optional inject below ran); the Badge's openPanel verb exists
|
|
937
943
|
// only then — without it the Badge falls back to its composer popover.
|
|
944
|
+
const capabilityDictionaries = (model, panel) => ({
|
|
945
|
+
en: { ...model.dictionaries?.en, ...panel.dictionaries?.en },
|
|
946
|
+
zh: { ...model.dictionaries?.zh, ...panel.dictionaries?.zh }
|
|
947
|
+
});
|
|
938
948
|
let capabilitySidebarTab = false;
|
|
939
949
|
const CapabilityBadgeWrapper = (props) => {
|
|
940
950
|
const [impl, setImpl] = react.useState(null);
|
|
@@ -946,8 +956,19 @@ window.__ModuleLoader__.load({
|
|
|
946
956
|
require.async("./client.capability-manager.js"),
|
|
947
957
|
require.async("./client.mcp-manager.js").catch(() => null),
|
|
948
958
|
]).then(([badge, model, panel, mcp]) => {
|
|
949
|
-
registerChunkDictionaries("settings.weir.capability", model
|
|
950
|
-
if (alive) setImpl(() =>
|
|
959
|
+
registerChunkDictionaries("settings.weir.capability", capabilityDictionaries(model, panel));
|
|
960
|
+
if (alive) setImpl(() => {
|
|
961
|
+
// The Badge chunk's established prop bridge is intentionally unchanged.
|
|
962
|
+
// Bind the new verbs once at this composition-root adapter so the
|
|
963
|
+
// composer popover and sidebar drive the identical path child.
|
|
964
|
+
const Panel = (panelProps) => react_jsx_runtime.jsx(panel.CapabilityManagerPanel, {
|
|
965
|
+
...panelProps,
|
|
966
|
+
fetchSkillPaths: props.fetchSkillPaths,
|
|
967
|
+
skillPathAdd: props.skillPathAdd,
|
|
968
|
+
skillPathRemove: props.skillPathRemove
|
|
969
|
+
});
|
|
970
|
+
return { Badge: badge.CapabilityBadge, model, Panel, McpManagerView: mcp?.McpManagerView, McpManagerBoundary: mcp?.McpManagerBoundary };
|
|
971
|
+
});
|
|
951
972
|
});
|
|
952
973
|
return () => { alive = false; };
|
|
953
974
|
}, [props.sessionId]);
|
|
@@ -984,7 +1005,7 @@ window.__ModuleLoader__.load({
|
|
|
984
1005
|
require.async("./client.capability-manager.js"),
|
|
985
1006
|
require.async("./client.mcp-manager.js").catch(() => null),
|
|
986
1007
|
]).then((chunks) => {
|
|
987
|
-
registerChunkDictionaries("settings.weir.capability", chunks[0]
|
|
1008
|
+
registerChunkDictionaries("settings.weir.capability", capabilityDictionaries(chunks[0], chunks[1]));
|
|
988
1009
|
return chunks;
|
|
989
1010
|
}));
|
|
990
1011
|
function CapabilityPanelWrapper(props) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weir-harness",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,13 +22,14 @@ import { buildReceiptPayload, buildListPayload, buildConditionsPayload, buildPre
|
|
|
22
22
|
import { PRESET_SELECTED_EVENT } from './preset-invalidation.js'
|
|
23
23
|
import { createAudit } from '../shared/audit.js'
|
|
24
24
|
import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
|
|
25
|
+
import { createSkillScanPathRegistry } from './skill-scan-path-registry.js'
|
|
25
26
|
|
|
26
27
|
/** The host-root service key the typert gateway resolves (S27: ctx.get from the host root). */
|
|
27
28
|
export const CAPABILITY_READ_SERVICE_KEY = 'weirCapabilityRead'
|
|
28
29
|
/** The wire namespace: POST /api/weirCapabilities/<method>, client ctx.remote.weirCapabilities.*. */
|
|
29
30
|
export const CAPABILITY_READ_NAMESPACE = 'weirCapabilities'
|
|
30
|
-
/** The
|
|
31
|
-
export const CAPABILITY_READ_METHODS = ['receipt', 'list', 'conditions', 'presets', 'defaultGet']
|
|
31
|
+
/** The six read methods, each taking exactly one parameter. */
|
|
32
|
+
export const CAPABILITY_READ_METHODS = ['receipt', 'list', 'conditions', 'presets', 'defaultGet', 'skillPaths']
|
|
32
33
|
|
|
33
34
|
/** Typed infrastructure/ownership failure, distinct from domain status values. */
|
|
34
35
|
export class CapabilityReadError extends Error {
|
|
@@ -157,6 +158,7 @@ export function capabilityOwnership(observation, sessionId) {
|
|
|
157
158
|
export function createCapabilityReadService(dependencies = {}) {
|
|
158
159
|
const bridgeFor = feedResolver(dependencies)
|
|
159
160
|
const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
|
|
161
|
+
const skillPathRegistryFor = dependencies.skillPathRegistry ?? (profileContext => createSkillScanPathRegistry({ store: storeFor(profileContext) }))
|
|
160
162
|
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
161
163
|
const onDegraded = dependencies.onDegraded ?? (() => {})
|
|
162
164
|
const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
|
|
@@ -242,12 +244,13 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
242
244
|
defaultGet: sessionId => read(sessionId, 'defaultGet', (faces, options) => buildDefaultGetPayload({
|
|
243
245
|
store: storeFor(faces.profileContext?.()),
|
|
244
246
|
}, options)),
|
|
247
|
+
skillPaths: sessionId => read(sessionId, 'skillPaths', faces => skillPathRegistryFor(faces.profileContext?.()).read()),
|
|
245
248
|
}
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
export const CAPABILITY_MUTATION_SERVICE_KEY = 'weirCapabilityMutation'
|
|
249
252
|
export const CAPABILITY_MUTATION_NAMESPACE = 'weirCapabilityMutations'
|
|
250
|
-
export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch']
|
|
253
|
+
export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch', 'skillPathAdd', 'skillPathRemove']
|
|
251
254
|
|
|
252
255
|
export function sameMutationOwner(first, second) {
|
|
253
256
|
const profile = value => value ? `${value.home ?? ''}\0${value.name ?? ''}` : ''
|
|
@@ -257,9 +260,38 @@ export function sameMutationOwner(first, second) {
|
|
|
257
260
|
&& profile(first.profile) === profile(second.profile)
|
|
258
261
|
}
|
|
259
262
|
|
|
263
|
+
/** Call every accepting-process provider for exactly one changed Profile. */
|
|
264
|
+
export function invalidateSkillScanPathProviders(profile) {
|
|
265
|
+
const failures = []
|
|
266
|
+
if (typeof profile?.home !== 'string' || typeof profile?.name !== 'string') {
|
|
267
|
+
return [new Error('changed Profile identity is unavailable')]
|
|
268
|
+
}
|
|
269
|
+
for (const feeds of bridgeFeeds.values()) {
|
|
270
|
+
for (const token of feeds) {
|
|
271
|
+
const faces = token.faces ?? token
|
|
272
|
+
try {
|
|
273
|
+
const candidate = faces.profileContext?.()
|
|
274
|
+
if (candidate?.home !== profile.home || candidate?.name !== profile.name) continue
|
|
275
|
+
if (typeof faces.provider?.sourceChanged !== 'function') throw new Error('mounted Skill provider cannot invalidate')
|
|
276
|
+
faces.provider.sourceChanged()
|
|
277
|
+
} catch (cause) {
|
|
278
|
+
failures.push(cause)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return failures
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const pathDiagnostic = (cause, code = cause?.code ?? 'validation-failed') => ({
|
|
286
|
+
code,
|
|
287
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
288
|
+
})
|
|
289
|
+
|
|
260
290
|
/** Session-preset/profile/feed ownership for mutations; selectionSeq is not compared. */
|
|
261
291
|
export function createCapabilityMutationService(dependencies = {}) {
|
|
262
292
|
const bridgeFor = feedResolver(dependencies)
|
|
293
|
+
const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
|
|
294
|
+
const skillPathRegistryFor = dependencies.skillPathRegistry ?? (profileContext => createSkillScanPathRegistry({ store: storeFor(profileContext) }))
|
|
263
295
|
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
264
296
|
const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
|
|
265
297
|
const observe = async sessionId => {
|
|
@@ -278,16 +310,18 @@ export function createCapabilityMutationService(dependencies = {}) {
|
|
|
278
310
|
throw new CapabilityReadError('unknown-session', `capability mutation: cannot observe session ${JSON.stringify(sessionId)}`, { cause })
|
|
279
311
|
}
|
|
280
312
|
}
|
|
313
|
+
// The same exact observation/feed/Profile recheck serves MCP and path writes.
|
|
314
|
+
const ownershipAssertion = (sessionId, first) => async () => {
|
|
315
|
+
const current = await observe(sessionId)
|
|
316
|
+
try {
|
|
317
|
+
if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
|
|
318
|
+
} finally { current.lease[Symbol.dispose]() }
|
|
319
|
+
}
|
|
281
320
|
async function mutate(sessionId, request, batch = false) {
|
|
282
321
|
if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
|
|
283
322
|
const first = await observe(sessionId)
|
|
284
323
|
try {
|
|
285
|
-
const assertOwner =
|
|
286
|
-
const current = await observe(sessionId)
|
|
287
|
-
try {
|
|
288
|
-
if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
|
|
289
|
-
} finally { current.lease[Symbol.dispose]() }
|
|
290
|
-
}
|
|
324
|
+
const assertOwner = ownershipAssertion(sessionId, first)
|
|
291
325
|
if (typeof first.faces.mcpOperations !== 'function') throw new CapabilityReadError('bridge-absent', 'capability mutation: owning preset has no MCP operations')
|
|
292
326
|
const operations = first.faces.mcpOperations({ id: sessionId, header: { cwd: first.owner.cwd } }, { assertOwner })
|
|
293
327
|
let result
|
|
@@ -322,9 +356,62 @@ export function createCapabilityMutationService(dependencies = {}) {
|
|
|
322
356
|
first.lease[Symbol.dispose]()
|
|
323
357
|
}
|
|
324
358
|
}
|
|
359
|
+
|
|
360
|
+
async function mutatePath(sessionId, request, kind) {
|
|
361
|
+
if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
|
|
362
|
+
const first = await observe(sessionId)
|
|
363
|
+
try {
|
|
364
|
+
const assertOwner = ownershipAssertion(sessionId, first)
|
|
365
|
+
let result
|
|
366
|
+
try {
|
|
367
|
+
const registry = skillPathRegistryFor(first.profile)
|
|
368
|
+
result = kind === 'add'
|
|
369
|
+
? await registry.add(request?.path, request?.expectedRevision, { assertOwner })
|
|
370
|
+
: await registry.remove(request?.path, request?.expectedRevision, { assertOwner })
|
|
371
|
+
} catch (cause) {
|
|
372
|
+
result = {
|
|
373
|
+
status: cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed',
|
|
374
|
+
saved: false, revision: null, paths: null,
|
|
375
|
+
error: pathDiagnostic(cause, cause?.code ?? 'validation-failed'),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A known or possibly landed registry write may already have changed the
|
|
380
|
+
// durable discovery scope. Invalidate every same-Profile mount before the
|
|
381
|
+
// response, without converting an ambiguous outcome into a definite one.
|
|
382
|
+
const indeterminate = result.status === 'indeterminate'
|
|
383
|
+
let invalidationFailure = null
|
|
384
|
+
if (result.saved === true || indeterminate) {
|
|
385
|
+
const failures = invalidateSkillScanPathProviders(first.profile)
|
|
386
|
+
if (failures.length > 0) invalidationFailure = failures.map(failure => pathDiagnostic(failure).message).join('; ')
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
await assertOwner()
|
|
390
|
+
} catch (cause) {
|
|
391
|
+
return {
|
|
392
|
+
...result,
|
|
393
|
+
status: indeterminate ? 'indeterminate' : 'owner-changed', ownerChanged: true, saved: result.saved === true,
|
|
394
|
+
error: pathDiagnostic(cause, 'owner-changed'),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (invalidationFailure !== null) {
|
|
398
|
+
return {
|
|
399
|
+
...result,
|
|
400
|
+
status: indeterminate ? 'indeterminate' : 'invalidation-failed',
|
|
401
|
+
error: { code: 'invalidation-failed', message: invalidationFailure },
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return result
|
|
405
|
+
} finally {
|
|
406
|
+
first.lease[Symbol.dispose]()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
325
410
|
return {
|
|
326
411
|
mutate: (sessionId, request) => mutate(sessionId, request, false),
|
|
327
412
|
batch: (sessionId, request) => mutate(sessionId, request, true),
|
|
413
|
+
skillPathAdd: (sessionId, request) => mutatePath(sessionId, request, 'add'),
|
|
414
|
+
skillPathRemove: (sessionId, request) => mutatePath(sessionId, request, 'remove'),
|
|
328
415
|
}
|
|
329
416
|
}
|
|
330
417
|
|
|
@@ -343,7 +430,7 @@ export const CAPABILITY_ENDPOINTS = [
|
|
|
343
430
|
* codecs, no zod, no generated artifacts). Parameters and results are plain
|
|
344
431
|
* JSON; the envelope validates the argument name only.
|
|
345
432
|
*
|
|
346
|
-
* ALL
|
|
433
|
+
* ALL six read and four mutation invocations live in ONE contribution, because
|
|
347
434
|
* the registry keys exactly one record per package+face and rejects any second
|
|
348
435
|
* registration while that record is live: two separate `weir-harness` host-face
|
|
349
436
|
* contributions could never overlap generations, and the face the mutation half
|
|
@@ -3,6 +3,8 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { createSkillIdentity } from './skill-identity.js'
|
|
5
5
|
import { parseSkillText } from './frontmatter.js'
|
|
6
|
+
import { canonicalPathKey } from './skill-applicability.js'
|
|
7
|
+
import { requireSkillScanPaths } from './skill-scan-path-registry.js'
|
|
6
8
|
|
|
7
9
|
export const SKILL_ROOT_RANKS = Object.freeze({ 'project-dsh': 100, 'project-agents': 200, custom: 300, 'user-dsh': 400, 'user-agents': 500, bundled: 600 })
|
|
8
10
|
|
|
@@ -19,7 +21,7 @@ export async function findProjectRoot(cwd, fs = nodeFs) {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** Homes are supplied by the host adapter, not inferred from the process profile. */
|
|
22
|
-
export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillDirs = [], bundledSkillDir, weirBuiltinDir, includeDefaultRoots = true }, fs = nodeFs) {
|
|
24
|
+
export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillDirs = [], bundledSkillDir, weirBuiltinDir, includeDefaultRoots = true, skillScanPathRegistry }, fs = nodeFs) {
|
|
23
25
|
const roots = []
|
|
24
26
|
const add = (path, source, scope, extra = {}) => roots.push({ path: resolve(path), source, scope, rank: SKILL_ROOT_RANKS[source], ...extra })
|
|
25
27
|
if (includeDefaultRoots && cwd !== undefined) {
|
|
@@ -37,7 +39,21 @@ export async function resolveSkillRoots({ cwd, dshHome, agentsHome, customSkillD
|
|
|
37
39
|
}
|
|
38
40
|
// Host bundles are not automatically Weir-owned or selected.
|
|
39
41
|
if (bundledSkillDir !== undefined) add(bundledSkillDir, 'bundled', 'custom', { trustedHost: true })
|
|
40
|
-
|
|
42
|
+
// Existing composition/default roots keep their source, scope and rank. User
|
|
43
|
+
// registry roots append in registry order and never replace Creative's roots.
|
|
44
|
+
if (skillScanPathRegistry) {
|
|
45
|
+
for (const path of await requireSkillScanPaths(skillScanPathRegistry)) add(path, 'custom', 'custom')
|
|
46
|
+
}
|
|
47
|
+
// Canonical identity, not lexical spelling, controls enumeration. A root whose
|
|
48
|
+
// canonical key cannot be proven stays independent so discovery's established
|
|
49
|
+
// partial-failure path reports its real unreadable/missing outcome.
|
|
50
|
+
const seen = new Set()
|
|
51
|
+
return roots.filter((root, index) => {
|
|
52
|
+
const key = canonicalPathKey(root.path) ?? `unresolved:${index}`
|
|
53
|
+
if (seen.has(key)) return false
|
|
54
|
+
seen.add(key)
|
|
55
|
+
return true
|
|
56
|
+
})
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
const rootKey = root => JSON.stringify([resolve(root.path), root.source, root.scope, root.rank])
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Profile-scoped, user-managed Skill discovery roots. The capability store owns
|
|
2
|
+
// locking, revisions and crash-safe writes; this module owns strict payload
|
|
3
|
+
// validation and canonical directory semantics. No mutation executes a command,
|
|
4
|
+
// appends to a session, or treats a failed write as a discovery change.
|
|
5
|
+
import { constants } from 'node:fs'
|
|
6
|
+
import { access, realpath, stat } from 'node:fs/promises'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import * as nodePath from 'node:path'
|
|
9
|
+
import { canonicalPathKey } from './skill-applicability.js'
|
|
10
|
+
|
|
11
|
+
export const SKILL_SCAN_PATH_UNIT = Object.freeze({ kind: 'skill-scan-paths' })
|
|
12
|
+
|
|
13
|
+
const message = error => error instanceof Error ? error.message : String(error)
|
|
14
|
+
const codeOf = error => error?.code
|
|
15
|
+
const missing = error => ['ENOENT', 'ENOTDIR'].includes(codeOf(error))
|
|
16
|
+
const denied = error => ['EACCES', 'EPERM'].includes(codeOf(error))
|
|
17
|
+
const clone = paths => [...paths]
|
|
18
|
+
|
|
19
|
+
class SkillPathFailure extends Error {
|
|
20
|
+
constructor(code, text, result = undefined) {
|
|
21
|
+
super(text)
|
|
22
|
+
this.name = 'SkillPathFailure'
|
|
23
|
+
this.code = code
|
|
24
|
+
this.result = result
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const validation = (code, text) => ({
|
|
29
|
+
status: 'validation-failed', saved: false, revision: null, paths: null,
|
|
30
|
+
error: { code, message: text },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const unavailable = (code, text, revision = null) => ({
|
|
34
|
+
status: 'unavailable', saved: false, revision, paths: null,
|
|
35
|
+
error: { code, message: text },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Comparison of paths already canonicalized by realpath. Registry payloads never
|
|
40
|
+
* store this folded key: the canonical spelling remains the display/read value.
|
|
41
|
+
*/
|
|
42
|
+
function storedPathKey(path, pathApi, platform) {
|
|
43
|
+
let key = pathApi.normalize(path)
|
|
44
|
+
if (platform === 'win32') key = key.replaceAll('\\', '/').toLowerCase()
|
|
45
|
+
return key.replace(/\/+$/, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Strict payload shape: unknown or malformed records are never repaired. */
|
|
49
|
+
function payloadPaths(payload, pathApi, platform) {
|
|
50
|
+
if (payload === undefined) return []
|
|
51
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)
|
|
52
|
+
|| Object.keys(payload).length !== 1 || !Array.isArray(payload.paths)
|
|
53
|
+
|| !payload.paths.every(path => typeof path === 'string' && path.length > 0 && pathApi.isAbsolute(path))) {
|
|
54
|
+
throw new SkillPathFailure('invalid-payload', 'Skill scan path registry payload is invalid')
|
|
55
|
+
}
|
|
56
|
+
const seen = new Set()
|
|
57
|
+
for (const path of payload.paths) {
|
|
58
|
+
const key = storedPathKey(path, pathApi, platform)
|
|
59
|
+
if (seen.has(key)) throw new SkillPathFailure('invalid-payload', 'Skill scan path registry contains duplicate paths')
|
|
60
|
+
seen.add(key)
|
|
61
|
+
}
|
|
62
|
+
return clone(payload.paths)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{
|
|
67
|
+
* store: ReturnType<typeof import('./store/store.js').openCapabilityStore>,
|
|
68
|
+
* fs?: { realpath(path: string): Promise<string>, stat(path: string): Promise<{ isDirectory(): boolean }>, access(path: string, mode?: number): Promise<void> },
|
|
69
|
+
* home?: string,
|
|
70
|
+
* platform?: string,
|
|
71
|
+
* pathApi?: typeof nodePath,
|
|
72
|
+
* }} options
|
|
73
|
+
*/
|
|
74
|
+
export function createSkillScanPathRegistry(options) {
|
|
75
|
+
const store = options.store
|
|
76
|
+
const fs = options.fs ?? { realpath, stat, access }
|
|
77
|
+
const platform = options.platform ?? process.platform
|
|
78
|
+
const pathApi = options.pathApi ?? (platform === 'win32' ? nodePath.win32 : nodePath)
|
|
79
|
+
const home = options.home ?? homedir()
|
|
80
|
+
const unit = SKILL_SCAN_PATH_UNIT
|
|
81
|
+
|
|
82
|
+
const pathKey = path => storedPathKey(path, pathApi, platform)
|
|
83
|
+
|
|
84
|
+
function expanded(input) {
|
|
85
|
+
if (typeof input !== 'string') throw new SkillPathFailure('path-invalid', 'Skill scan path must be a string')
|
|
86
|
+
const trimmed = input.trim()
|
|
87
|
+
if (!trimmed) throw new SkillPathFailure('path-empty', 'Skill scan path cannot be empty')
|
|
88
|
+
let value = trimmed
|
|
89
|
+
if (trimmed === '~') value = home
|
|
90
|
+
else if (trimmed.startsWith('~/') || (platform === 'win32' && trimmed.startsWith('~\\'))) {
|
|
91
|
+
if (typeof home !== 'string' || !pathApi.isAbsolute(home)) throw new SkillPathFailure('home-unavailable', 'Home directory is unavailable')
|
|
92
|
+
value = pathApi.resolve(home, trimmed.slice(2))
|
|
93
|
+
}
|
|
94
|
+
if (!pathApi.isAbsolute(value)) throw new SkillPathFailure('path-not-absolute', 'Skill scan path must be absolute or start with ~')
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function canonicalForAdd(input) {
|
|
99
|
+
const value = expanded(input)
|
|
100
|
+
let canonical
|
|
101
|
+
try { canonical = await fs.realpath(value) }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (missing(error)) throw new SkillPathFailure('path-missing', `Skill scan path does not exist: ${value}`)
|
|
104
|
+
if (denied(error)) throw new SkillPathFailure('path-unreadable', `Skill scan path is unreadable: ${value}`)
|
|
105
|
+
throw new SkillPathFailure('path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${message(error)}`)
|
|
106
|
+
}
|
|
107
|
+
let info
|
|
108
|
+
try { info = await fs.stat(canonical) }
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new SkillPathFailure(denied(error) ? 'path-unreadable' : 'path-missing', `Cannot inspect Skill scan path: ${message(error)}`)
|
|
111
|
+
}
|
|
112
|
+
if (!info.isDirectory()) throw new SkillPathFailure('path-not-directory', `Skill scan path is not a directory: ${canonical}`)
|
|
113
|
+
try { await fs.access(canonical, constants.R_OK) }
|
|
114
|
+
catch (error) { throw new SkillPathFailure('path-unreadable', `Skill scan path is not readable: ${message(error)}`) }
|
|
115
|
+
return { path: canonical, key: pathKey(canonical) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function keyForRemove(input) {
|
|
119
|
+
const value = expanded(input)
|
|
120
|
+
try { return pathKey(await fs.realpath(value)) }
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!missing(error)) {
|
|
123
|
+
throw new SkillPathFailure(denied(error) ? 'path-unreadable' : 'path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${message(error)}`)
|
|
124
|
+
}
|
|
125
|
+
// Removal must remain possible after a directory disappears. The shared
|
|
126
|
+
// platform key canonicalizes the deepest surviving ancestor without
|
|
127
|
+
// requiring the removed directory itself to exist.
|
|
128
|
+
const key = canonicalPathKey(value, platform)
|
|
129
|
+
if (key === null) throw new SkillPathFailure('path-canonicalization-failed', `Cannot canonicalize Skill scan path: ${value}`)
|
|
130
|
+
return key
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function read() {
|
|
135
|
+
let record
|
|
136
|
+
try { record = await store.read(unit) }
|
|
137
|
+
catch (error) { return unavailable('read-failed', `Skill scan path registry is unreadable: ${message(error)}`) }
|
|
138
|
+
if (record.kind === 'absent') return { status: 'ok', revision: 0, paths: [] }
|
|
139
|
+
if (record.kind !== 'ok') return unavailable(record.kind, `Skill scan path registry is ${record.kind}`)
|
|
140
|
+
try {
|
|
141
|
+
return { status: 'ok', revision: record.revision, paths: payloadPaths(record.payload, pathApi, platform) }
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return unavailable(error?.code ?? 'invalid-payload', message(error), record.revision)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function currentAfterConflict(result) {
|
|
148
|
+
const current = await read()
|
|
149
|
+
if (current.status === 'ok') return { revision: current.revision, paths: current.paths, error: null }
|
|
150
|
+
return {
|
|
151
|
+
revision: result.revision ?? current.revision ?? null,
|
|
152
|
+
paths: null,
|
|
153
|
+
error: { code: 'current-state-unavailable', message: `Revision conflict; current registry state is unavailable: ${current.error?.message ?? current.status}` },
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function mutate(kind, input, expectedRevision, options = {}) {
|
|
158
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
159
|
+
return validation('revision-invalid', 'expectedRevision must be a non-negative safe integer')
|
|
160
|
+
}
|
|
161
|
+
let candidate
|
|
162
|
+
try { candidate = kind === 'add' ? await canonicalForAdd(input) : { key: await keyForRemove(input) } }
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof SkillPathFailure) return validation(error.code, error.message)
|
|
165
|
+
return validation('path-validation-failed', message(error))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let result
|
|
169
|
+
try {
|
|
170
|
+
result = await store.commit(unit, expectedRevision, async payload => {
|
|
171
|
+
let paths
|
|
172
|
+
try { paths = payloadPaths(payload, pathApi, platform) }
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw new SkillPathFailure(error?.code ?? 'invalid-payload', message(error), unavailable(error?.code ?? 'invalid-payload', message(error)))
|
|
175
|
+
}
|
|
176
|
+
const index = paths.findIndex(path => pathKey(path) === candidate.key)
|
|
177
|
+
if (kind === 'add' && index >= 0) {
|
|
178
|
+
throw new SkillPathFailure('duplicate', `Skill scan path is already registered: ${paths[index]}`, {
|
|
179
|
+
status: 'duplicate', saved: false, revision: expectedRevision, paths: clone(paths),
|
|
180
|
+
error: { code: 'duplicate', message: `Skill scan path is already registered: ${paths[index]}` },
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
if (kind === 'remove' && index < 0) {
|
|
184
|
+
throw new SkillPathFailure('not-found', `Skill scan path is not registered: ${input}`, {
|
|
185
|
+
status: 'not-found', saved: false, revision: expectedRevision, paths: clone(paths),
|
|
186
|
+
error: { code: 'not-found', message: `Skill scan path is not registered: ${input}` },
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
// Ownership is rechecked under the unit lock, immediately before the
|
|
190
|
+
// payload is handed to the store's atomic write phase.
|
|
191
|
+
await options.assertOwner?.()
|
|
192
|
+
return { paths: kind === 'add' ? [...paths, candidate.path] : paths.filter((_, i) => i !== index) }
|
|
193
|
+
})
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === 'owner-changed') throw error
|
|
196
|
+
if (error instanceof SkillPathFailure && error.result) return error.result
|
|
197
|
+
return {
|
|
198
|
+
status: 'storage-failed', saved: false, revision: null, paths: null,
|
|
199
|
+
error: { code: 'storage-failed', message: message(error) },
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (result.status === 'committed') {
|
|
204
|
+
return {
|
|
205
|
+
status: kind === 'add' ? 'saved' : 'removed', saved: true,
|
|
206
|
+
revision: result.revision, paths: clone(result.payload.paths), error: null,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (result.status === 'revision-conflict') {
|
|
210
|
+
const current = await currentAfterConflict(result)
|
|
211
|
+
return {
|
|
212
|
+
status: 'revision-conflict', saved: false, revision: current.revision, paths: current.paths,
|
|
213
|
+
error: current.error ?? { code: 'revision-conflict', message: `Skill scan path registry revision ${expectedRevision} is stale` },
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
status: result.status === 'write-failed' ? 'indeterminate' : result.status,
|
|
218
|
+
saved: false, revision: result.revision ?? null, paths: null,
|
|
219
|
+
error: { code: result.kind ?? result.status, message: result.reason ?? message(result.error ?? `Skill scan path registry write failed: ${result.status}`) },
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
unit,
|
|
225
|
+
read,
|
|
226
|
+
add: (path, expectedRevision, options) => mutate('add', path, expectedRevision, options),
|
|
227
|
+
remove: (path, expectedRevision, options) => mutate('remove', path, expectedRevision, options),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Registry read failures must become visible inventory/roots failures. */
|
|
232
|
+
export async function requireSkillScanPaths(registry) {
|
|
233
|
+
const result = await registry.read()
|
|
234
|
+
if (result.status !== 'ok') {
|
|
235
|
+
throw new Error(`Skill scan path registry unavailable: ${result.error?.message ?? result.status}`)
|
|
236
|
+
}
|
|
237
|
+
return result.paths
|
|
238
|
+
}
|
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { createSkillIdentity, skillIdentityKey } from './skill-identity.js'
|
|
8
8
|
import { openCapabilityStore } from './store/store.js'
|
|
9
9
|
import { discoverSkillInventory, resolveSkillRoots } from './skill-inventory.js'
|
|
10
|
+
import { createSkillScanPathRegistry } from './skill-scan-path-registry.js'
|
|
10
11
|
import { createSkillSelectionProvider } from './skill-selection-provider.js'
|
|
11
12
|
import { assertBuiltinSkillMigration } from './skill-builtin-migration.js'
|
|
12
13
|
import { createOfficeAdapter, officeDenials } from './skill-office-adapter.js'
|
|
@@ -67,6 +68,9 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
67
68
|
/** Task 6.5 initialization reports per session (manager surface). */
|
|
68
69
|
let initialReports = new Map()
|
|
69
70
|
let lifecycle = null
|
|
71
|
+
const scanPathRegistry = dependencies.skillScanPathRegistry ?? createSkillScanPathRegistry({
|
|
72
|
+
store: openCapabilityStore({ profileContext: ctx.get?.('profileContext') }),
|
|
73
|
+
})
|
|
70
74
|
try {
|
|
71
75
|
ctx.skills.registerProvider(control => {
|
|
72
76
|
const readSelection = dependencies.readSelection ?? (async options => {
|
|
@@ -134,6 +138,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
134
138
|
customSkillDirs: config.customSkillDirs ?? [],
|
|
135
139
|
bundledSkillDir: config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR,
|
|
136
140
|
weirBuiltinDir: config.weirBuiltinDir ?? fileURLToPath(new URL('../../skills/', import.meta.url)),
|
|
141
|
+
skillScanPathRegistry: scanPathRegistry,
|
|
137
142
|
})
|
|
138
143
|
const snapshot = await discoverSkillInventory({ roots, machineId, previous })
|
|
139
144
|
// First-run migration check (D-H 6): a non-equivalent builtin
|
|
@@ -323,6 +328,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
|
|
|
323
328
|
customSkillDirs: config.customSkillDirs ?? [],
|
|
324
329
|
bundledSkillDir: config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR,
|
|
325
330
|
weirBuiltinDir: config.weirBuiltinDir ?? fileURLToPath(new URL('../../skills/', import.meta.url)),
|
|
331
|
+
skillScanPathRegistry: scanPathRegistry,
|
|
326
332
|
})
|
|
327
333
|
}
|
|
328
334
|
// The shared cold-safe audit channel (cordis emit + .weir/audit.jsonl
|
|
@@ -38,6 +38,7 @@ export function resolveStoreRoot(profileContext) {
|
|
|
38
38
|
* | { kind: 'preset', scope: 'global', presetId: string }
|
|
39
39
|
* | { kind: 'preset', scope: 'workspace', workspaceKey: string, presetId: string }
|
|
40
40
|
* | { kind: 'mcp-registry' }
|
|
41
|
+
* | { kind: 'skill-scan-paths' }
|
|
41
42
|
* | { kind: 'distribution', scope: string }} Unit
|
|
42
43
|
*/
|
|
43
44
|
|
|
@@ -77,6 +78,8 @@ export function unitLayout(unit) {
|
|
|
77
78
|
break
|
|
78
79
|
case 'mcp-registry':
|
|
79
80
|
return layout(['mcp'], 'registry')
|
|
81
|
+
case 'skill-scan-paths':
|
|
82
|
+
return layout(['skills'], 'scan-paths')
|
|
80
83
|
case 'distribution':
|
|
81
84
|
need(u.scope)
|
|
82
85
|
return layout(['distribution', /** @type {string} */ (u.scope)], 'active')
|