@mrclrchtr/supi-skills 8.0.1 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "8.0.1",
3
+ "version": "9.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,6 +6,9 @@
6
6
 
7
7
  export type DebugLevel = "debug" | "info" | "warning" | "error";
8
8
  export type DebugAgentAccess = "off" | "sanitized" | "raw";
9
+
10
+ /** Custom session-entry type used to persist sanitized debug events. */
11
+ export const DEBUG_EVENT_ENTRY_TYPE = "supi-debug-event";
9
12
  export interface DebugRegistryConfig {
10
13
  /** Whether producers should retain debug events. */
11
14
  enabled: boolean;
@@ -212,7 +215,7 @@ function toSanitizedView(event: DebugEvent): DebugEventView {
212
215
  category: event.category,
213
216
  message: event.message,
214
217
  cwd: event.cwd,
215
- data: event.data,
218
+ data: event.data === undefined ? undefined : redactDebugData(event.data),
216
219
  };
217
220
  }
218
221
 
@@ -241,15 +244,17 @@ export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
241
244
  };
242
245
  state.events.push(event);
243
246
  trimToMaxEvents(state);
244
- const view = toSanitizedView(event);
245
247
  for (const listener of state.listeners) {
246
248
  try {
247
- listener(view);
249
+ listener(toSanitizedView(event));
248
250
  } catch {
249
251
  // Debug-event consumers must not alter producer behavior.
250
252
  }
251
253
  }
252
- return { ...event };
254
+ return {
255
+ ...event,
256
+ data: event.data === undefined ? undefined : redactDebugData(event.data),
257
+ };
253
258
  }
254
259
 
255
260
  /** Query retained debug events newest-first. Results are sanitized unless raw access is requested and allowed. */
@@ -1,3 +1,9 @@
1
+ import {
2
+ decodeSessionCapabilityProfileSnapshot,
3
+ type SessionCapabilityProfile,
4
+ type SessionCapabilityProfileSnapshot,
5
+ } from "./session-capability-profiles.ts";
6
+
1
7
  /** Session-only tool and skill visibility state. */
2
8
  export interface SessionCapabilityState {
3
9
  version: 1;
@@ -7,6 +13,8 @@ export interface SessionCapabilityState {
7
13
  /** Tools that the user explicitly enabled for this session. */
8
14
  toolEnabledNames?: string[];
9
15
  hiddenSkillNames: string[];
16
+ /** Original profile identity and exclusions. Later configuration changes do not affect this copy. */
17
+ profileSnapshot?: SessionCapabilityProfileSnapshot;
10
18
  }
11
19
 
12
20
  /** A model-visible skill that a provider can offer to the selector. */
@@ -94,8 +102,9 @@ export function decodeSessionCapabilities(value: unknown): SessionCapabilityStat
94
102
  (name) => !initiallyInactive.has(name),
95
103
  );
96
104
  const eligible = new Set(eligibleToolNames);
97
- const toolDenylist = stringList(record.toolDenylist).filter((name) => eligible.has(name));
105
+ const toolDenylist = stringList(record.toolDenylist);
98
106
  const denied = new Set(toolDenylist);
107
+ const profileSnapshot = decodeSessionCapabilityProfileSnapshot(record.profileSnapshot);
99
108
 
100
109
  return {
101
110
  version: 1,
@@ -106,9 +115,54 @@ export function decodeSessionCapabilities(value: unknown): SessionCapabilityStat
106
115
  (name) => eligible.has(name) && !denied.has(name),
107
116
  ),
108
117
  hiddenSkillNames: stringList(record.hiddenSkillNames),
118
+ ...(profileSnapshot ? { profileSnapshot } : {}),
119
+ };
120
+ }
121
+
122
+ /** Copy current exclusion sets for saving. Do not use inventory to remove names. */
123
+ export function captureSessionCapabilityExclusions(
124
+ state: SessionCapabilityState,
125
+ ): SessionCapabilityProfile {
126
+ return {
127
+ excludedTools: [...new Set(state.toolDenylist)],
128
+ hiddenSkills: [...new Set(state.hiddenSkillNames)],
109
129
  };
110
130
  }
