@herbertgao/pi-extensions 2026.9.7 → 2026.9.9

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.
Files changed (158) hide show
  1. package/README.md +8 -4
  2. package/THIRD_PARTY_NOTICES.md +52 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  7. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  8. package/node_modules/@narumitw/pi-btw/README.md +6 -1
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts +209 -23
  10. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +1 -12
  13. package/node_modules/@narumitw/pi-btw/src/menu.ts +244 -15
  14. package/node_modules/@narumitw/pi-btw/src/settings.ts +5 -0
  15. package/node_modules/grok-mermaid/CHANGELOG.md +46 -0
  16. package/node_modules/grok-mermaid/LICENSE +205 -0
  17. package/node_modules/grok-mermaid/README.md +191 -0
  18. package/node_modules/grok-mermaid/dist/ansi.d.ts +16 -0
  19. package/node_modules/grok-mermaid/dist/ansi.d.ts.map +1 -0
  20. package/node_modules/grok-mermaid/dist/ansi.js +23 -0
  21. package/node_modules/grok-mermaid/dist/ansi.js.map +1 -0
  22. package/node_modules/grok-mermaid/dist/canvas.d.ts +87 -0
  23. package/node_modules/grok-mermaid/dist/canvas.d.ts.map +1 -0
  24. package/node_modules/grok-mermaid/dist/canvas.js +366 -0
  25. package/node_modules/grok-mermaid/dist/canvas.js.map +1 -0
  26. package/node_modules/grok-mermaid/dist/graph.d.ts +74 -0
  27. package/node_modules/grok-mermaid/dist/graph.d.ts.map +1 -0
  28. package/node_modules/grok-mermaid/dist/graph.js +91 -0
  29. package/node_modules/grok-mermaid/dist/graph.js.map +1 -0
  30. package/node_modules/grok-mermaid/dist/index.d.ts +32 -0
  31. package/node_modules/grok-mermaid/dist/index.d.ts.map +1 -0
  32. package/node_modules/grok-mermaid/dist/index.js +100 -0
  33. package/node_modules/grok-mermaid/dist/index.js.map +1 -0
  34. package/node_modules/grok-mermaid/dist/labels.d.ts +62 -0
  35. package/node_modules/grok-mermaid/dist/labels.d.ts.map +1 -0
  36. package/node_modules/grok-mermaid/dist/labels.js +324 -0
  37. package/node_modules/grok-mermaid/dist/labels.js.map +1 -0
  38. package/node_modules/grok-mermaid/dist/layout-seq.d.ts +12 -0
  39. package/node_modules/grok-mermaid/dist/layout-seq.d.ts.map +1 -0
  40. package/node_modules/grok-mermaid/dist/layout-seq.js +194 -0
  41. package/node_modules/grok-mermaid/dist/layout-seq.js.map +1 -0
  42. package/node_modules/grok-mermaid/dist/layout.d.ts +87 -0
  43. package/node_modules/grok-mermaid/dist/layout.d.ts.map +1 -0
  44. package/node_modules/grok-mermaid/dist/layout.js +881 -0
  45. package/node_modules/grok-mermaid/dist/layout.js.map +1 -0
  46. package/node_modules/grok-mermaid/dist/parse.d.ts +83 -0
  47. package/node_modules/grok-mermaid/dist/parse.d.ts.map +1 -0
  48. package/node_modules/grok-mermaid/dist/parse.js +1151 -0
  49. package/node_modules/grok-mermaid/dist/parse.js.map +1 -0
  50. package/node_modules/grok-mermaid/dist/source-box.d.ts +18 -0
  51. package/node_modules/grok-mermaid/dist/source-box.d.ts.map +1 -0
  52. package/node_modules/grok-mermaid/dist/source-box.js +78 -0
  53. package/node_modules/grok-mermaid/dist/source-box.js.map +1 -0
  54. package/node_modules/grok-mermaid/dist/types.d.ts +42 -0
  55. package/node_modules/grok-mermaid/dist/types.d.ts.map +1 -0
  56. package/node_modules/grok-mermaid/dist/types.js +1 -0
  57. package/node_modules/grok-mermaid/dist/types.js.map +1 -0
  58. package/node_modules/grok-mermaid/dist/width-data.d.ts +2 -0
  59. package/node_modules/grok-mermaid/dist/width-data.d.ts.map +1 -0
  60. package/node_modules/grok-mermaid/dist/width-data.js +994 -0
  61. package/node_modules/grok-mermaid/dist/width-data.js.map +1 -0
  62. package/node_modules/grok-mermaid/dist/width.d.ts +18 -0
  63. package/node_modules/grok-mermaid/dist/width.d.ts.map +1 -0
  64. package/node_modules/grok-mermaid/dist/width.js +76 -0
  65. package/node_modules/grok-mermaid/dist/width.js.map +1 -0
  66. package/node_modules/grok-mermaid/package.json +49 -0
  67. package/node_modules/grok-mermaid/src/ansi.ts +34 -0
  68. package/node_modules/grok-mermaid/src/canvas.ts +373 -0
  69. package/node_modules/grok-mermaid/src/graph.ts +142 -0
  70. package/node_modules/grok-mermaid/src/index.ts +104 -0
  71. package/node_modules/grok-mermaid/src/labels.ts +326 -0
  72. package/node_modules/grok-mermaid/src/layout-seq.ts +203 -0
  73. package/node_modules/grok-mermaid/src/layout.ts +1015 -0
  74. package/node_modules/grok-mermaid/src/parse.ts +1189 -0
  75. package/node_modules/grok-mermaid/src/source-box.ts +89 -0
  76. package/node_modules/grok-mermaid/src/types.ts +43 -0
  77. package/node_modules/grok-mermaid/src/width-data.ts +993 -0
  78. package/node_modules/grok-mermaid/src/width.ts +74 -0
  79. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +110 -0
  80. package/node_modules/pi-jev-auto-mode/LICENSE +21 -0
  81. package/node_modules/pi-jev-auto-mode/README.md +285 -0
  82. package/node_modules/pi-jev-auto-mode/SECURITY.md +32 -0
  83. package/node_modules/pi-jev-auto-mode/docs/calibration.md +154 -0
  84. package/node_modules/pi-jev-auto-mode/docs/design.md +164 -0
  85. package/node_modules/pi-jev-auto-mode/docs/security.md +124 -0
  86. package/node_modules/pi-jev-auto-mode/index.ts +1 -0
  87. package/node_modules/pi-jev-auto-mode/package.json +72 -0
  88. package/node_modules/pi-jev-auto-mode/src/call.ts +180 -0
  89. package/node_modules/pi-jev-auto-mode/src/decide.ts +86 -0
  90. package/node_modules/pi-jev-auto-mode/src/extension.ts +862 -0
  91. package/node_modules/pi-jev-auto-mode/src/intent.ts +71 -0
  92. package/node_modules/pi-jev-auto-mode/src/jev/availability.ts +53 -0
  93. package/node_modules/pi-jev-auto-mode/src/jev/criteria.ts +19 -0
  94. package/node_modules/pi-jev-auto-mode/src/jev/decide.ts +187 -0
  95. package/node_modules/pi-jev-auto-mode/src/jev/engine.ts +165 -0
  96. package/node_modules/pi-jev-auto-mode/src/jev/index.ts +20 -0
  97. package/node_modules/pi-jev-auto-mode/src/jev/questions.ts +273 -0
  98. package/node_modules/pi-jev-auto-mode/src/jev/response.ts +64 -0
  99. package/node_modules/pi-jev-auto-mode/src/jev/state.ts +20 -0
  100. package/node_modules/pi-jev-auto-mode/src/jev/transport.ts +117 -0
  101. package/node_modules/pi-jev-auto-mode/src/jev/types.ts +46 -0
  102. package/node_modules/pi-jev-auto-mode/src/policy.ts +558 -0
  103. package/node_modules/pi-jev-auto-mode/src/records.ts +118 -0
  104. package/node_modules/pi-jev-auto-mode/src/settings.ts +327 -0
  105. package/node_modules/pi-jev-auto-mode/src/ui.ts +233 -0
  106. package/node_modules/pi-lens/CHANGELOG.md +45 -0
  107. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +21 -9
  108. package/node_modules/pi-lens/dist/clients/cascade-format.js +24 -0
  109. package/node_modules/pi-lens/dist/clients/extension-log.js +48 -1
  110. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +11 -12
  111. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +3 -0
  112. package/node_modules/pi-lens/dist/clients/lsp/index.js +41 -7
  113. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +9 -2
  114. package/node_modules/pi-lens/dist/clients/path-utils.js +51 -9
  115. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +271 -0
  116. package/node_modules/pi-lens/dist/clients/pipeline.js +15 -1
  117. package/node_modules/pi-lens/dist/clients/runtime-turn.js +124 -7
  118. package/node_modules/pi-lens/dist/clients/skills-resolver.js +38 -2
  119. package/node_modules/pi-lens/dist/clients/widget-state.js +152 -6
  120. package/node_modules/pi-lens/dist/index.js +729 -344
  121. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +14 -2
  122. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +84 -17
  123. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +32 -5
  124. package/node_modules/pi-lens/package.json +1 -1
  125. package/node_modules/pi-typesafe/LICENSE +21 -0
  126. package/node_modules/pi-typesafe/README.md +153 -0
  127. package/node_modules/pi-typesafe/dist/ask.d.ts +32 -0
  128. package/node_modules/pi-typesafe/dist/ask.js +25 -0
  129. package/node_modules/pi-typesafe/dist/auth.d.ts +56 -0
  130. package/node_modules/pi-typesafe/dist/auth.js +114 -0
  131. package/node_modules/pi-typesafe/dist/batch.d.ts +74 -0
  132. package/node_modules/pi-typesafe/dist/batch.js +116 -0
  133. package/node_modules/pi-typesafe/dist/calibrate.d.ts +106 -0
  134. package/node_modules/pi-typesafe/dist/calibrate.js +157 -0
  135. package/node_modules/pi-typesafe/dist/client.d.ts +69 -0
  136. package/node_modules/pi-typesafe/dist/client.js +188 -0
  137. package/node_modules/pi-typesafe/dist/credentials.d.ts +46 -0
  138. package/node_modules/pi-typesafe/dist/credentials.js +112 -0
  139. package/node_modules/pi-typesafe/dist/errors.d.ts +9 -0
  140. package/node_modules/pi-typesafe/dist/errors.js +31 -0
  141. package/node_modules/pi-typesafe/dist/extension.d.ts +3 -0
  142. package/node_modules/pi-typesafe/dist/extension.js +222 -0
  143. package/node_modules/pi-typesafe/dist/index.d.ts +18 -0
  144. package/node_modules/pi-typesafe/dist/index.js +9 -0
  145. package/node_modules/pi-typesafe/dist/key-prompt.d.ts +3 -0
  146. package/node_modules/pi-typesafe/dist/key-prompt.js +43 -0
  147. package/node_modules/pi-typesafe/dist/login.d.ts +27 -0
  148. package/node_modules/pi-typesafe/dist/login.js +38 -0
  149. package/node_modules/pi-typesafe/dist/schema.d.ts +43 -0
  150. package/node_modules/pi-typesafe/dist/schema.js +152 -0
  151. package/node_modules/pi-typesafe/dist/ui.d.ts +3 -0
  152. package/node_modules/pi-typesafe/dist/ui.js +3 -0
  153. package/node_modules/pi-typesafe/dist/usage.d.ts +73 -0
  154. package/node_modules/pi-typesafe/dist/usage.js +182 -0
  155. package/node_modules/pi-typesafe/examples/decision-extension.ts +41 -0
  156. package/node_modules/pi-typesafe/extensions/index.js +2 -0
  157. package/node_modules/pi-typesafe/package.json +89 -0
  158. package/package.json +16 -8
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Settings, policy notes, and the stored API key.
3
+ *
4
+ * Global settings live next to the rest of the Pi agent state
5
+ * (`$PI_CODING_AGENT_DIR` or `~/.pi/agent`). A project can override them from
6
+ * `<cwd>/<CONFIG_DIR_NAME>/jev-auto-mode.json`, but only for a trusted project:
7
+ * an untrusted checkout must not be able to loosen the gate that is judging it.
8
+ *
9
+ * The API key is not a setting. It goes to `<agentDir>/secrets/` as a `0600` file,
10
+ * which is where Pi keeps its own credentials, so that it is neither committed with
11
+ * a project nor readable by other users on the machine.
12
+ */
13
+
14
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
15
+ import { dirname, join } from "node:path";
16
+
17
+ export interface JevAutoModeSettings {
18
+ readonly enabled: boolean;
19
+ /** Per-attempt Jev timeout. Kept short: this is a gate, not a batch job. */
20
+ readonly timeoutMs: number;
21
+ /** Retries after the first attempt. */
22
+ readonly maxRetries: number;
23
+ /** Commands the user considers safe to run without a decision record. */
24
+ readonly safeCommands: readonly string[];
25
+ /** Commands that override a dangerous-pattern match; the override is recorded. */
26
+ readonly allowedCommands: readonly string[];
27
+ readonly disallowedCommands: readonly string[];
28
+ readonly extraProtectedPaths: readonly string[];
29
+ /** Shared state + questions budget guard, in characters. */
30
+ readonly maxStateCharacters: number;
31
+ /** What a middle-band judgment means. Default `allow`: no user confirmation. */
32
+ readonly uncertain: UncertainAction;
33
+ /**
34
+ * Which calls reach the semantic layer.
35
+ *
36
+ * `all` (the default) sends every call the deterministic layer cannot vouch for
37
+ * to Jev, so an unrecognised shape is still judged. `matched` only judges calls
38
+ * that match a dangerous-command pattern, which is the older denylist behaviour.
39
+ */
40
+ readonly gateScope: GateScope;
41
+ /**
42
+ * Per-rule probability thresholds, overriding the calibrated defaults.
43
+ *
44
+ * Keys are rule ids. An unknown key is kept but has no effect, so a typo is
45
+ * visible in `/jev-auto-mode threshold` instead of silently resetting the rule.
46
+ */
47
+ readonly thresholds: Readonly<Record<string, number>>;
48
+ }
49
+
50
+ export type SettingsScope = "global" | "project";
51
+
52
+ /**
53
+ * How a judgment that lands in the middle band is resolved.
54
+ *
55
+ * `allow` (the default) keeps an auto mode useful: Jev blocks what it can clearly
56
+ * reject and lets an unclear answer through, so the gate never interrupts. `deny`
57
+ * is the conservative alternative for anyone who wants "not sure" to stop a call.
58
+ * `ask` hands the call to the user, which contradicts the point of an auto mode and
59
+ * is therefore not the default.
60
+ */
61
+ export type UncertainAction = "deny" | "ask" | "allow";
62
+
63
+ export const UNCERTAIN_ACTIONS: readonly UncertainAction[] = ["deny", "ask", "allow"];
64
+
65
+ /**
66
+ * How far the semantic layer reaches.
67
+ *
68
+ * A denylist can only recognise the shapes someone thought of first: a request that
69
+ * uploads a file (`curl -d @...`) once ran with no judgment at all because no pattern
70
+ * described it. `all` inverts that: the deterministic layer names what it can vouch
71
+ * for, and everything else is judged.
72
+ */
73
+ export type GateScope = "all" | "matched";
74
+
75
+ export const GATE_SCOPES: readonly GateScope[] = ["all", "matched"];
76
+
77
+ export function isGateScope(value: unknown): value is GateScope {
78
+ return typeof value === "string" && GATE_SCOPES.includes(value as GateScope);
79
+ }
80
+
81
+ export const DEFAULT_SETTINGS: JevAutoModeSettings = {
82
+ enabled: true,
83
+ timeoutMs: 4000,
84
+ maxRetries: 1,
85
+ safeCommands: [],
86
+ allowedCommands: [],
87
+ disallowedCommands: [],
88
+ extraProtectedPaths: [],
89
+ maxStateCharacters: 120_000,
90
+ uncertain: "allow",
91
+ gateScope: "all",
92
+ thresholds: {},
93
+ };
94
+
95
+ const MAX_PATTERN_ENTRIES = 200;
96
+ const MAX_PATTERN_LENGTH = 300;
97
+ const MAX_THRESHOLD_ENTRIES = 32;
98
+ const MAX_RULE_ID_LENGTH = 64;
99
+ const MAX_POLICY_NOTES_LENGTH = 8000;
100
+ const CREDENTIAL_FILE_NAME = "jev-auto-mode-typesafe-api-key";
101
+ /** Mirrors Pi's own secret directory/file modes. */
102
+ const SECRET_DIRECTORY_MODE = 0o700;
103
+ const SECRET_FILE_MODE = 0o600;
104
+ const MIN_TIMEOUT_MS = 250;
105
+ const MAX_TIMEOUT_MS = 60_000;
106
+ const MAX_RETRIES = 5;
107
+
108
+ /** A probability threshold must leave a middle band on both sides. */
109
+ export const MIN_THRESHOLD = 0.5;
110
+ export const MAX_THRESHOLD = 1;
111
+
112
+ export interface StoreOptions {
113
+ /** Usually `~/.pi/agent`, honoring `PI_CODING_AGENT_DIR`. */
114
+ readonly agentDir: string;
115
+ /** Usually `.pi` (`CONFIG_DIR_NAME`). */
116
+ readonly configDirName: string;
117
+ }
118
+
119
+ export type SettingsPatch = { -readonly [K in keyof JevAutoModeSettings]?: JevAutoModeSettings[K] };
120
+
121
+ function isRecord(value: unknown): value is Record<string, unknown> {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value);
123
+ }
124
+
125
+ /** Validate a single threshold value. Returns `undefined` when it is not usable. */
126
+ export function parseThreshold(value: unknown): number | undefined {
127
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
128
+ if (value <= MIN_THRESHOLD || value > MAX_THRESHOLD) return undefined;
129
+ return value;
130
+ }
131
+
132
+ function readThresholds(value: unknown): Readonly<Record<string, number>> | undefined {
133
+ if (!isRecord(value)) return undefined;
134
+ const thresholds: Record<string, number> = {};
135
+ for (const [ruleId, raw] of Object.entries(value)) {
136
+ if (ruleId.length === 0 || ruleId.length > MAX_RULE_ID_LENGTH) continue;
137
+ const threshold = parseThreshold(raw);
138
+ if (threshold === undefined) continue;
139
+ thresholds[ruleId] = threshold;
140
+ if (Object.keys(thresholds).length >= MAX_THRESHOLD_ENTRIES) break;
141
+ }
142
+ return thresholds;
143
+ }
144
+
145
+ export function isUncertainAction(value: unknown): value is UncertainAction {
146
+ return typeof value === "string" && UNCERTAIN_ACTIONS.includes(value as UncertainAction);
147
+ }
148
+
149
+ function readBoundedInteger(value: unknown, min: number, max: number): number | undefined {
150
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
151
+ const rounded = Math.round(value);
152
+ if (rounded < min || rounded > max) return undefined;
153
+ return rounded;
154
+ }
155
+
156
+ function readStringArray(value: unknown): readonly string[] | undefined {
157
+ if (!Array.isArray(value)) return undefined;
158
+ return value
159
+ .filter((entry): entry is string => typeof entry === "string")
160
+ .map((entry) => entry.trim())
161
+ .filter((entry) => entry.length > 0 && entry.length <= MAX_PATTERN_LENGTH && !entry.includes("\n"))
162
+ .slice(0, MAX_PATTERN_ENTRIES);
163
+ }
164
+
165
+ /**
166
+ * Validate an untrusted settings file.
167
+ *
168
+ * Malformed values are dropped rather than replaced by a default: a broken
169
+ * project file must not be able to pin a value that overrides the global layer.
170
+ * Unknown fields are ignored too.
171
+ */
172
+ export function parseSettingsPatch(value: unknown): SettingsPatch {
173
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
174
+ const record = value as Record<string, unknown>;
175
+ const patch: SettingsPatch = {};
176
+
177
+ if (typeof record.enabled === "boolean") patch.enabled = record.enabled;
178
+
179
+ const timeoutMs = readBoundedInteger(record.timeoutMs, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
180
+ if (timeoutMs !== undefined) patch.timeoutMs = timeoutMs;
181
+
182
+ const maxRetries = readBoundedInteger(record.maxRetries, 0, MAX_RETRIES);
183
+ if (maxRetries !== undefined) patch.maxRetries = maxRetries;
184
+
185
+ const maxStateCharacters = readBoundedInteger(record.maxStateCharacters, 1000, 1_000_000);
186
+ if (maxStateCharacters !== undefined) patch.maxStateCharacters = maxStateCharacters;
187
+
188
+ if (record.uncertain !== undefined && isUncertainAction(record.uncertain)) {
189
+ patch.uncertain = record.uncertain;
190
+ }
191
+
192
+ if (record.gateScope !== undefined && isGateScope(record.gateScope)) {
193
+ patch.gateScope = record.gateScope;
194
+ }
195
+
196
+ const safeCommands = record.safeCommands === undefined ? undefined : readStringArray(record.safeCommands);
197
+ if (safeCommands !== undefined) patch.safeCommands = safeCommands;
198
+
199
+ const allowedCommands = record.allowedCommands === undefined ? undefined : readStringArray(record.allowedCommands);
200
+ if (allowedCommands !== undefined) patch.allowedCommands = allowedCommands;
201
+
202
+ const disallowedCommands =
203
+ record.disallowedCommands === undefined ? undefined : readStringArray(record.disallowedCommands);
204
+ if (disallowedCommands !== undefined) patch.disallowedCommands = disallowedCommands;
205
+
206
+ const extraProtectedPaths =
207
+ record.extraProtectedPaths === undefined ? undefined : readStringArray(record.extraProtectedPaths);
208
+ if (extraProtectedPaths !== undefined) patch.extraProtectedPaths = extraProtectedPaths;
209
+
210
+ const thresholds = record.thresholds === undefined ? undefined : readThresholds(record.thresholds);
211
+ if (thresholds !== undefined) patch.thresholds = thresholds;
212
+
213
+ return patch;
214
+ }
215
+
216
+ export function mergeSettings(base: JevAutoModeSettings, patch: SettingsPatch): JevAutoModeSettings {
217
+ const merged = { ...base, ...patch };
218
+ // Thresholds merge per rule: a project file that retunes one condition must not
219
+ // wipe the global overrides for the others.
220
+ if (patch.thresholds !== undefined) {
221
+ merged.thresholds = { ...base.thresholds, ...patch.thresholds };
222
+ }
223
+ return merged;
224
+ }
225
+
226
+ async function readJsonFile(path: string): Promise<unknown> {
227
+ try {
228
+ return JSON.parse(await readFile(path, "utf8"));
229
+ } catch {
230
+ return undefined;
231
+ }
232
+ }
233
+
234
+ async function writeFileAtomic(path: string, contents: string): Promise<void> {
235
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
236
+ await mkdir(dirname(path), { recursive: true });
237
+ await writeFile(temporary, contents, "utf8");
238
+ await rename(temporary, path);
239
+ }
240
+
241
+ export class JevAutoModeStore {
242
+ private readonly agentDir: string;
243
+ private readonly configDirName: string;
244
+
245
+ constructor(options: StoreOptions) {
246
+ this.agentDir = options.agentDir;
247
+ this.configDirName = options.configDirName;
248
+ }
249
+
250
+ globalSettingsPath(): string {
251
+ return join(this.agentDir, "jev-auto-mode.json");
252
+ }
253
+
254
+ projectSettingsPath(cwd: string): string {
255
+ return join(cwd, this.configDirName, "jev-auto-mode.json");
256
+ }
257
+
258
+ policyNotesPath(): string {
259
+ return join(this.agentDir, "jev-auto-mode-policy.md");
260
+ }
261
+
262
+ /** Global settings with the project override layered on top, when trusted. */
263
+ async loadSettings(cwd: string, projectTrusted: boolean): Promise<{ settings: JevAutoModeSettings; scope: SettingsScope }> {
264
+ const globalPatch = parseSettingsPatch(await readJsonFile(this.globalSettingsPath()));
265
+ if (!projectTrusted) {
266
+ return { settings: mergeSettings(DEFAULT_SETTINGS, globalPatch), scope: "global" };
267
+ }
268
+ const projectValue = await readJsonFile(this.projectSettingsPath(cwd));
269
+ const hasProjectSettings = projectValue !== undefined;
270
+ const projectPatch = parseSettingsPatch(projectValue);
271
+ return {
272
+ settings: mergeSettings(mergeSettings(DEFAULT_SETTINGS, globalPatch), projectPatch),
273
+ scope: hasProjectSettings ? "project" : "global",
274
+ };
275
+ }
276
+
277
+ async saveSettings(settings: JevAutoModeSettings, scope: SettingsScope, cwd: string): Promise<void> {
278
+ const path = scope === "project" ? this.projectSettingsPath(cwd) : this.globalSettingsPath();
279
+ await writeFileAtomic(path, `${JSON.stringify(settings, null, 2)}\n`);
280
+ }
281
+
282
+ /** User-authored policy notes. Advisory input to Jev, never a hard rule. */
283
+ async loadPolicyNotes(): Promise<string> {
284
+ try {
285
+ return (await readFile(this.policyNotesPath(), "utf8")).slice(0, MAX_POLICY_NOTES_LENGTH);
286
+ } catch {
287
+ return "";
288
+ }
289
+ }
290
+
291
+ async savePolicyNotes(notes: string): Promise<void> {
292
+ await writeFileAtomic(this.policyNotesPath(), notes.slice(0, MAX_POLICY_NOTES_LENGTH));
293
+ }
294
+
295
+ credentialPath(): string {
296
+ return join(this.agentDir, "secrets", CREDENTIAL_FILE_NAME);
297
+ }
298
+
299
+ async readStoredApiKey(): Promise<string | undefined> {
300
+ try {
301
+ const value = (await readFile(this.credentialPath(), "utf8")).trim();
302
+ return value.length > 0 ? value : undefined;
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Store the API key with owner-only permissions.
310
+ *
311
+ * `mode` on `writeFile` only applies when the file is created, so the mode is set
312
+ * again afterwards: an existing file with looser permissions is tightened rather
313
+ * than trusted.
314
+ */
315
+ async writeStoredApiKey(apiKey: string): Promise<void> {
316
+ const path = this.credentialPath();
317
+ const directory = dirname(path);
318
+ await mkdir(directory, { recursive: true, mode: SECRET_DIRECTORY_MODE });
319
+ await chmod(directory, SECRET_DIRECTORY_MODE).catch(() => undefined);
320
+ await writeFile(path, `${apiKey.trim()}\n`, { encoding: "utf8", mode: SECRET_FILE_MODE });
321
+ await chmod(path, SECRET_FILE_MODE).catch(() => undefined);
322
+ }
323
+
324
+ async deleteStoredApiKey(): Promise<void> {
325
+ await rm(this.credentialPath(), { force: true });
326
+ }
327
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Footer status and user-facing text.
3
+ *
4
+ * The status line is the only always-visible signal that a probabilistic gate is
5
+ * standing between the model and the shell, so it stays short but never silent:
6
+ * "off" and "no decision engine" are different states and are shown differently.
7
+ */
8
+
9
+ import type { JevAutoModeSettings, SettingsScope } from "./settings.ts";
10
+ import { DEFAULT_RULES, type JevRule } from "./jev/questions.ts";
11
+ import { formatThreshold, observe } from "./jev/decide.ts";
12
+
13
+ export const STATUS_ID = "jev-auto-mode";
14
+
15
+ export interface StatusInput {
16
+ readonly enabled: boolean;
17
+ readonly engineId: string;
18
+ readonly scope: SettingsScope;
19
+ }
20
+
21
+ export function statusText(input: StatusInput): string {
22
+ if (!input.enabled) return "🛡 jev off";
23
+ const scope = input.scope === "project" ? "project" : "global";
24
+ return input.engineId === "manual" ? `🛡 jev no key (${scope})` : `🛡 jev (${scope})`;
25
+ }
26
+
27
+ export interface StatusContext {
28
+ readonly ui: { setStatus(key: string, value: string | undefined): void };
29
+ }
30
+
31
+ export function updateStatus(ctx: StatusContext, input: StatusInput): void {
32
+ ctx.ui.setStatus(STATUS_ID, statusText(input));
33
+ }
34
+
35
+ export function describeSettings(settings: JevAutoModeSettings, scope: SettingsScope): string {
36
+ return [
37
+ `enabled: ${settings.enabled}`,
38
+ `scope: ${scope}`,
39
+ `timeout: ${settings.timeoutMs}ms (retries ${settings.maxRetries})`,
40
+ `safe commands: ${settings.safeCommands.length}`,
41
+ `allowed commands: ${settings.allowedCommands.length}`,
42
+ `disallowed commands: ${settings.disallowedCommands.length}`,
43
+ `extra protected paths: ${settings.extraProtectedPaths.length}`,
44
+ `max state characters: ${settings.maxStateCharacters}`,
45
+ `uncertain band: ${settings.uncertain}`,
46
+ `gate scope: ${settings.gateScope}`,
47
+ ].join("\n");
48
+ }
49
+
50
+ /**
51
+ * A bounded rendering of a command, for dialogs.
52
+ *
53
+ * Pi's dialogs do not clip their content: a 60-line title fills the pane and pushes
54
+ * the dialog's own heading off screen, and opening and closing one per tool call
55
+ * makes the terminal scroll back and forth. So the preview is bounded here, and the
56
+ * full command stays where it already is — in the tool call above the dialog.
57
+ */
58
+ export interface CommandPreview {
59
+ readonly lines: readonly string[];
60
+ readonly truncated: boolean;
61
+ readonly hiddenLines: number;
62
+ readonly hiddenCharacters: number;
63
+ }
64
+
65
+ export const DEFAULT_PREVIEW_LINES = 6;
66
+ export const DEFAULT_PREVIEW_LINE_LENGTH = 120;
67
+
68
+ export function previewCommand(
69
+ command: string,
70
+ options: { readonly maxLines?: number; readonly maxLineLength?: number } = {},
71
+ ): CommandPreview {
72
+ const maxLines = options.maxLines ?? DEFAULT_PREVIEW_LINES;
73
+ const maxLineLength = options.maxLineLength ?? DEFAULT_PREVIEW_LINE_LENGTH;
74
+
75
+ const all = command.split("\n");
76
+ const kept = all.slice(0, maxLines);
77
+ const lines = kept.map((line) => (line.length > maxLineLength ? `${line.slice(0, maxLineLength)}…` : line));
78
+ const hidden = all.slice(maxLines);
79
+
80
+ // A single 4000-character line has no hidden lines, but most of it was still cut.
81
+ const cutCharacters = kept.reduce((total, line, index) => total + Math.max(0, line.length - (lines[index]?.length ?? 0)), 0);
82
+ const hiddenCharacters = hidden.reduce((total, line) => total + line.length + 1, 0) + cutCharacters;
83
+
84
+ return {
85
+ lines,
86
+ truncated: hidden.length > 0 || cutCharacters > 0,
87
+ hiddenLines: hidden.length,
88
+ hiddenCharacters,
89
+ };
90
+ }
91
+
92
+ /** Cut a text block to a line budget, marking what was dropped. */
93
+ export function clampLines(text: string, maxLines: number): string {
94
+ const lines = text.split("\n");
95
+ if (lines.length <= maxLines) return text;
96
+ return [...lines.slice(0, maxLines - 1), `… (${lines.length - maxLines + 1} more lines)`].join("\n");
97
+ }
98
+
99
+ export interface ConfirmationParts {
100
+ readonly tool: string;
101
+ readonly command?: string;
102
+ readonly path?: string;
103
+ readonly reasons: readonly string[];
104
+ readonly rationale: string;
105
+ }
106
+
107
+ /** The dialog shown when a judgment is delegated to the user. */
108
+ export const CONFIRMATION_MAX_LINES = 14;
109
+
110
+ export function buildConfirmationDialog(parts: ConfirmationParts): string {
111
+ const preview = parts.command === undefined ? undefined : previewCommand(parts.command);
112
+ const hiddenNote =
113
+ preview?.truncated === true
114
+ ? `… ${[
115
+ preview.hiddenLines > 0 ? `${preview.hiddenLines} more line(s)` : undefined,
116
+ `${preview.hiddenCharacters} more character(s)`,
117
+ ]
118
+ .filter(Boolean)
119
+ .join(", ")} — the full command is in the tool call above`
120
+ : undefined;
121
+
122
+ return clampLines(
123
+ [
124
+ "Jev auto mode wants confirmation before this runs.",
125
+ `Tool: ${parts.tool}`,
126
+ ...(preview?.lines ?? []),
127
+ ...(hiddenNote === undefined ? [] : [hiddenNote]),
128
+ ...(parts.path === undefined ? [] : [parts.path]),
129
+ "",
130
+ `Escalated: ${parts.reasons.join(", ")}`,
131
+ parts.rationale,
132
+ ].join("\n"),
133
+ CONFIRMATION_MAX_LINES,
134
+ );
135
+ }
136
+
137
+ export const USAGE_TEXT = [
138
+ "Usage:",
139
+ " /jev-auto-mode show status",
140
+ " /jev-auto-mode on|off toggle auto mode",
141
+ " /jev-auto-mode login|logout store or remove the TypeSafe API key",
142
+ " /jev-auto-mode policy list the user policy notes",
143
+ " /jev-auto-mode policy edit",
144
+ " /jev-auto-mode policy clear",
145
+ " /jev-auto-mode threshold show thresholds and last observed probabilities",
146
+ " /jev-auto-mode threshold <rule> <0.5-1.0>",
147
+ " /jev-auto-mode threshold reset [rule]",
148
+ " /jev-auto-mode threshold edit pick a rule and type a value",
149
+ " /jev-auto-mode uncertain show what the middle band resolves to",
150
+ " /jev-auto-mode uncertain deny|ask|allow",
151
+ " /jev-auto-mode scope all|matched which calls reach Jev",
152
+ ].join("\n");
153
+
154
+ export const GATE_SCOPE_EXPLANATION = [
155
+ "How far the semantic layer reaches.",
156
+ " all - judge everything the deterministic layer cannot vouch for (default).",
157
+ " Read-only commands, user-declared safe commands, and in-project",
158
+ " unprotected edits stay on the fast path.",
159
+ " matched - judge only calls that match a dangerous-command pattern. Faster,",
160
+ " but a shape nobody wrote a pattern for runs unjudged.",
161
+ ].join("\n");
162
+
163
+ export const UNCERTAIN_EXPLANATION = [
164
+ "The middle band is where Jev is neither satisfied nor rejecting.",
165
+ " deny - block it. No prompt, no screen takeover: Jev's probability is the answer.",
166
+ " ask - hand the call to the user. Opt-in, because it hands the decision back to a human.",
167
+ " allow - let it through. Trusts the band; the least safe of the three.",
168
+ ].join("\n");
169
+
170
+ export const POLICY_HEADER = [
171
+ "# Jev auto mode policy",
172
+ "",
173
+ "Free-form notes describing what this machine and these repositories allow.",
174
+ "They are reference material for the semantic judgment: they can justify an",
175
+ "approval, but they cannot override hard-deny rules.",
176
+ ].join("\n");
177
+
178
+ /**
179
+ * The most recent judgment of one condition, kept for threshold tuning.
180
+ *
181
+ * Only the probability is stored. The band is recomputed against the *current*
182
+ * threshold, so changing a threshold immediately shows what the last judgment would
183
+ * have become — storing the band would leave a stale label next to a threshold that
184
+ * no longer produced it.
185
+ */
186
+ export interface ObservedCondition {
187
+ readonly probability: number;
188
+ readonly at: number;
189
+ }
190
+
191
+ function pad(value: string, width: number): string {
192
+ return value.length >= width ? value : value + " ".repeat(width - value.length);
193
+ }
194
+
195
+ /**
196
+ * The tuning table.
197
+ *
198
+ * Showing the last observed probability next to each threshold is the whole point:
199
+ * a threshold cannot be chosen from a rule description, only from what the model
200
+ * actually answered for calls you care about. The band is recomputed against the
201
+ * current threshold, so the table doubles as a what-if view while tuning.
202
+ */
203
+ export function formatRuleTable(
204
+ rules: readonly JevRule[] = DEFAULT_RULES,
205
+ overrides: Readonly<Record<string, number>> = {},
206
+ observed: ReadonlyMap<string, ObservedCondition> = new Map(),
207
+ ): string {
208
+ const header = `${pad("rule", 28)}${pad("mode", 10)}${pad("severity", 10)}${pad("threshold", 30)}last observed`;
209
+ const rows = rules.map((rule) => {
210
+ const override = overrides[rule.id];
211
+ const threshold = override ?? rule.threshold;
212
+ const origin = override === undefined ? "default" : `override (default ${formatThreshold(rule.threshold)})`;
213
+ // Recompute against the effective rule, not the default one: the point of the
214
+ // last-observed column is to answer "what would this answer mean now".
215
+ const effective = override === undefined ? rule : { ...rule, threshold: override };
216
+ return `${pad(rule.id, 28)}${pad(rule.mode, 10)}${pad(rule.severity, 10)}${pad(`${formatThreshold(threshold)} ${origin}`, 30)}${describeLast(effective, observed.get(rule.id))}`;
217
+ });
218
+
219
+ const unknown = Object.keys(overrides).filter((ruleId) => !rules.some((rule) => rule.id === ruleId));
220
+ if (unknown.length > 0) {
221
+ rows.push(`\noverride(s) that match no known rule: ${unknown.join(", ")}`);
222
+ }
223
+
224
+ return [header, ...rows].join("\n");
225
+ }
226
+
227
+ function describeLast(rule: JevRule, last: ObservedCondition | undefined): string {
228
+ if (!last) return "-";
229
+ const [observation] = observe([rule], { [rule.id]: last.probability });
230
+ const band = observation?.verdict ?? "uncertain";
231
+ const label = band === "uncertain" && observation?.effective === "satisfied" ? "ignored" : band;
232
+ return `p=${last.probability.toFixed(2)} (${label})`;
233
+ }
@@ -16,6 +16,51 @@ All notable changes to pi-lens will be documented in this file.
16
16
 
17
17
  ### Security
18
18
 
19
+ ## [4.2.1] - 2026-09-17
20
+
21
+ ### Added
22
+
23
+ - **Carried cascade advisories and demoted delta rows now render with explicit age labels (refs #3167)** — a cascade result carried across a turn boundary renders `(carried 1 turn · scanned Xm ago)` from the run's own observation stamp (#1444's `publishedAt`, threaded to `CascadeRun.observedAt`), a coverage advisory computed entirely from carried indeterminate runs is labeled the same way on its own line, and a demoted delta file group closes with one scan-age line under that file's own header through `formatCacheAgeLabel` — so an agent can tell a just-observed finding from one re-served from cache. An absent or partial stamp renders `scan age unknown`, never a fabricated number. The two `partial` delivery-gate entries naming this gap resolve to full, and each carrying turn emits one bounded `cascade_carry_rendered` record.
24
+
25
+ - **Stale carried findings are now re-verified against the live server before re-delivery (refs #3170)** — a deferred-origin finding whose file has not moved re-served turn after turn even when the root cause was fixed elsewhere (the #1093 cross-file class on the advisory lane). At turn_end, up to four such files per turn are re-observed through the probe's `touchFile` path (bounded by a wall budget and the turn's abort signal, both of which re-arm the rest for the next turn). A carried finding is dropped only when the touch itself completed its confirmation policy and no longer reports it; a silent, skipped or wedged answer keeps the finding verbatim and renders an explicit "(re-verify incomplete)" label in the turn-end advisory and in `lens_diagnostics` delta output — never a false clean. Findings sourced from an auxiliary scanner are left to their own lane rather than scored against a primary-scope answer. One `persistent_reverify` latency record per pass, carrying the candidate, touched, outcome and skip counts.
26
+
27
+ ### Fixed
28
+
29
+ - **The widget `suppressed: N` chip keeps counting a marked finding (refs #3158)** — A finding marked `false-positive` or `suppress` stopped contributing to the pi-lens footer's `suppressed: N` chip as soon as any fresh `lens_diagnostics source=lsp` probe ran, because the probe hands the widget store the post-disposition filtered set and the store replaces a file's entries wholesale. Suppressed rows the latest scan no longer reports are now retained, and a suppressed row is no longer treated as a blocker anywhere — it cannot draw a red footer row, displace a live finding, or enter the turn-end blocker sweep. Known window: the retained row is retired by the file's next edit, even one that leaves the marked line unchanged and the mark still applying, and nothing re-creates it; the chip therefore counts a mark until that file is next edited rather than for as long as the mark stands.
30
+
31
+ - **`lens_diagnostic_mark` widget cross-check missed mis-cased paths on case-insensitive filesystems (refs #3160)** — the #802 line-reanchor
32
+ cross-check looked up the raw, possibly mis-cased, mark target against
33
+ widget state, which is keyed case-preservingly on POSIX; on a
34
+ case-insensitive filesystem (macOS APFS, `nocase` vfat/ntfs3/cifs) this
35
+ silently missed the live diagnostic and fell back to the fuzzy line guess.
36
+ The lookup now normalizes the path to on-disk casing before checking
37
+ widget state. `pilens_analyze` also recorded diagnostics under the raw,
38
+ un-normalized spelling of its agent-supplied `file` argument, so it now
39
+ normalizes on write too — both sides key on-disk casing consistently. The
40
+ `lsp_diagnostics`/`lens_diagnostics(source: "lsp", scope: "paths")`
41
+ explicit-`paths` batch had the same raw-key write for a mis-cased entry
42
+ (refs #3182); it now normalizes on write as well.
43
+
44
+ - **`guard-bash` refuses `git worktree remove` on a tree whose `node_modules` is a symlink into another checkout (refs #3173)** — twice on 2026-09-16 a fixer removed a worktree whose `node_modules` was symlinked to the shared checkout (the fixer playbook's own speed convention), and git followed the link and emptied the shared install, breaking every other agent building or testing in that window. The PreToolUse hook now denies (exit 2) ANY `git worktree remove <tree>` — force or not — when `<tree>` is a real linked git worktree and its `node_modules` entry is a symlink whose target resolves outside `<tree>`; the stderr note names the fix (`rm <tree>/node_modules`, then retry). A path that does not exist, is not a git worktree, or whose `node_modules` is a real directory (or a symlink that stays inside the worktree) is left to git.
45
+
46
+ - **The tests-tree write guard no longer crashes the Unit tests job on a scratch-directory race (refs #3179)** — `tests/support/tests-tree-write-guard-setup.ts` armed a recursive `fs.watch` over `tests/` with no `'error'` listener. On Linux, node's recursive watch is a JS polyfill that re-scans a changed subfolder with a synchronous `readdirSync`; when `tests/index-2992-integration.test.ts` removed its own exempt scratch directory (`tests/support/.index-2992-scratch`) between the change event and that rescan, the polyfill emitted an unhandled `'error'` event and killed the whole vitest process with exit 1 and no failing test. The guard now attaches an `'error'` handler that swallows ENOENT for paths under its existing exempt prefixes and records any other error once via `console.warn`, never per event.
47
+
48
+ - **A tool path with `/../` in it no longer orphans its own diagnostics record (refs #3184)** — `normalizeMapKey`, the canonical map-key normalizer, passed dot segments through unchanged on macOS and Linux whenever the path's casing was already right, so an absolute path containing `/../` (or `/./`, or a doubled separator) keyed under a spelling no other writer or reader ever derives. A `lens_diagnostic_mark`, `pilens_analyze` or `lens_diagnostics {source:"lsp"}` call typed that way produced an orphan widget row, a missed line reanchor and a split disposition anchor. The POSIX arm now folds dot segments before it looks at casing, so every consumer inherits one key per file; the fold is pure string algebra, so a relative path is still never resolved against the process working directory. The `lsp_diagnostics` single-`path` mode, which derives its key without that normalizer, was folded onto the same expression its `paths` batch already uses.
49
+
50
+ - **`tmp-fixture-hygiene`'s governance owner attributed another test file's async-recreated directory to itself (refs #3186)** — PR #3168's CI run redded `tests/config/tmp-fixture-hygiene.test.ts` over `pi-lens-tool-policy-conventions-*` directories owned by `tests/clients/tool-policy-conventions.test.ts`. That file's `afterEach` removed its `setupTestEnvironment` directory synchronously while `saveProjectSnapshot` (called from within the test) was still mid-flight: its body persist is dispatched to a worker thread / main-thread fallback the caller never awaited, and that persist's write path recreates the just-removed directory via a recursive `mkdir` — measured directly landing ~10-50ms later, unforced, on a bare invocation of the real function. Fixed at the producer: the `afterEach` now awaits the repo's own `waitForProjectSnapshotPersistsForTests()` drain seam before cleanup, the same seam five other test files already use for this, closing the race instead of admitting its symptom in the tmp-hygiene baseline.
51
+
52
+ - **A cold diagnostics sweep no longer burns its whole warm-up budget on a silent TypeScript server (closes #3187)** — The shared pre-sweep warm-up (`LSPService.ensureWarmForSweep`) touches a representative file to prove the server can answer, and that touch collects nothing. The tsserver sync clean-confirm race was gated on a collecting touch, so on a project whose classic typescript-language-server publishes nothing for clean files the warm-up could only reach a verdict by waiting out its entire cold-start budget — measured on a real session at 6,972 ms of a 20,000 ms budget, and on a fake tier3-silent server through the real service at 4,509 ms (initial attempt plus the retry) against 308 ms after the fix. The warm-up is now eligible for the sync confirm on its own identity while still collecting nothing, so it neither primes nor erases the file's last-known diagnostics, and a server that does not offer the sync commands still certifies through the existing silent-clean fallback instead of failing warm-up and skipping the sweep group.
53
+
54
+ - **No more `🔴 STOP — 0 issue(s) must be fixed:` with nothing under it (refs [#3188](https://github.com/apmantza/pi-lens/issues/3188))** — When #2028's deleted-path gate retracted *every* blocker — the common shape being a bash command that writes a file and deletes it again (`cat > probe.py … ; rm probe.py`), whose recovered path the per-edit pipeline still analyses — the readback-failed re-render path emitted the STOP header with a count of zero and an empty body, so the agent read a "must be fixed" imperative naming no finding and no file. The external reporter measured 154 of 159 of their banners in that zero form. The blocker renderer now returns nothing when it has no blockers (the same guard `formatDiagnostics` already carries), and the re-render path no longer appends a bare separator, which would still have been delivered as a whitespace-only tool-result block. A partial retraction is unchanged: the surviving blockers render with their own count, and the coverage/auto-fix tail after the blocker section is still delivered. The existing bounded `finding_dead_path_drop` record (`store=stop-blocker`, `deliveredCount=0`) is unchanged and remains the way a total retraction is observed.
55
+
56
+ - **Tool schemas survive registration on hosts with callable schemas (closes #3195)** — On a host whose `typebox` specifier resolves to an ArkType-style builder — `@oh-my-pi/pi-coding-agent` (the `@oh-my-pi/omptype` shim it rewrites an extension's `typebox` import to), and any other host whose schema builders return callables — every pi-lens tool lost its parameter schema at registration: the console-capture seam replaced each function-valued property of a tool definition with a capture wrapper, and a schema is itself a function there. All 13 tools then rendered `type Args = unknown;` and rejected every `xd://` call with `root: schema must be an object or boolean`. Callable schemas are now handed to the host untouched, using the host's own `isArkSchema` test, while `execute`, `renderResult` and command handlers keep their console-capture window. Hosts whose schemas are plain objects (`@earendil-works/pi-coding-agent`) are unaffected.
57
+
58
+ - **`mode=delta` no longer renders a file's quality (or project-diagnostics) rows under another file's header (refs #3196)** — `formatDeltaMode`'s quality loop and `appendProjectDiagnosticsDeltaLines` both suppressed a file's header with `if (!lines.includes(rel)) lines.push(rel)`, true whenever an earlier tier already pushed that exact path — but each tier's rows were appended to the END of the shared buffer, not under that earlier header. A file present in more than one report (actionable, quality, or project) had its later tier's rows, and any age/re-verify-incomplete label describing them, land under whichever OTHER file's header happened to be last in the buffer, misdirecting an agent acting on the finding. The render is now a single pass grouped by file: every tier appends into that file's own bucket, which makes the header-membership guess (and #3168's label-prediction dedupe) unnecessary — both are removed.
59
+
60
+ - **`mutation (advisory)` no longer false-reds on `scripts/with-memory-watch.mjs` (closes #3108)** — its two source-text pins in `tests/scripts/with-memory-watch.test.ts` matched only the un-instrumented wrapper, so Stryker's in-place `// @ts-nocheck` + switch-mutant scaffolding (added even with no mutant active) failed the dry run on every PR that edited the file. The pins now tolerate that optional wrapper and still red when the pinned code is genuinely removed.
61
+
62
+ - **The skills-directory degradation record no longer drops which entry file triggered it (refs #3175)** — when `resources_discover`'s `skills/` lookup misses, the ledger's `reason` field concatenated a classification that repeats `skillsDir` (already recorded verbatim in `subject`) ahead of the entry file's directory — the one fact the record uniquely carries for diagnosing a managed-cache-relocated entry (#2587). Once the shared root was long enough that the two exceeded the ledger's 200-char cap (a real dry-roll/CI-lane TMPDIR shape), the head-preserving truncation kept the redundant path and silently dropped the entry directory. The ledger's copy of the reason now uses a short, path-free classification tag instead, leaving room for the entry directory to survive intact; the user-facing notification is unaffected.
63
+
19
64
  ## [4.2.0] - 2026-09-16
20
65
 
21
66
  ### Added