@mrclrchtr/supi-debug 4.9.0 → 5.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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ After install, this package wires the shared debug registry into three user-faci
30
30
  - `supi_debug` — let the model query recent debug events during troubleshooting
31
31
  - `/supi-settings` integration — configure whether events are captured and how much data is exposed
32
32
 
33
- It also registers a **Debug** provider section for `/supi-context`.
33
+ It also registers a **Debug** provider section for `/supi-context`, and ships a `/supi-tooling-retro` prompt template for post-task retrospective feedback on the SuPi tooling used in the completed task.
34
34
 
35
35
  ## Event behavior
36
36
 
@@ -40,6 +40,12 @@ It also registers a **Debug** provider section for `/supi-context`.
40
40
  - if debug capture is disabled, no events are retained
41
41
  - agent-facing access is blocked, sanitized, or raw depending on settings
42
42
 
43
+ ### Identity disclosure
44
+
45
+ Retained and persisted LSP debug events may identify local workspaces and files. Since the LSP telemetry expansion, LSP events can carry the absolute workspace root (`cwd`), the configured server name (`server`), workspace-relative file paths (`file`), exact LSP method names (`method`), and the server root (`root`). Identity strings are bounded to 512 UTF-16 code units and server lists to 16 entries.
46
+
47
+ Identity fields are **not** secret-redacted. The debug registry redacts secret keys and secret-looking values (tokens, passwords, API keys, authorization headers, URL credentials), but server names, file paths, method names, and workspace roots pass through unredacted by design, so local protocol failures stay diagnosable. Treat retained and persisted LSP events as potentially identifying your local machine, project layout, and file names.
48
+
43
49
  ## Rendering
44
50
 
45
51
  `/supi-debug` uses a custom TUI message renderer that shows two levels of detail:
@@ -63,6 +69,7 @@ Rendered fields per event:
63
69
  - optional `cwd`
64
70
  - optional `data`
65
71
  - optional `rawData`
72
+ - optional `operationId` for events directly owned by one public `code_*` call
66
73
 
67
74
  ### Why collapsed by default
68
75
 
@@ -83,11 +90,12 @@ Both `/supi-debug` and `supi_debug` support the same basic filters:
83
90
  - `source`
84
91
  - `level`
85
92
  - `category`
93
+ - exact `operationId`
86
94
  - `limit`
87
95
 
88
96
  For historical sessions, pass `sessionFile` to `supi_debug`, or
89
97
  `sessionFile=<path>` to `/supi-debug`. Historical sessions never retain raw data.
90
- The tool also accepts `includeRaw` for live-session data when settings allow it.
98
+ The tool also accepts `includeRaw` for live-session data when settings allow it. A Debug Operation ID groups direct request ownership only. It is not a security identity, distributed trace, raw Pi Tool-call identity, or time-window correlation.
91
99
 
92
100
  ## Settings
93
101
 