111
131
 
132
+ /**
133
+ * Replace exclusions and capture a profile copy. Return undefined for an invalid profile.
134
+ * Do not mutate the input state or activate tools. The caller owns runtime restoration.
135
+ */
136
+ export function applySessionCapabilityProfile(
137
+ state: SessionCapabilityState,
138
+ profile: unknown,
139
+ ): SessionCapabilityState | undefined {
140
+ const profileSnapshot = decodeSessionCapabilityProfileSnapshot(profile);
141
+ if (!profileSnapshot) return undefined;
142
+ return decodeSessionCapabilities({
143
+ ...state,
144
+ toolDenylist: profileSnapshot.excludedTools,
145
+ hiddenSkillNames: profileSnapshot.hiddenSkills,
146
+ profileSnapshot,
147
+ });
148
+ }
149
+
150
+ function equalNameSets(left: readonly string[], right: readonly string[]): boolean {
151
+ const leftSet = new Set(left);
152
+ const rightSet = new Set(right);
153
+ return leftSet.size === rightSet.size && [...leftSet].every((name) => rightSet.has(name));
154
+ }
155
+
156
+ /** Compare current exclusions with the applied copy. No profile means no Modified marker. */
157
+ export function isSessionCapabilityProfileModified(state: SessionCapabilityState): boolean {
158
+ const snapshot = state.profileSnapshot;
159
+ return (
160
+ snapshot !== undefined &&
161
+ (!equalNameSets(state.toolDenylist, snapshot.excludedTools) ||
162
+ !equalNameSets(state.hiddenSkillNames, snapshot.hiddenSkills))
163
+ );
164
+ }
165
+
112
166
  /** Return a safe copy of the latest valid snapshot in all session entries. */
