weir-harness 2.1.2 → 2.1.4

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/README.md CHANGED
@@ -21,7 +21,7 @@ Weir — 以 Orchestrator 为中心的 DSH agent 预设 bundle。声明预设 `w
21
21
 
22
22
  ## 安装
23
23
 
24
- 要求 DeepSeek Harness 版本 `>=0.2.0-rc.2 <0.3.0`(不兼容的运行时会在安装前被明确拒绝)。
24
+ 要求 DeepSeek Harness 版本 `>=0.2.2-rc.1 <0.3.0`(不兼容的运行时会在安装前被明确拒绝)。仍在 DSH 0.2.1 线的用户请固定 `weir-harness@2.1.3`,它是该线的最后支持版本。
25
25
 
26
26
  CLI 一条命令:
27
27
 
package/cordis.patch.yml CHANGED
@@ -112,6 +112,10 @@
112
112
  disabled: !!js process.platform !== 'win32'
113
113
  - id: tool-fs
114
114
  name: '@deepseek-ai/dsh-tool-fs'
115
+ # Weir's managed writers require the stock observation/CAS guard even
116
+ # though DSH 0.2.2 leaves the profile-level policy disabled.
117
+ - id: fs-observation-policy
118
+ name: '@deepseek-ai/dsh-fs-observation-policy'
115
119
  - id: tool-fs-search
116
120
  name: '@deepseek-ai/dsh-tool-fs-search'
117
121
  config:
@@ -119,9 +123,11 @@
119
123
  - id: tool-jobs
120
124
  name: '@deepseek-ai/dsh-tool-jobs'
121
125
  config:
122
- # F5 拉取式后台:结算通知已内建为紧凑 + 边界注入 + 拉取式读取;
123
- # 这里仅限制连续唤醒次数,防止通知唤醒风暴。
126
+ # F5 拉取式后台:限制连续唤醒次数,并禁止 DSH 0.2.2 将
127
+ # 输出/报告全文内联进结算通知;全文仍由 job_output 拉取。
124
128
  maxConsecutiveWakes: 8
129
+ # Never inline output/results into completion notices (DSH 0.2.2).
130
+ inlineOutputBytes: 0
125
131
  # Weir capabilities: skill selection + managed MCP. The manager
126
132
  # publishes weirMcpGate / weirMcpManager via reflect; preset
127
133
  # services must sit in an isolate realm or the registry rejects the
@@ -207,8 +213,6 @@
207
213
  name: 'weir-harness/context-guard'
208
214
  - id: tool-subagent-control
209
215
  name: '@deepseek-ai/dsh-tool-subagent-control'
210
- - id: tool-subagent-list-agents
211
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
212
216
  - id: tool-ralph
213
217
  name: '@deepseek-ai/dsh-tool-ralph'
214
218
  disabled: true
@@ -302,6 +306,10 @@
302
306
  disabled: !!js process.platform !== 'win32'
303
307
  - id: tool-fs
304
308
  name: '@deepseek-ai/dsh-tool-fs'
309
+ # Weir's managed writers require the stock observation/CAS guard even
310
+ # though DSH 0.2.2 leaves the profile-level policy disabled.
311
+ - id: fs-observation-policy
312
+ name: '@deepseek-ai/dsh-fs-observation-policy'
305
313
  - id: tool-fs-search
306
314
  name: '@deepseek-ai/dsh-tool-fs-search'
307
315
  config:
@@ -310,6 +318,8 @@
310
318
  name: '@deepseek-ai/dsh-tool-jobs'
311
319
  config:
312
320
  maxConsecutiveWakes: 8
321
+ # Never inline output/results into completion notices (DSH 0.2.2).
322
+ inlineOutputBytes: 0
313
323
  # Same capabilities group as `weir` (S11 realm structure), with
314
324
  # the creative delta: customSkillDirs resolves the Cordis
315
325
  # development skills shipped inside @deepseek-ai/dsh-agent-preset,
@@ -389,8 +399,6 @@
389
399
  name: 'weir-harness/context-guard'
390
400
  - id: tool-subagent-control
391
401
  name: '@deepseek-ai/dsh-tool-subagent-control'
392
- - id: tool-subagent-list-agents
393
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
394
402
  - id: tool-ralph
395
403
  name: '@deepseek-ai/dsh-tool-ralph'
396
404
  disabled: true
@@ -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) => paint(event, { outline: "2px solid var(--dsw-alias-focus-ring, currentColor)", outlineOffset: "1px" }),
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: "none", marginLeft: "2px" },
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":"f02e08174487f2f70e90a5c50a4a44cbeb4d462f115a2d2ab8a52ed8b4e70aee","client.capability-model.js":"57127dc5ea7ca6aa70c619480684f4de84fcbff4922f70df9886f55fad66abbf","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"}
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.dictionaries);
950
- if (alive) setImpl(() => ({ Badge: badge.CapabilityBadge, model, Panel: panel.CapabilityManagerPanel, McpManagerView: mcp?.McpManagerView, McpManagerBoundary: mcp?.McpManagerBoundary }));
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].dictionaries);
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.2",
3
+ "version": "2.1.4",
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",
@@ -29,10 +29,10 @@
29
29
  "icon.svg"
30
30
  ],
31
31
  "engines": {
32
- "dsh": ">=0.2.1-rc.1 <0.3.0"
32
+ "dsh": ">=0.2.2-rc.1 <0.3.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh": ">=0.2.1-rc.1 <0.3.0"
35
+ "@deepseek-ai/dsh": ">=0.2.2-rc.1 <0.3.0"
36
36
  },
37
37
  "exports": {
38
38
  "./package.json": "./package.json",
@@ -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 five read methods, each taking exactly one parameter. */
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 = async () => {
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 five read and two mutation invocations live in ONE contribution, because
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
- return roots
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')