@@ -28,6 +28,7 @@ pnpm add @mrclrchtr/supi-core
28
28
  - `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
29
29
  - `loadSupiConfigForScope()` — load one scope at a time for settings UIs
30
30
  - `writeSupiConfig()` — persist values
31
+ - `replaceSupiConfigSection()` — replace one nested section while preserving other sections
31
32
  - `removeSupiConfigKey()` — remove a key or override
32
33
 
33
34
  Config file locations:
@@ -49,6 +50,7 @@ Config file locations:
49
50
 
50
51
  - context-provider registry for `/supi-context`
51
52
  - debug-event registry and monotonic phase timers for producers that want shared debug capture
53
+ - optional Debug Operation IDs for exact, directly owned public Tool-call correlation; ambient events stay uncorrelated
52
54
  - settings registry used by `/supi-settings`
53
55
 
54
56
  ### Project and session helpers
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "4.9.0",
3
+ "version": "5.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -148,6 +148,37 @@ export function writeSupiConfig(
148
148
  fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
149
149
  }
150
150
 
151
+ /**
152
+ * Replace one complete config section while preserving other sections.
153
+ *
154
+ * This is useful for nested settings that must remove stale keys as part of
155
+ * one update. An empty section is removed from the config file.
156
+ */
157
+ export function replaceSupiConfigSection(
158
+ loc: SupiConfigLocation,
159
+ value: Record<string, unknown>,
160
+ options?: SupiConfigOptions,
161
+ ): void {
162
+ const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
163
+ const existing = readJsonFile(configPath) ?? {};
164
+
165
+ if (Object.keys(value).length > 0) existing[loc.section] = value;
166
+ else delete existing[loc.section];
167
+
168
+ const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
169
+ if (content) {
170
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
171
+ fs.writeFileSync(configPath, content, "utf-8");
172
+ return;
173
+ }
174
+
175
+ try {
176
+ fs.unlinkSync(configPath);
177
+ } catch {
178
+ // File may not exist.
179
+ }
180
+ }
181
+
151
182
  /**
152
183
  * Remove a key from a config section.
153
184
  * Used by `interval default` to remove the project override.
@@ -1,10 +1,12 @@
1
1
  // supi-core config domain — config loading.
2
2
  export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
3
3
  export {
4
+ getSupiConfigPath,
4
5
  loadSupiConfig,
5
6
  loadSupiConfigForScope,
6
7
  loadSupiConfigSectionForScope,
7
8
  readJsonFile,
8
9
  removeSupiConfigKey,
10
+ replaceSupiConfigSection,
9
11
  writeSupiConfig,
10
12
  } from "./config/config.ts";
@@ -25,6 +25,8 @@ export const DEBUG_REGISTRY_DEFAULTS: DebugRegistryConfig = {
25
25
  };
26
26
 
27
27
  export interface DebugEventInput {
28
+ /** Opaque identity for events directly owned by one public Tool call. */
29
+ operationId?: string;
28
30
  source: string;
29
31
  level: DebugLevel;
30
32
  category: string;
@@ -42,6 +44,8 @@ export interface DebugEvent extends DebugEventInput {
42
44
  }
43
45
 
44
46
  export interface DebugEventQuery {
47
+ /** Match one exact Debug Operation ID. */
48
+ operationId?: string;
45
49
  source?: string;
46
50
  level?: DebugLevel;
47
51
  category?: string;
@@ -53,6 +57,7 @@ export interface DebugEventQuery {
53
57
  export interface DebugEventView {
54
58
  id: number;
55
59
  timestamp: number;
60
+ operationId?: string;
56
61
  source: string;
57
62
  level: DebugLevel;
58
63
  category: string;
@@ -84,6 +89,7 @@ interface DebugRegistryState {
84
89
  }
85
90
 
86
91
  const REGISTRY_KEY = Symbol.for("@mrclrchtr/supi-core/debug-registry");
92
+ const DEBUG_OPERATION_ID_RE = /^op-[A-Za-z0-9_-]{21}[AQgw]$/;
87
93
  const SECRET_KEY_RE = /(?:token|password|passwd|secret|api[_-]?key|authorization|credential)/i;
88
94
  const ENV_SECRET_RE =
89
95
  /\b([A-Za-z0-9_]*(?:token|password|passwd|secret|api[_-]?key|authorization|credential)[A-Za-z0-9_]*)=(?:'[^']*'|"[^"]*"|\S+)/gi;
@@ -135,11 +141,17 @@ export function isDebugLevel(value: unknown): value is DebugLevel {
135
141
  return value === "debug" || value === "info" || value === "warning" || value === "error";
136
142
  }
137
143
 
138
- /** Match a debug event against the supported source, level, and category filters. */
144
+ /** Return whether a value has the exact 16-byte base64url Debug Operation ID form. */
145
+ export function isDebugOperationId(value: unknown): value is string {
146
+ return typeof value === "string" && DEBUG_OPERATION_ID_RE.test(value);
147
+ }
148
+
149
+ /** Match a debug event against the supported exact filters. */
139
150
  export function matchesDebugEventQuery(
140
- event: Pick<DebugEventView, "source" | "level" | "category">,
141
- query: Pick<DebugEventQuery, "source" | "level" | "category">,
151
+ event: Pick<DebugEventView, "operationId" | "source" | "level" | "category">,
152
+ query: Pick<DebugEventQuery, "operationId" | "source" | "level" | "category">,
142
153
  ): boolean {
154
+ if (query.operationId && event.operationId !== query.operationId) return false;
143
155
  if (query.source && event.source !== query.source) return false;
144
156
  if (query.level && event.level !== query.level) return false;
145
157
  if (query.category && event.category !== query.category) return false;
@@ -197,6 +209,7 @@ function toSanitizedView(event: DebugEvent): DebugEventView {
197
209
  return {
198
210
  id: event.id,
199
211
  timestamp: event.timestamp,
212
+ operationId: event.operationId,
200
213
  source: event.source,
201
214
  level: event.level,
202
215
  category: event.category,
@@ -216,6 +229,9 @@ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
216
229
  /** Record a session-local debug event if debugging is enabled. */
217
230
  export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
218
231
  const state = getState();
232
+ if (input.operationId !== undefined && !isDebugOperationId(input.operationId)) {
233
+ return null;
234
+ }
219
235
  if (!state.config.enabled) {
220
236
  return null;
221
237
  }
@@ -46,13 +46,21 @@ export interface DebugTimer {
46
46
  * names are accumulated. Event data reserves the `timing` field. When Debug is
47
47
  * disabled at start, this returns a no-op timer and does not read the clock.
48
48
  * Pass a factory to `finish()` to avoid event-data construction when disabled.
49
+ * Clock, event-construction, and registry failures are isolated from the
50
+ * measured operation and make the timer a no-op.
49
51
  */
50
52
  export function startDebugTimer(options: DebugTimerOptions = {}): DebugTimer {
51
53
  if (!isDebugRegistryEnabled()) return DISABLED_DEBUG_TIMER;
52
54
  const now = options.now ?? performance.now.bind(performance);
53
- const startedAt = now();
55
+ let startedAt: number;
56
+ try {
57
+ startedAt = now();
58
+ } catch {
59
+ return DISABLED_DEBUG_TIMER;
60
+ }
54
61
  let previousAt = startedAt;
55
62
  let finished = false;
63
+ let failed = false;
56
64
  const phases = new Map<string, number>();
57
65
 
58
66
  const markAt = (phase: string, current: number): void => {
@@ -65,30 +73,35 @@ export function startDebugTimer(options: DebugTimerOptions = {}): DebugTimer {
65
73
  return {
66
74
  enabled: true,
67
75
  mark(phase) {
68
- if (finished) return;
69
- markAt(phase, now());
76
+ if (finished || failed) return;
77
+ try {
78
+ markAt(phase, now());
79
+ } catch {
80
+ failed = true;
81
+ }
70
82
  },
71
83
  finish(input, finalPhase) {
72
- if (finished) return null;
73
- if (!isDebugRegistryEnabled()) {
74
- finished = true;
84
+ if (finished || failed) return null;
85
+ finished = true;
86
+ try {
87
+ if (!isDebugRegistryEnabled()) return null;
88
+ const completedAt = now();
89
+ if (finalPhase) markAt(finalPhase, completedAt);
90
+ const phasesMs = Object.fromEntries(
91
+ [...phases.entries()].map(([name, value]) => [name, duration(value)]),
92
+ );
93
+ const timing: DebugTiming = {
94
+ durationMs: duration(completedAt - startedAt),
95
+ phasesMs,
96
+ };
97
+ const eventInput = typeof input === "function" ? input() : input;
98
+ return recordDebugEvent({
99
+ ...eventInput,
100
+ data: { ...eventInput.data, timing },
101
+ });
102
+ } catch {
75
103
  return null;
76
104
  }
77
- const completedAt = now();
78
- if (finalPhase) markAt(finalPhase, completedAt);
79
- finished = true;
80
- const phasesMs = Object.fromEntries(
81
- [...phases.entries()].map(([name, value]) => [name, duration(value)]),
82
- );
83
- const timing: DebugTiming = {
84
- durationMs: duration(completedAt - startedAt),
85
- phasesMs,
86
- };
87
- const eventInput = typeof input === "function" ? input() : input;
88
- return recordDebugEvent({
89
- ...eventInput,
90
- data: { ...eventInput.data, timing },
91
- });
92
105
  },
93
106
  };
94
107
  }
@@ -41,7 +41,10 @@ export interface SettingsApplyResult {
41
41
  */
42
42
  export interface SettingsModule {
43
43
  id: string;
44
+ /** Human-readable section label shown in the UI. */
44
45
  label: string;
46
+ /** Optional label that groups this module within its section. */
47
+ subsection?: string;
45
48
  read(context: SettingsContext): Promise<SettingsSnapshot>;
46
49
  apply(request: SettingsActionRequest): Promise<SettingsApplyResult>;
47
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-debug",
3
- "version": "4.9.0",
3
+ "version": "5.0.0",
4
4
  "description": "Capture and inspect SuPi debug events",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,10 +28,11 @@
28
28
  "type": "module",
29
29
  "files": [
30
30
  "src/**/*.ts",
31
+ "prompts",
31
32
  "README.md"
32
33
  ],
33
34
  "dependencies": {
34
- "@mrclrchtr/supi-core": "4.9.0"
35
+ "@mrclrchtr/supi-core": "5.0.0"
35
36
  },
36
37
  "bundledDependencies": [
37
38
  "@mrclrchtr/supi-core"
@@ -0,0 +1,62 @@
1
+ ---
2
+ description: Retrospect on SuPi tooling used during the completed task
3
+ ---
4
+
5
+ Produce a compact, evidence-based retrospective of the task that just ended. Evaluate SuPi-provided tools, skills, prompts, extensions, and injected context—not the entire suite in the abstract.
6
+
7
+ ## Evidence rules
8
+
9
+ - Reconstruct the retrospective from this session only. Do not invent tool calls, outcomes, failures, token costs, or user impact.
10
+ - Tag claims as `[observed]`, `[inferred]`, or `[unknown]`. An observed claim is supported by the transcript or a tool result; an inferred claim is plausible but untested.
11
+ - Use the exact surface name shown in the session. Do not treat a tool being available but unused as evidence that it failed.
12
+ - If no SuPi surface was used or relevant, say so explicitly. Do not manufacture a wishlist; list only task-specific, plausible missed help.
13
+ - Do not make tool calls or perform follow-up investigation. Stop after writing the retrospective.
14
+
15
+ ## Evaluate
16
+
17
+ - Actual SuPi surfaces used: what they enabled, where they fell short, and any failure or friction.
18
+ - Missed opportunities: a relevant unused tool (like supi-code-intelligence tools) or context source, and why it was missed—discoverability, guidance, timing, capability, or not applicable.
19
+ - Missing pieces: a concrete utility, capability, documentation page, example, or output improvement that would have changed this task.
20
+ - Noise: redundant instructions, repeated advice without added value, stale or irrelevant context, excessive output, unnecessary long paths, poor timing, or misleading guidance. Do not criticize necessary context without naming the avoidable cost.
21
+ - Keep SuPi/tooling recommendations separate from general code or project recommendations.
22
+
23
+ ## Output rules
24
+
25
+ - Keep the result under about 500 words and specific to this task.
26
+ - Prefer concrete evidence and observed friction over generic praise or a general feature wishlist.
27
+ - If a section has no supported item, write `None identified` and explain the evidence limit briefly.
28
+ - Include at most three recommendations. Each must name a changeable surface (tool, prompt, skill, docs, guidance, or feature), the proposed change, and the expected benefit. Do not recommend “use the tool more” unless discoverability is the identified cause.
29
+ - Do not edit files, open issues, update OpenSpec artifacts, or take any other follow-up action.
30
+
31
+ ## Required output
32
+
33
+ ## SuPi Tooling Retrospective
34
+
35
+ **Task completed**: <1–2 sentence summary>
36
+
37
+ ### Tools used
38
+ - `[observed]` **`<tool, skill, prompt, extension, or context>`** — concrete help, friction, or failure.
39
+ - If none: `None identified — no SuPi surface materially participated in this task.`
40
+
41
+ ### Missed opportunities
42
+ - `[inferred]` **`<surface>`** — task-specific help it might have provided; cause: discoverability, guidance, timing, capability, or not applicable.
43
+ - If none: `None identified — do not infer a gap from non-use alone.`
44
+
45
+ ### Missing pieces
46
+ - `[observed|inferred]` **`<utility, feature, docs, example, or output change>`** — the concrete gap and how it affected this task.
47
+ - If none: `None identified.`
48
+
49
+ ### Unhelpful or noisy context
50
+ - `[observed]` **`<instruction, context, or output>`** — what was unnecessary or costly and how it could be reduced or better timed.
51
+ - If none: `None identified.`
52
+
53
+ ### Prioritized recommendations
54
+ 1. **`<named surface>`** — <specific change>; <expected benefit>; confidence: <high|medium|low>.
55
+ 2. **`<named surface>`** — <specific change>; <expected benefit>; confidence: <high|medium|low>.
56
+ 3. **`<named surface>`** — <specific change>; <expected benefit>; confidence: <high|medium|low>.
57
+ - Include only supported recommendations; if none are supported, write `None identified`.
58
+
59
+ ### Confidence / evidence
60
+ - Direct evidence: <what the session demonstrates>
61
+ - Inference: <what is plausible but untested>
62
+ - Limits: <what the session cannot establish>
package/src/command.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { getDebugEvents } from "@mrclrchtr/supi-core/debug";
3
+ import { resolveToolPath } from "@mrclrchtr/supi-core/path";
4
+ import { formatDebugEvents, truncateDebugOutput } from "./output.ts";
5
+ import type { DebugToolParams } from "./query.ts";
6
+ import { parseDebugCommandArgs } from "./query.ts";
7
+ import { createDebugMessageDetails } from "./renderer.ts";
8
+ import { readSessionDebugEvents } from "./session-events.ts";
9
+
10
+ const DEBUG_REPORT_TYPE = "supi-debug-report";
11
+
12
+ interface DebugConfig {
13
+ enabled: boolean;
14
+ }
15
+
16
+ interface DebugCommandDependencies {
17
+ pi: ExtensionAPI;
18
+ applyConfig: (cwd: string) => DebugConfig;
19
+ normalizeLimit: (value: string) => number;
20
+ }
21
+
22
+ function persistedDebugFilters(query: DebugToolParams) {
23
+ return {
24
+ operationId: query.operationId,
25
+ source: query.source,
26
+ level: query.level,
27
+ category: query.category,
28
+ limit: query.limit,
29
+ };
30
+ }
31
+
32
+ async function sendPersistedDebugReport(
33
+ query: DebugToolParams,
34
+ ctx: ExtensionCommandContext,
35
+ pi: ExtensionAPI,
36
+ ): Promise<void> {
37
+ const statusKey = "supi-debug";
38
+ const setStatus = ctx.ui?.setStatus;
39
+ try {
40
+ const filters = persistedDebugFilters(query);
41
+ if (!query.sessionFile)
42
+ throw new Error("A session file is required for historical debug events.");
43
+ const sessionFile = resolveToolPath(ctx.cwd, query.sessionFile);
44
+ const persisted =
45
+ ctx.signal || setStatus
46
+ ? await readSessionDebugEvents(sessionFile, filters, {
47
+ signal: ctx.signal,
48
+ onProgress: (progress) => {
49
+ setStatus?.(
50
+ statusKey,
51
+ `Reading debug events: ${progress.scannedLines.toLocaleString("en-US")} lines · ${progress.matchedEvents.toLocaleString("en-US")} matches`,
52
+ );
53
+ },
54
+ })
55
+ : await readSessionDebugEvents(sessionFile, filters);
56
+ const output = truncateDebugOutput(
57
+ formatDebugEvents(persisted.events, false, false, persisted.persistedEventCount).join("\n"),
58
+ );
59
+ pi.sendMessage({
60
+ customType: DEBUG_REPORT_TYPE,
61
+ content: output.text,
62
+ display: true,
63
+ details: createDebugMessageDetails(persisted.events, {
64
+ sessionFile: query.sessionFile,
65
+ persistedEventCount: persisted.persistedEventCount,
66
+ eventCount: persisted.events.length,
67
+ emptyReason:
68
+ persisted.events.length === 0
69
+ ? persisted.persistedEventCount === 0
70
+ ? "no-persisted-events"
71
+ : "no-matches"
72
+ : undefined,
73
+ truncation: output.truncation,
74
+ }),
75
+ });
76
+ } finally {
77
+ setStatus?.(statusKey, undefined);
78
+ }
79
+ }
80
+
81
+ function sendLiveDebugReport(query: DebugToolParams, pi: ExtensionAPI): void {
82
+ const { events, rawAccessDenied } = getDebugEvents(query);
83
+ const output = truncateDebugOutput(formatDebugEvents(events, rawAccessDenied).join("\n"));
84
+ pi.sendMessage({
85
+ customType: DEBUG_REPORT_TYPE,
86
+ content: output.text,
87
+ display: true,
88
+ details: createDebugMessageDetails(events, {
89
+ rawAccessDenied,
90
+ eventCount: events.length,
91
+ emptyReason: events.length === 0 ? "no-matches" : undefined,
92
+ truncation: output.truncation,
93
+ }),
94
+ });
95
+ }
96
+
97
+ async function handleDebugCommand(
98
+ args: string,
99
+ ctx: ExtensionCommandContext,
100
+ dependencies: DebugCommandDependencies,
101
+ ): Promise<void> {
102
+ const { pi, applyConfig, normalizeLimit } = dependencies;
103
+ const config = applyConfig(ctx.cwd);
104
+ const query = parseDebugCommandArgs(args, normalizeLimit);
105
+ if (!config.enabled && !query.sessionFile) {
106
+ pi.sendMessage({
107
+ customType: DEBUG_REPORT_TYPE,
108
+ content: "SuPi debug event capture is disabled. Enable Debug in /supi-settings.",
109
+ display: true,
110
+ });
111
+ return;
112
+ }
113
+
114
+ if (query.sessionFile) {
115
+ await sendPersistedDebugReport(query, ctx, pi);
116
+ return;
117
+ }
118
+ sendLiveDebugReport(query, pi);
119
+ }
120
+
121
+ /** Register the user-facing debug command. */
122
+ export function registerDebugCommand(
123
+ pi: ExtensionAPI,
124
+ applyConfig: (cwd: string) => DebugConfig,
125
+ normalizeLimit: (value: string) => number,
126
+ ): void {
127
+ const dependencies = { pi, applyConfig, normalizeLimit };
128
+ pi.registerCommand("supi-debug", {
129
+ description: "Show recent SuPi debug events",
130
+ handler: (args, ctx) => handleDebugCommand(args, ctx, dependencies),
131
+ });
132
+ }