113
167
  export function findSessionCapabilities(
114
168
  entries: readonly unknown[],
@@ -0,0 +1,240 @@
1
+ /** Exact names to exclude. Omitted configuration lists mean empty sets. */
2
+ export interface SessionCapabilityProfileDefinition {
3
+ excludedTools?: string[];
4
+ hiddenSkills?: string[];
5
+ }
6
+
7
+ /** Validated exclusion sets. Names need not match the current inventory. */
8
+ export interface SessionCapabilityProfile {
9
+ excludedTools: string[];
10
+ hiddenSkills: string[];
11
+ }
12
+
13
+ /** A profile is available only when validation has no errors. */
14
+ export interface SessionCapabilityProfileValidation {
15
+ profile?: SessionCapabilityProfile;
16
+ errors: string[];
17
+ }
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
21
+ const prototype = Object.getPrototypeOf(value);
22
+ return prototype === Object.prototype || prototype === null;
23
+ }
24
+
25
+ function readNames(value: unknown): string[] | undefined {
26
+ if (!Array.isArray(value)) return undefined;
27
+ const names: unknown[] = [...new Set(value)];
28
+ return names.every((name): name is string => typeof name === "string") ? names : undefined;
29
+ }
30
+
31
+ /** Validate and copy a complete definition. Do not check inventory or expand patterns. */
32
+ export function validateSessionCapabilityProfile(
33
+ value: unknown,
34
+ ): SessionCapabilityProfileValidation {
35
+ if (!isRecord(value)) return { errors: ["Profile must be an object."] };
36
+ const excludedTools = Object.hasOwn(value, "excludedTools") ? readNames(value.excludedTools) : [];
37
+ const hiddenSkills = Object.hasOwn(value, "hiddenSkills") ? readNames(value.hiddenSkills) : [];
38
+ const errors = Object.keys(value)
39
+ .filter((key) => key !== "excludedTools" && key !== "hiddenSkills")
40
+ .map((key) => `Unknown profile field: ${key}.`);
41
+ if (!excludedTools) errors.push("excludedTools must be an array of strings.");
42
+ if (!hiddenSkills) errors.push("hiddenSkills must be an array of strings.");
43
+ if (errors.length || !excludedTools || !hiddenSkills) return { errors };
44
+ return { profile: { excludedTools, hiddenSkills }, errors };
45
+ }
46
+
47
+ /** Configuration scope that supplied a profile or default. */
48
+ export type SessionCapabilityProfileScope = "global" | "project";
49
+
50
+ /** Configuration values for the sessionCapabilities section. */
51
+ export interface SessionCapabilityProfilesSection {
52
+ profiles?: Record<string, SessionCapabilityProfileDefinition>;
53
+ defaultProfile?: string | null;
54
+ }
55
+
56
+ /** Session-owned identity and original exclusion sets, independent of configuration. */
57
+ export interface SessionCapabilityProfileSnapshot extends SessionCapabilityProfile {
58
+ id: string;
59
+ source: SessionCapabilityProfileScope;
60
+ }
61
+
62
+ /** A configuration error with a scope and field path for display. */
63
+ export interface SessionCapabilityProfileDiagnostic {
64
+ scope: SessionCapabilityProfileScope;
65
+ path: string;
66
+ message: string;
67
+ }
68
+
69
+ /** A scoped definition, including invalid definitions and hidden global definitions. */
70
+ export interface SessionCapabilityProfileEntry {
71
+ id: string;
72
+ source: SessionCapabilityProfileScope;
73
+ shadowed: boolean;
74
+ profile?: SessionCapabilityProfile;
75
+ errors: SessionCapabilityProfileDiagnostic[];
76
+ }
77
+
78
+ /** Default selection source can differ from the effective profile source. */
79
+ export type SessionCapabilityProfileDefault =
80
+ | { status: "none"; source?: SessionCapabilityProfileScope }
81
+ | {
82
+ status: "resolved";
83
+ source: SessionCapabilityProfileScope;
84
+ id: string;
85
+ profile: SessionCapabilityProfileSnapshot;
86
+ }
87
+ | { status: "invalid"; source: SessionCapabilityProfileScope; id?: string };
88
+
89
+ /** Raw sections, not merged configuration. Callers must check trust before project reads. */
90
+ export interface ResolveSessionCapabilityProfilesOptions {
91
+ global?: unknown;
92
+ project?: unknown;
93
+ projectTrusted: boolean;
94
+ }
95
+
96
+ /** Data for profile rows, scoped actions, and default warnings. No I/O is performed. */
97
+ export interface SessionCapabilityProfilesResolution {
98
+ entries: SessionCapabilityProfileEntry[];
99
+ effectiveProfiles: SessionCapabilityProfileSnapshot[];
100
+ default: SessionCapabilityProfileDefault;
101
+ errors: SessionCapabilityProfileDiagnostic[];
102
+ }
103
+
104
+ /** IDs contain lowercase ASCII words separated by single hyphens. */
105
+ export function isSessionCapabilityProfileId(value: unknown): value is string {
106
+ return typeof value === "string" && /^[a-z]+(?:-[a-z]+)*$/.test(value);
107
+ }
108
+
109
+ /** Validate and copy stored identity and original lists. Invalid metadata has no identity. */
110
+ export function decodeSessionCapabilityProfileSnapshot(
111
+ value: unknown,
112
+ ): SessionCapabilityProfileSnapshot | undefined {
113
+ if (!isRecord(value) || !isSessionCapabilityProfileId(value.id)) return undefined;
114
+ if (value.source !== "global" && value.source !== "project") return undefined;
115
+ const { id, source, ...definition } = value;
116
+ if (!Object.hasOwn(definition, "excludedTools") || !Object.hasOwn(definition, "hiddenSkills"))
117
+ return undefined;
118
+ const result = validateSessionCapabilityProfile(definition);
119
+ return result.profile ? { id, source, ...result.profile } : undefined;
120
+ }
121
+
122
+ function readEntries(
123
+ value: unknown,
124
+ source: SessionCapabilityProfileScope,
125
+ ): SessionCapabilityProfileEntry[] {
126
+ if (!isRecord(value) || !isRecord(value.profiles)) return [];
127
+ return Object.entries(value.profiles).map(([id, definition]) => {
128
+ const result = validateSessionCapabilityProfile(definition);
129
+ if (!isSessionCapabilityProfileId(id))
130
+ result.errors.push("Invalid profile ID: use lowercase words with single hyphens.");
131
+ return {
132
+ id,
133
+ source,
134
+ shadowed: false,
135
+ profile: result.errors.length ? undefined : result.profile,
136
+ errors: result.errors.map((message) => ({ scope: source, path: `profiles.${id}`, message })),
137
+ };
138
+ });
139
+ }
140
+
141
+ type DefaultReference =
142
+ | SessionCapabilityProfileDefault
143
+ | { status: "pending"; source: SessionCapabilityProfileScope; id: string };
144
+
145
+ function readDefault(
146
+ value: unknown,
147
+ source: SessionCapabilityProfileScope,
148
+ errors: SessionCapabilityProfileDiagnostic[],
149
+ ): DefaultReference | undefined {
150
+ if (!isRecord(value) || !Object.hasOwn(value, "defaultProfile")) return undefined;
151
+ if (value.defaultProfile === null) return { status: "none", source };
152
+ if (isSessionCapabilityProfileId(value.defaultProfile)) {
153
+ return { status: "pending", source, id: value.defaultProfile };
154
+ }
155
+ errors.push({
156
+ scope: source,
157
+ path: "defaultProfile",
158
+ message: "defaultProfile must be null or a valid profile ID.",
159
+ });
160
+ return { status: "invalid", source };
161
+ }
162
+
163
+ function resolveDefault(
164
+ selected: DefaultReference | undefined,
165
+ profiles: SessionCapabilityProfileSnapshot[],
166
+ errors: SessionCapabilityProfileDiagnostic[],
167
+ ): SessionCapabilityProfileDefault {
168
+ if (!selected) return { status: "none" };
169
+ if (selected.status !== "pending") return selected;
170
+ const { source, id } = selected;
171
+ const profile = profiles.find((entry) => entry.id === id);
172
+ if (profile) return { status: "resolved", source, id, profile };
173
+ errors.push({
174
+ scope: source,
175
+ path: "defaultProfile",
176
+ message: `Default profile ${id} is missing or invalid. No profile will be applied.`,
177
+ });
178
+ return { status: "invalid", source, id };
179
+ }
180
+
181
+ interface ScopedProfiles {
182
+ entries: SessionCapabilityProfileEntry[];
183
+ errors: SessionCapabilityProfileDiagnostic[];
184
+ default?: DefaultReference;
185
+ }
186
+
187
+ /** Reject malformed sections, but retain known IDs to prevent global fallback. */
188
+ function readSection(value: unknown, scope: SessionCapabilityProfileScope): ScopedProfiles {
189
+ if (value === undefined) return { entries: [], errors: [] };
190
+ if (!isRecord(value)) {
191
+ return {
192
+ entries: [],
193
+ default: { status: "invalid", source: scope },
194
+ errors: [
195
+ { scope, path: "sessionCapabilities", message: "sessionCapabilities must be an object." },
196
+ ],
197
+ };
198
+ }
199
+ const sectionErrors = Object.keys(value)
200
+ .filter((key) => key !== "profiles" && key !== "defaultProfile")
201
+ .map((key) => ({ scope, path: key, message: `Unknown section field: ${key}.` }));
202
+ if (Object.hasOwn(value, "profiles") && !isRecord(value.profiles)) {
203
+ sectionErrors.push({ scope, path: "profiles", message: "profiles must be an object." });
204
+ }
205
+ const entries = readEntries(value, scope);
206
+ const errors = [...sectionErrors, ...entries.flatMap((entry) => entry.errors)];
207
+ const selection = readDefault(value, scope, errors);
208
+ if (sectionErrors.length) {
209
+ for (const entry of entries) {
210
+ entry.profile = undefined;
211
+ entry.errors.push(...sectionErrors);
212
+ }
213
+ return { entries, errors, default: { status: "invalid", source: scope } };
214
+ }
215
+ return { entries, errors, default: selection };
216
+ }
217
+
218
+ /**
219
+ * Resolve whole profiles by ID. Ignore project data unless projectTrusted is true.
220
+ * An absent section is undefined. Malformed sections cannot supply profiles or a default.
221
+ */
222
+ export function resolveSessionCapabilityProfiles(
223
+ options: ResolveSessionCapabilityProfilesOptions,
224
+ ): SessionCapabilityProfilesResolution {
225
+ const global = readSection(options.global, "global");
226
+ const project = readSection(options.projectTrusted ? options.project : undefined, "project");
227
+ const entries = [...global.entries, ...project.entries];
228
+ const errors = [...global.errors, ...project.errors];
229
+ const selected = new Map(entries.map((entry) => [entry.id, entry]));
230
+ for (const entry of entries) entry.shadowed = selected.get(entry.id) !== entry;
231
+ const effectiveProfiles = [...selected.values()].flatMap((entry) =>
232
+ entry.profile ? [{ id: entry.id, source: entry.source, ...entry.profile }] : [],
233
+ );
234
+ return {
235
+ entries,
236
+ effectiveProfiles,
237
+ default: resolveDefault(project.default ?? global.default, effectiveProfiles, errors),
238
+ errors,
239
+ };
240
+ }
@@ -7,17 +7,39 @@ export type {
7
7
  SessionCapabilityState,
8
8
  } from "./session-capabilities.ts";
