pumuki 6.3.37 → 6.3.39

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.
@@ -15,6 +15,9 @@ export type PumukiCriticalNotificationEvent =
15
15
  kind: 'gate.blocked';
16
16
  stage: PumukiNotificationStage;
17
17
  totalViolations: number;
18
+ causeCode?: string;
19
+ causeMessage?: string;
20
+ remediation?: string;
18
21
  }
19
22
  | {
20
23
  kind: 'evidence.stale';
@@ -31,23 +34,36 @@ export type SystemNotificationPayload = {
31
34
  title: string;
32
35
  message: string;
33
36
  subtitle?: string;
37
+ soundName?: string;
34
38
  };
35
39
 
36
40
  export type SystemNotificationsConfig = {
37
41
  enabled: boolean;
38
42
  channel: 'macos';
43
+ muteUntil?: string;
39
44
  };
40
45
 
41
46
  export type SystemNotificationEmitResult =
42
47
  | { delivered: true; reason: 'delivered' }
43
- | { delivered: false; reason: 'disabled' | 'unsupported-platform' | 'command-failed' };
48
+ | { delivered: false; reason: 'disabled' | 'muted' | 'unsupported-platform' | 'command-failed' };
44
49
 
45
50
  type SystemNotificationCommandRunner = (
46
51
  command: string,
47
52
  args: ReadonlyArray<string>
48
53
  ) => number;
49
54
 
55
+ type SystemNotificationCommandRunnerWithOutput = (
56
+ command: string,
57
+ args: ReadonlyArray<string>
58
+ ) => {
59
+ exitCode: number;
60
+ stdout: string;
61
+ };
62
+
50
63
  const SYSTEM_NOTIFICATIONS_CONFIG_PATH = '.pumuki/system-notifications.json';
64
+ const BLOCKED_DIALOG_KEEP = 'Mantener activas';
65
+ const BLOCKED_DIALOG_MUTE_30 = 'Silenciar 30 min';
66
+ const BLOCKED_DIALOG_DISABLE = 'Desactivar';
51
67
 
52
68
  const escapeAppleScriptString = (value: string): string =>
53
69
  value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ').trim();
@@ -58,7 +74,132 @@ const buildDisplayNotificationScript = (payload: SystemNotificationPayload): str
58
74
  const subtitleFragment = payload.subtitle
59
75
  ? ` subtitle "${escapeAppleScriptString(payload.subtitle)}"`
60
76
  : '';
61
- return `display notification "${message}" with title "${title}"${subtitleFragment}`;
77
+ const soundFragment = payload.soundName
78
+ ? ` sound name "${escapeAppleScriptString(payload.soundName)}"`
79
+ : '';
80
+ return `display notification "${message}" with title "${title}"${subtitleFragment}${soundFragment}`;
81
+ };
82
+
83
+ const buildDisplayDialogScript = (params: {
84
+ title: string;
85
+ cause: string;
86
+ remediation: string;
87
+ }): string => {
88
+ const title = escapeAppleScriptString(params.title);
89
+ const cause = escapeAppleScriptString(params.cause);
90
+ const remediation = escapeAppleScriptString(params.remediation);
91
+ const message = escapeAppleScriptString(`Causa: ${cause}\n\nSolución: ${remediation}`);
92
+ return `display dialog "${message}" with title "${title}" buttons {"${BLOCKED_DIALOG_DISABLE}", "${BLOCKED_DIALOG_MUTE_30}", "${BLOCKED_DIALOG_KEEP}"} default button "${BLOCKED_DIALOG_KEEP}" with icon stop giving up after 15`;
93
+ };
94
+
95
+ const normalizeNotificationText = (value: string): string =>
96
+ value.replace(/\s+/g, ' ').trim();
97
+
98
+ const truncateNotificationText = (value: string, maxLength: number): string => {
99
+ if (value.length <= maxLength) {
100
+ return value;
101
+ }
102
+ return `${value.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
103
+ };
104
+
105
+ const isTruthyFlag = (value?: string): boolean => {
106
+ if (!value) {
107
+ return false;
108
+ }
109
+ const normalized = value.trim().toLowerCase();
110
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
111
+ };
112
+
113
+ const BLOCKED_CAUSE_SUMMARY_BY_CODE: Readonly<Record<string, string>> = {
114
+ EVIDENCE_MISSING: 'Falta evidencia para validar este paso.',
115
+ EVIDENCE_INVALID: 'La evidencia actual es inválida.',
116
+ EVIDENCE_CHAIN_INVALID: 'La cadena de evidencia no es válida.',
117
+ EVIDENCE_STALE: 'La evidencia está desactualizada.',
118
+ EVIDENCE_BRANCH_MISMATCH: 'La evidencia no corresponde con la rama actual.',
119
+ EVIDENCE_REPO_ROOT_MISMATCH: 'La evidencia no corresponde con este repositorio.',
120
+ PRE_PUSH_UPSTREAM_MISSING: 'La rama no tiene upstream configurado.',
121
+ GITFLOW_PROTECTED_BRANCH: 'No se permiten cambios directos en esta rama protegida.',
122
+ SDD_SESSION_MISSING: 'No hay sesión SDD activa.',
123
+ SDD_SESSION_INVALID: 'La sesión SDD actual no es válida.',
124
+ OPENSPEC_MISSING: 'OpenSpec no está instalado en este repositorio.',
125
+ MCP_ENTERPRISE_RECEIPT_MISSING: 'Falta el recibo enterprise de MCP.',
126
+ BACKEND_AVOID_EXPLICIT_ANY: 'Se detectó uso de "any" explícito en backend.',
127
+ };
128
+
129
+ const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
130
+ EVIDENCE_MISSING: 'Genera evidencia y vuelve a ejecutar el gate.',
131
+ EVIDENCE_INVALID: 'Regenera la evidencia antes de reintentar.',
132
+ EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para reparar la cadena criptográfica.',
133
+ EVIDENCE_STALE: 'Refresca la evidencia y vuelve a intentarlo.',
134
+ EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en esta rama y reintenta.',
135
+ EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este repositorio.',
136
+ PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>.',
137
+ SDD_SESSION_MISSING: 'Abre sesión SDD y vuelve a intentar.',
138
+ SDD_SESSION_INVALID: 'Refresca la sesión SDD y vuelve a intentar.',
139
+ OPENSPEC_MISSING: 'Instala OpenSpec y reintenta la validación.',
140
+ MCP_ENTERPRISE_RECEIPT_MISSING: 'Genera el receipt enterprise de MCP antes de continuar.',
141
+ BACKEND_AVOID_EXPLICIT_ANY: 'Tipa el valor y elimina "any" explícito en backend.',
142
+ };
143
+
144
+ const toKnownSpanishCauseFromMessage = (message: string): string | null => {
145
+ const normalized = message.toLowerCase();
146
+ if (normalized.includes('avoid explicit any')) {
147
+ return BLOCKED_CAUSE_SUMMARY_BY_CODE.BACKEND_AVOID_EXPLICIT_ANY;
148
+ }
149
+ if (normalized.includes('evidence is stale')) {
150
+ return BLOCKED_CAUSE_SUMMARY_BY_CODE.EVIDENCE_STALE;
151
+ }
152
+ if (normalized.includes('no upstream tracking reference')) {
153
+ return BLOCKED_CAUSE_SUMMARY_BY_CODE.PRE_PUSH_UPSTREAM_MISSING;
154
+ }
155
+ return null;
156
+ };
157
+
158
+ const toKnownSpanishRemediationFromMessage = (message: string): string | null => {
159
+ const normalized = message.toLowerCase();
160
+ if (normalized.includes('avoid explicit any')) {
161
+ return BLOCKED_REMEDIATION_BY_CODE.BACKEND_AVOID_EXPLICIT_ANY;
162
+ }
163
+ if (normalized.includes('set-upstream')) {
164
+ return BLOCKED_REMEDIATION_BY_CODE.PRE_PUSH_UPSTREAM_MISSING;
165
+ }
166
+ if (normalized.includes('refresh evidence')) {
167
+ return BLOCKED_REMEDIATION_BY_CODE.EVIDENCE_STALE;
168
+ }
169
+ return null;
170
+ };
171
+
172
+ const resolveBlockedCauseSummary = (event: Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>, causeCode: string): string => {
173
+ const mapped = BLOCKED_CAUSE_SUMMARY_BY_CODE[causeCode];
174
+ if (mapped) {
175
+ return mapped;
176
+ }
177
+ if (event.causeMessage && event.causeMessage.trim().length > 0) {
178
+ const rawMessage = normalizeNotificationText(event.causeMessage).replace(/^[A-Z0-9_]+:\s*/, '');
179
+ const translated = toKnownSpanishCauseFromMessage(rawMessage);
180
+ if (translated) {
181
+ return translated;
182
+ }
183
+ return truncateNotificationText(rawMessage, 72);
184
+ }
185
+ return `Se detectaron ${event.totalViolations} bloqueos en ${event.stage}.`;
186
+ };
187
+
188
+ const resolveBlockedRemediation = (event: Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>, causeCode: string): string => {
189
+ const fromEvent = event.remediation
190
+ ? normalizeNotificationText(event.remediation).replace(/^cómo solucionarlo:\s*/i, '').replace(/^remediation:\s*/i, '')
191
+ : '';
192
+ if (fromEvent.length > 0) {
193
+ const translated = toKnownSpanishRemediationFromMessage(fromEvent);
194
+ if (translated) {
195
+ return truncateNotificationText(translated, 88);
196
+ }
197
+ return truncateNotificationText(fromEvent, 88);
198
+ }
199
+ const fallback =
200
+ BLOCKED_REMEDIATION_BY_CODE[causeCode]
201
+ ?? 'Corrige el bloqueo indicado y vuelve a ejecutar el comando.';
202
+ return truncateNotificationText(fallback, 88);
62
203
  };
63
204
 
64
205
  const runSystemCommand: SystemNotificationCommandRunner = (command, args) => {
@@ -70,6 +211,43 @@ const runSystemCommand: SystemNotificationCommandRunner = (command, args) => {
70
211
  }
71
212
  };
72
213
 
214
+ const runSystemCommandWithOutput: SystemNotificationCommandRunnerWithOutput = (
215
+ command,
216
+ args
217
+ ) => {
218
+ try {
219
+ const stdout = runBinarySync(command, [...args], {
220
+ encoding: 'utf8',
221
+ });
222
+ return {
223
+ exitCode: 0,
224
+ stdout: typeof stdout === 'string' ? stdout : '',
225
+ };
226
+ } catch (error: unknown) {
227
+ const fallbackStdout =
228
+ typeof error === 'object' &&
229
+ error !== null &&
230
+ 'stdout' in error &&
231
+ typeof (error as { stdout?: unknown }).stdout === 'string'
232
+ ? (error as { stdout: string }).stdout
233
+ : '';
234
+ return {
235
+ exitCode: 1,
236
+ stdout: fallbackStdout,
237
+ };
238
+ }
239
+ };
240
+
241
+ const persistSystemNotificationsConfigFile = (
242
+ repoRoot: string,
243
+ config: SystemNotificationsConfig
244
+ ): string => {
245
+ const configPath = join(repoRoot, SYSTEM_NOTIFICATIONS_CONFIG_PATH);
246
+ mkdirSync(join(repoRoot, '.pumuki'), { recursive: true });
247
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
248
+ return configPath;
249
+ };
250
+
73
251
  export const buildSystemNotificationsConfigFromSelection = (
74
252
  enabled: boolean
75
253
  ): SystemNotificationsConfig => ({
@@ -78,14 +256,10 @@ export const buildSystemNotificationsConfigFromSelection = (
78
256
  });
79
257
 
80
258
  export const persistSystemNotificationsConfig = (repoRoot: string, enabled: boolean): string => {
81
- const configPath = join(repoRoot, SYSTEM_NOTIFICATIONS_CONFIG_PATH);
82
- mkdirSync(join(repoRoot, '.pumuki'), { recursive: true });
83
- writeFileSync(
84
- configPath,
85
- `${JSON.stringify(buildSystemNotificationsConfigFromSelection(enabled), null, 2)}\n`,
86
- 'utf8'
259
+ return persistSystemNotificationsConfigFile(
260
+ repoRoot,
261
+ buildSystemNotificationsConfigFromSelection(enabled)
87
262
  );
88
- return configPath;
89
263
  };
90
264
 
91
265
  export const readSystemNotificationsConfig = (repoRoot: string): SystemNotificationsConfig => {
@@ -98,16 +272,67 @@ export const readSystemNotificationsConfig = (repoRoot: string): SystemNotificat
98
272
  const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as {
99
273
  enabled?: unknown;
100
274
  channel?: unknown;
275
+ muteUntil?: unknown;
101
276
  };
102
- return {
277
+ const config: SystemNotificationsConfig = {
103
278
  enabled: parsed.enabled === true,
104
279
  channel: 'macos',
105
280
  };
281
+ if (typeof parsed.muteUntil === 'string' && parsed.muteUntil.trim().length > 0) {
282
+ config.muteUntil = parsed.muteUntil;
283
+ }
284
+ return config;
106
285
  } catch {
107
286
  return buildSystemNotificationsConfigFromSelection(true);
108
287
  }
109
288
  };
110
289
 
290
+ const isMutedAt = (config: SystemNotificationsConfig, nowMs: number): boolean => {
291
+ if (!config.muteUntil) {
292
+ return false;
293
+ }
294
+ const parsed = Date.parse(config.muteUntil);
295
+ if (!Number.isFinite(parsed)) {
296
+ return false;
297
+ }
298
+ return parsed > nowMs;
299
+ };
300
+
301
+ const extractDialogButton = (stdout: string): string | null => {
302
+ const match = stdout.match(/button returned:(.+)/i);
303
+ if (!match || !match[1]) {
304
+ return null;
305
+ }
306
+ return match[1].trim();
307
+ };
308
+
309
+ const applyDialogChoice = (params: {
310
+ repoRoot: string;
311
+ config: SystemNotificationsConfig;
312
+ button: string;
313
+ nowMs: number;
314
+ }): void => {
315
+ if (params.button === BLOCKED_DIALOG_KEEP) {
316
+ return;
317
+ }
318
+ if (params.button === BLOCKED_DIALOG_DISABLE) {
319
+ persistSystemNotificationsConfigFile(params.repoRoot, {
320
+ enabled: false,
321
+ channel: params.config.channel,
322
+ });
323
+ return;
324
+ }
325
+ if (params.button === BLOCKED_DIALOG_MUTE_30) {
326
+ const minutes = 30;
327
+ const muteUntil = new Date(params.nowMs + minutes * 60_000).toISOString();
328
+ persistSystemNotificationsConfigFile(params.repoRoot, {
329
+ enabled: true,
330
+ channel: params.config.channel,
331
+ muteUntil,
332
+ });
333
+ }
334
+ };
335
+
111
336
  export const buildSystemNotificationPayload = (
112
337
  event: PumukiCriticalNotificationEvent
113
338
  ): SystemNotificationPayload => {
@@ -137,23 +362,30 @@ export const buildSystemNotificationPayload = (
137
362
  }
138
363
 
139
364
  if (event.kind === 'gate.blocked') {
365
+ const causeCode = event.causeCode ?? 'GATE_BLOCKED';
366
+ const causeSummary = truncateNotificationText(
367
+ resolveBlockedCauseSummary(event, causeCode),
368
+ 72
369
+ );
370
+ const remediation = resolveBlockedRemediation(event, causeCode);
140
371
  return {
141
- title: 'Pumuki · Gate BLOCK',
142
- subtitle: event.stage,
143
- message: `Detected ${event.totalViolations} blocking violations in stage ${event.stage}.`,
372
+ title: '🔴 Pumuki bloqueado',
373
+ subtitle: `${event.stage} · ${causeSummary}`,
374
+ message: `Solución: ${remediation}`,
375
+ soundName: 'Basso',
144
376
  };
145
377
  }
146
378
 
147
379
  if (event.kind === 'evidence.stale') {
148
380
  return {
149
- title: 'Pumuki · Evidence Stale',
150
- message: `${event.evidencePath} is stale (${event.ageMinutes} minutes old). Refresh evidence before continue.`,
381
+ title: '🟡 Pumuki · evidencia desactualizada',
382
+ message: `Actualiza evidencia (${event.ageMinutes} min): ${event.evidencePath}.`,
151
383
  };
152
384
  }
153
385
 
154
386
  return {
155
- title: 'Pumuki · Git-Flow Violation',
156
- message: `Branch ${event.currentBranch} violates git-flow (${event.reason}).`,
387
+ title: '🔴 Pumuki · bloqueo GitFlow',
388
+ message: `La rama ${event.currentBranch} no cumple GitFlow (${event.reason}).`,
157
389
  };
158
390
  };
159
391
 
@@ -161,8 +393,11 @@ export const emitSystemNotification = (params: {
161
393
  event: PumukiCriticalNotificationEvent;
162
394
  platform?: NodeJS.Platform;
163
395
  runCommand?: SystemNotificationCommandRunner;
396
+ runCommandWithOutput?: SystemNotificationCommandRunnerWithOutput;
164
397
  repoRoot?: string;
165
398
  config?: SystemNotificationsConfig;
399
+ env?: NodeJS.ProcessEnv;
400
+ now?: () => number;
166
401
  }): SystemNotificationEmitResult => {
167
402
  const config =
168
403
  params.config ??
@@ -172,6 +407,10 @@ export const emitSystemNotification = (params: {
172
407
  if (!config.enabled) {
173
408
  return { delivered: false, reason: 'disabled' };
174
409
  }
410
+ const nowMs = (params.now ?? Date.now)();
411
+ if (isMutedAt(config, nowMs)) {
412
+ return { delivered: false, reason: 'muted' };
413
+ }
175
414
 
176
415
  const platform = params.platform ?? process.platform;
177
416
  if (platform !== 'darwin') {
@@ -187,5 +426,30 @@ export const emitSystemNotification = (params: {
187
426
  return { delivered: false, reason: 'command-failed' };
188
427
  }
189
428
 
429
+ const env = params.env ?? process.env;
430
+ if (params.event.kind === 'gate.blocked' && isTruthyFlag(env.PUMUKI_MACOS_BLOCKED_DIALOG)) {
431
+ const causeCode = params.event.causeCode ?? 'GATE_BLOCKED';
432
+ const cause = resolveBlockedCauseSummary(params.event, causeCode);
433
+ const remediation = resolveBlockedRemediation(params.event, causeCode);
434
+ const dialogScript = buildDisplayDialogScript({
435
+ title: '🔴 Pumuki bloqueado',
436
+ cause,
437
+ remediation,
438
+ });
439
+ const dialogRunner = params.runCommandWithOutput ?? runSystemCommandWithOutput;
440
+ const dialogResult = dialogRunner('osascript', ['-e', dialogScript]);
441
+ if (dialogResult.exitCode === 0 && params.repoRoot) {
442
+ const selectedButton = extractDialogButton(dialogResult.stdout);
443
+ if (selectedButton) {
444
+ applyDialogChoice({
445
+ repoRoot: params.repoRoot,
446
+ config,
447
+ button: selectedButton,
448
+ nowMs,
449
+ });
450
+ }
451
+ }
452
+ }
453
+
190
454
  return { delivered: true, reason: 'delivered' };
191
455
  };