9
9
  export {
10
+ applySessionCapabilityProfile,
11
+ captureSessionCapabilityExclusions,
10
12
  clearPendingSessionCapabilityForks,
11
13
  createEmptySessionCapabilityState,
12
14
  decodeSessionCapabilities,
13
15
  findSessionCapabilities,
14
16
  getSessionCapabilitySkillProvider,
17
+ isSessionCapabilityProfileModified,
15
18
  readPendingSessionCapabilityFork,
16
19
  registerSessionCapabilitySkillProvider,
17
20
  SESSION_CAPABILITIES_ENTRY,
18
21
  sessionCapabilityState,
19
22
  setPendingSessionCapabilityFork,
20
23
  } from "./session-capabilities.ts";
24
+ export type {
25
+ ResolveSessionCapabilityProfilesOptions,
26
+ SessionCapabilityProfile,
27
+ SessionCapabilityProfileDefault,
28
+ SessionCapabilityProfileDefinition,
29
+ SessionCapabilityProfileDiagnostic,
30
+ SessionCapabilityProfileEntry,
31
+ SessionCapabilityProfileScope,
32
+ SessionCapabilityProfileSnapshot,
33
+ SessionCapabilityProfilesResolution,
34
+ SessionCapabilityProfilesSection,
35
+ SessionCapabilityProfileValidation,
36
+ } from "./session-capability-profiles.ts";
37
+ export {
38
+ decodeSessionCapabilityProfileSnapshot,
39
+ isSessionCapabilityProfileId,
40
+ resolveSessionCapabilityProfiles,
41
+ validateSessionCapabilityProfile,
42
+ } from "./session-capability-profiles.ts";
21
43
  export type { SessionNameTrackerHost } from "./session-utils.ts";
22
44
  export {
23
45
  createSessionNameTracker,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-skills",
3
- "version": "8.0.1",
3
+ "version": "9.0.0",
4
4
  "description": "Scoped skill controls and skill input shortcuts for PI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "README.md"
31
31
  ],
32
32
  "dependencies": {
33
- "@mrclrchtr/supi-core": "8.0.1"
33
+ "@mrclrchtr/supi-core": "9.0.0"
34
34
  },
35
35
  "bundledDependencies": [
36
36
  "@mrclrchtr/supi-core"