pumuki 6.3.68 → 6.3.70

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
@@ -37,7 +37,7 @@ npx --yes pumuki status
37
37
  npx --yes pumuki doctor --json
38
38
  ```
39
39
 
40
- Desde **6.3.63**, `npm install` en la raíz de un repo **Git** dispara un `postinstall` que ejecuta **`pumuki install` solo** (hooks `pre-commit` / `pre-push`, lifecycle, evidencia cuando aplica). **Pumuki no depende de ningún IDE** para el baseline: no toca `.cursor/` ni otros ficheros de editor por defecto. Desde **6.3.68**, cada hook gestionado ejecuta **`pumuki-pre-write` antes** de `pumuki-pre-commit` / `pumuki-pre-push` (stage **PRE_WRITE** vía Git). Saltar solo PRE_WRITE en hooks: `PUMUKI_SKIP_CHAINED_PRE_WRITE=1`. Tras install, si no existía, aparece **`.pumuki/adapter.json`** con los comandos de hooks y **MCP stdio** para referencia o para clientes que los registren manualmente. Para generar también config de IDE: **`pumuki install --with-mcp --agent=cursor`** (u otro) o **`pumuki bootstrap --enterprise --agent=…`**. Desactivar el postinstall: `PUMUKI_SKIP_POSTINSTALL=1`. En CI suele saltarse solo (`CI=true`). En **6.3.64+**, las notificaciones del sistema en plataformas sin banner nativo se reflejan en **stderr** por defecto (`PUMUKI_DISABLE_STDERR_NOTIFICATIONS=1` para silenciarlas).
40
+ Desde **6.3.63**, `npm install` en la raíz de un repo **Git** dispara un `postinstall` que ejecuta **`pumuki install` solo** (hooks `pre-commit` / `pre-push`, lifecycle, evidencia cuando aplica). **Pumuki no depende de ningún IDE** para el baseline: no toca `.cursor/` ni otros ficheros de editor por defecto. Desde **6.3.68**, cada hook gestionado ejecuta **`pumuki-pre-write` antes** de `pumuki-pre-commit` / `pumuki-pre-push` (stage **PRE_WRITE** vía Git). Saltar solo PRE_WRITE en hooks: `PUMUKI_SKIP_CHAINED_PRE_WRITE=1`. Desde **6.3.69**, esos mismos hooks aplican también **git-flow en ramas protegidas** (`GITFLOW_PROTECTED_BRANCH`) e **higiene de worktree** (`PUMUKI_PREWRITE_WORKTREE_*` / códigos `EVIDENCE_PREWRITE_WORKTREE_*`) cuando la evidencia es válida; el **modal macOS** de bloqueo (Desactivar / Silenciar 30 min / Mantener activas) queda **activo por defecto** si las notificaciones están habilitadas (`"blockedDialogEnabled": false` o `PUMUKI_MACOS_BLOCKED_DIALOG=0` para apagarlo). Tras install, si no existía, aparece **`.pumuki/adapter.json`** con los comandos de hooks y **MCP stdio** para referencia o para clientes que los registren manualmente. Para generar también config de IDE: **`pumuki install --with-mcp --agent=cursor`** (u otro) o **`pumuki bootstrap --enterprise --agent=…`**. Desactivar el postinstall: `PUMUKI_SKIP_POSTINSTALL=1`. En CI suele saltarse solo (`CI=true`). En **6.3.64+**, las notificaciones del sistema en plataformas sin banner nativo se reflejan en **stderr** por defecto (`PUMUKI_DISABLE_STDERR_NOTIFICATIONS=1` para silenciarlas). En **6.3.69+**, un `gate.blocked` en macOS también deja una copia en **stderr** por defecto (`PUMUKI_DISABLE_GATE_BLOCKED_STDERR_MIRROR=1` para desactivar solo eso). Desde **6.3.70**, si **`.ai_evidence.json` está versionado** y **PRE_PUSH** no bloquea, ese archivo **no se reescribe** en el push (compatibilidad con **pre-commit** como hook de **pre-push**); para forzar escritura: `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE=1`. Con modal de bloqueo activo, el panel interactivo prioriza foco/clics y se evita el banner duplicado.
41
41
 
42
42
  Fallback (equivalent in pasos separados):
43
43
 
@@ -6,6 +6,18 @@ This file keeps only the operational highlights and rollout notes that matter wh
6
6
 
7
7
  ## 2026-04 (CLI stability and macOS notifications)
8
8
 
9
+ ### 2026-04-06 (v6.3.70)
10
+
11
+ - **Consumidores con pre-commit en pre-push**: con `.ai_evidence.json` **versionado**, `PRE_PUSH` en **ALLOW/WARN** omite persistir en disco para no ensuciar el árbol tras el gate; variable `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE=1` si necesitas el snapshot `PRE_PUSH` en fichero trackeado (puede exigir flujo de commit explícito).
12
+ - **macOS bloqueo**: un solo canal interactivo cuando el modal está activo (sin banner `osascript` paralelo); panel Swift más fiable para foco y clics en botones.
13
+ - **Rollout**: `pumuki@6.3.70`, repin en monorepos (p. ej. RuralGO); `npm test` / `git push` con hooks encadenados como validación.
14
+
15
+ ### 2026-04-05 (v6.3.69)
16
+
17
+ - **Hooks = política de repo**: `PRE_COMMIT` / `PRE_PUSH` / `CI` / `PRE_WRITE` incorporan **`GITFLOW_PROTECTED_BRANCH`** y **higiene de worktree** (`EVIDENCE_PREWRITE_WORKTREE_*`, env `PUMUKI_PREWRITE_WORKTREE_*`) vía fusión con `evaluateAiGate` en `runPlatformGate`.
18
+ - **macOS bloqueos**: modal **Desactivar / Silenciar 30 min / Mantener activas** **on by default** si las notificaciones están habilitadas; normalización de etiquetas y parseo `osascript` más robusto; `gate.blocked` duplica payload en **stderr** por defecto (`PUMUKI_DISABLE_GATE_BLOCKED_STDERR_MIRROR=1` para opt-out).
19
+ - **Rollout**: `pumuki@6.3.69`, **`pumuki install`** en consumidores si quieren hooks reescritos tras cambios previos; revisar `.pumuki/system-notifications.json` si asumías modal apagado sin clave `blockedDialogEnabled`.
20
+
9
21
  ### 2026-04-06 (v6.3.68)
10
22
 
11
23
  - **PRE_WRITE sin depender del IDE**: `pre-commit` y `pre-push` gestionados ejecutan **`pumuki-pre-write`** antes del gate del stage principal. Opt-out: `PUMUKI_SKIP_CHAINED_PRE_WRITE=1`.
@@ -307,9 +307,9 @@ Blocking code:
307
307
 
308
308
  - `EVIDENCE_SKILLS_CONTRACT_INCOMPLETE` (when contract is incomplete outside PRE_WRITE)
309
309
 
310
- ## PRE_WRITE worktree hygiene guard
310
+ ## Worktree hygiene guard (PRE_WRITE + Git hooks)
311
311
 
312
- AI Gate can enforce early worktree hygiene in `PRE_WRITE` to reduce non-atomic changes before commit time.
312
+ AI Gate enforces worktree hygiene using **pending_changes** (or `staged + unstaged`) to reduce oversized batches before commit/push. The same thresholds apply to **`PRE_WRITE`**, **`PRE_COMMIT`**, **`PRE_PUSH`**, and **`CI`** when `.ai_evidence.json` is readable and valid (hooks merge these violations into `runPlatformGate`). Git-flow protected-branch checks (`GITFLOW_PROTECTED_BRANCH`) are also merged into hook runs.
313
313
 
314
314
  Environment variables:
315
315
 
@@ -317,6 +317,10 @@ Environment variables:
317
317
  - `PUMUKI_PREWRITE_WORKTREE_WARN_THRESHOLD` (default: `12`)
318
318
  - `PUMUKI_PREWRITE_WORKTREE_BLOCK_THRESHOLD` (default: `24`)
319
319
 
320
+ ## Evidencia en PRE_PUSH con `.ai_evidence.json` trackeado
321
+
322
+ - `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE` (`1|true|yes`): fuerza la escritura del snapshot en `PRE_PUSH` aunque `.ai_evidence.json` esté versionado. Por defecto (sin esta variable), si el fichero está en el índice de git y el outcome no es `BLOCK`, Pumuki **no** muta el archivo para no romper hooks encadenados (p. ej. `pre-commit` ejecutado desde `pre-push`).
323
+
320
324
  Codes emitted:
321
325
 
322
326
  - `EVIDENCE_PREWRITE_WORKTREE_WARN` (warning, still `ALLOWED`)
@@ -242,7 +242,7 @@ Stage mapping:
242
242
  If a scope is empty, the menu prints an explicit operational hint (`Scope vacío`), so `PASS` with zero findings is distinguishable from a clean repository scan.
243
243
 
244
244
  System notifications (macOS) can be enabled from advanced menu option `31` (persisted in `.pumuki/system-notifications.json`).
245
- On non-macOS platforms, the same payloads are written to **stderr** by default (visible in the terminal) because there is no native banner API. Set `PUMUKI_DISABLE_STDERR_NOTIFICATIONS=1` to silence that path (delivery reports `unsupported-platform` on those OSes). On macOS, set `PUMUKI_NOTIFICATION_STDERR_MIRROR=1` to duplicate the banner text to stderr in addition to the system notification.
245
+ On non-macOS platforms, the same payloads are written to **stderr** by default (visible in the terminal) because there is no native banner API. Set `PUMUKI_DISABLE_STDERR_NOTIFICATIONS=1` to silence that path (delivery reports `unsupported-platform` on those OSes). On macOS, set `PUMUKI_NOTIFICATION_STDERR_MIRROR=1` to duplicate **any** notification payload to stderr in addition to the system notification. For **`gate.blocked`** specifically, stderr mirroring is **on by default** when the macOS path reports success (so a failed push/commit still prints a `[pumuki]` block in the terminal even if the banner does not appear); disable only that default with `PUMUKI_DISABLE_GATE_BLOCKED_STDERR_MIRROR=1`. The **blocked modal** (Swift floating / AppleScript) with **Desactivar / Silenciar 30 min / Mantener activas** is **on by default** whenever notifications are enabled and `blockedDialogEnabled` is omitted in `.pumuki/system-notifications.json`. Turn it off with `"blockedDialogEnabled": false` or `PUMUKI_MACOS_BLOCKED_DIALOG=0`. Ensure the terminal app is allowed to show notifications in **System Settings → Notifications**.
246
246
  Blocked notifications now use a native Swift floating modal (bottom-right) by default, with AppleScript fallback.
247
247
  Override mode with `PUMUKI_MACOS_BLOCKED_DIALOG_MODE=auto|swift-floating|applescript`.
248
248
  Custom skills import is available in advanced menu option `33` (writes `/.pumuki/custom-rules.json`).
@@ -679,6 +679,7 @@ npm run toolkit:clean-artifacts -- --dry-run
679
679
  - Fails safe (`exit 1`) with guidance when no upstream is configured.
680
680
  - Evaluates `upstream..HEAD` commit range.
681
681
  - Requires valid SDD/OpenSpec status (session + active change + validation).
682
+ - Si `.ai_evidence.json` está **versionado** en git y el resultado del gate **no** es `BLOCK` (`PASS`/`WARN`), Pumuki **no reescribe** ese archivo en disco. Así se evita que integraciones tipo `pre-commit` (p. ej. como hook de `pre-push`) fallen con “files were modified by this hook” tras un `decision=ALLOW`. El snapshot en el último commit sigue siendo el generado en `PRE_COMMIT` hasta el siguiente commit. Para forzar la escritura del snapshot `PRE_PUSH` en un fichero trackeado, usa `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE=1` (puede exigir un flujo de commit/evidencia explícito).
682
683
 
683
684
  ### CI
684
685
 
@@ -706,10 +707,12 @@ Resolver source: `integrations/git/resolveGitRefs.ts`.
706
707
 
707
708
  ## Evidence output
708
709
 
709
- Each run writes deterministic evidence to:
710
+ Cada ejecución del gate escribe evidencia determinista en:
710
711
 
711
712
  - `.ai_evidence.json`
712
713
 
714
+ Excepción: en `PRE_PUSH`, si el fichero está trackeado y el outcome no es `BLOCK`, la escritura al path anterior se omite (ver sección PRE_PUSH arriba). La telemetría interna del gate sigue generándose; solo se evita mutar el árbol de trabajo.
715
+
713
716
  Schema and behavior:
714
717
 
715
718
  - `version: "2.1"` is the source of truth
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { withTempDir } from '../__tests__/helpers/tempDir';
@@ -61,6 +61,29 @@ test('generateEvidence compone build + write y persiste .ai_evidence.json', asyn
61
61
  });
62
62
  });
63
63
 
64
+ test('generateEvidence omite escritura a disco cuando skipDiskWrite y repoRoot son válidos', async () => {
65
+ await withTempDir('pumuki-generate-evidence-skip-', async (tempRoot) => {
66
+ initGitRepo(tempRoot);
67
+ const evidencePath = join(tempRoot, '.ai_evidence.json');
68
+ writeFileSync(evidencePath, '{"version":"2.1","pinned":true}\n', 'utf8');
69
+
70
+ const result = generateEvidence({
71
+ stage: 'PRE_PUSH',
72
+ gateOutcome: 'PASS',
73
+ findings: [],
74
+ detectedPlatforms: {},
75
+ loadedRulesets: [],
76
+ repoRoot: tempRoot,
77
+ skipDiskWrite: true,
78
+ });
79
+
80
+ assert.equal(result.evidence.snapshot.stage, 'PRE_PUSH');
81
+ assert.equal(result.write.ok, true);
82
+ assert.equal(result.write.skipped, true);
83
+ assert.equal(readFileSync(evidencePath, 'utf8'), '{"version":"2.1","pinned":true}\n');
84
+ });
85
+ });
86
+
64
87
  test('generateEvidence mantiene evidencia en memoria aunque write falle', async () => {
65
88
  await withTempDir('pumuki-generate-evidence-write-error-', async (tempRoot) => {
66
89
  initGitRepo(tempRoot);
@@ -1,7 +1,10 @@
1
+ import { join } from 'node:path';
1
2
  import { buildEvidence, type BuildEvidenceParams } from './buildEvidence';
2
3
  import type { AiEvidenceV2_1 } from './schema';
3
4
  import { writeEvidence, type WriteEvidenceResult } from './writeEvidence';
4
5
 
6
+ const EVIDENCE_FILE_BASENAME = '.ai_evidence.json';
7
+
5
8
  export type GenerateEvidenceResult = {
6
9
  evidence: AiEvidenceV2_1;
7
10
  write: WriteEvidenceResult;
@@ -9,11 +12,25 @@ export type GenerateEvidenceResult = {
9
12
 
10
13
  export type GenerateEvidenceParams = BuildEvidenceParams & {
11
14
  repoRoot?: string;
15
+ skipDiskWrite?: boolean;
12
16
  };
13
17
 
14
18
  export const generateEvidence = (params: GenerateEvidenceParams): GenerateEvidenceResult => {
15
- const { repoRoot, ...buildParams } = params;
19
+ const { repoRoot, skipDiskWrite, ...buildParams } = params;
16
20
  const evidence = buildEvidence(buildParams);
21
+ if (skipDiskWrite === true) {
22
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
23
+ throw new Error('generateEvidence: repoRoot is required when skipDiskWrite is true');
24
+ }
25
+ return {
26
+ evidence,
27
+ write: {
28
+ ok: true,
29
+ path: join(repoRoot, EVIDENCE_FILE_BASENAME),
30
+ skipped: true,
31
+ },
32
+ };
33
+ }
17
34
  const write = writeEvidence(evidence, { repoRoot });
18
35
  return { evidence, write };
19
36
  };
@@ -22,6 +22,7 @@ export type WriteEvidenceResult = {
22
22
  ok: boolean;
23
23
  path: string;
24
24
  error?: string;
25
+ skipped?: boolean;
25
26
  };
26
27
 
27
28
  const EVIDENCE_FILE_NAME = '.ai_evidence.json';
@@ -1093,25 +1093,6 @@ const collectPreWriteCoherenceViolations = (params: {
1093
1093
  );
1094
1094
  }
1095
1095
 
1096
- if (params.preWriteWorktreeHygiene.enabled && params.repoState.git.available) {
1097
- const pendingChanges = resolvePendingChanges(params.repoState) ?? 0;
1098
- if (pendingChanges >= params.preWriteWorktreeHygiene.blockThreshold) {
1099
- violations.push(
1100
- toErrorViolation(
1101
- 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT',
1102
- `PRE_WRITE hygiene exceeded: pending_changes=${pendingChanges} (block_threshold=${params.preWriteWorktreeHygiene.blockThreshold}). Split worktree into atomic slices.`
1103
- )
1104
- );
1105
- } else if (pendingChanges >= params.preWriteWorktreeHygiene.warnThreshold) {
1106
- violations.push(
1107
- toWarnViolation(
1108
- 'EVIDENCE_PREWRITE_WORKTREE_WARN',
1109
- `PRE_WRITE hygiene warning: pending_changes=${pendingChanges} (warn_threshold=${params.preWriteWorktreeHygiene.warnThreshold}). Consider splitting worktree into smaller slices.`
1110
- )
1111
- );
1112
- }
1113
- }
1114
-
1115
1096
  return violations;
1116
1097
  };
1117
1098
 
@@ -1178,6 +1159,8 @@ const collectEvidenceViolations = (
1178
1159
  );
1179
1160
  }
1180
1161
 
1162
+ appendWorktreeHygieneViolations(violations, repoState, preWriteWorktreeHygiene, stage);
1163
+
1181
1164
  return { violations, ageSeconds };
1182
1165
  };
1183
1166
 
@@ -1228,6 +1211,33 @@ const resolvePendingChanges = (repoState: RepoState): number | null => {
1228
1211
  return repoState.git.pending_changes ?? (repoState.git.staged + repoState.git.unstaged);
1229
1212
  };
1230
1213
 
1214
+ const appendWorktreeHygieneViolations = (
1215
+ violations: AiGateViolation[],
1216
+ repoState: RepoState,
1217
+ policy: PreWriteWorktreeHygienePolicy,
1218
+ stageLabel: AiGateStage
1219
+ ): void => {
1220
+ if (!policy.enabled || !repoState.git.available) {
1221
+ return;
1222
+ }
1223
+ const pendingChanges = resolvePendingChanges(repoState) ?? 0;
1224
+ if (pendingChanges >= policy.blockThreshold) {
1225
+ violations.push(
1226
+ toErrorViolation(
1227
+ 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT',
1228
+ `${stageLabel} worktree hygiene exceeded: pending_changes=${pendingChanges} (block_threshold=${policy.blockThreshold}). Split worktree into atomic slices.`
1229
+ )
1230
+ );
1231
+ } else if (pendingChanges >= policy.warnThreshold) {
1232
+ violations.push(
1233
+ toWarnViolation(
1234
+ 'EVIDENCE_PREWRITE_WORKTREE_WARN',
1235
+ `${stageLabel} worktree hygiene warning: pending_changes=${pendingChanges} (warn_threshold=${policy.warnThreshold}). Consider smaller staged/unstaged batches.`
1236
+ )
1237
+ );
1238
+ }
1239
+ };
1240
+
1231
1241
  const toPolicyStage = (stage: AiGateStage): SkillsStage => {
1232
1242
  if (stage === 'PRE_WRITE') {
1233
1243
  return 'PRE_COMMIT';
@@ -0,0 +1,46 @@
1
+ import type { Finding } from '../../core/gate/Finding';
2
+ import type { GateStage } from '../../core/gate/GateStage';
3
+ import { evaluateAiGate, type AiGateStage } from '../gate/evaluateAiGate';
4
+
5
+ const AI_GATE_STAGES = new Set<AiGateStage>(['PRE_WRITE', 'PRE_COMMIT', 'PRE_PUSH', 'CI']);
6
+
7
+ const REPO_POLICY_CODES = new Set<string>([
8
+ 'GITFLOW_PROTECTED_BRANCH',
9
+ 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT',
10
+ 'EVIDENCE_PREWRITE_WORKTREE_WARN',
11
+ ]);
12
+
13
+ const toRepoPolicyFinding = (params: {
14
+ code: string;
15
+ message: string;
16
+ severity: 'ERROR' | 'WARN';
17
+ }): Finding => ({
18
+ ruleId: `ai_gate.repo_policy.${params.code}`,
19
+ severity: params.severity,
20
+ code: params.code,
21
+ message: params.message,
22
+ matchedBy: 'RepoPolicy',
23
+ source: 'ai_gate:repo_policy',
24
+ });
25
+
26
+ export const collectAiGateRepoPolicyFindings = (params: {
27
+ repoRoot: string;
28
+ stage: GateStage;
29
+ }): Finding[] => {
30
+ if (!AI_GATE_STAGES.has(params.stage as AiGateStage)) {
31
+ return [];
32
+ }
33
+ const evaluation = evaluateAiGate({
34
+ repoRoot: params.repoRoot,
35
+ stage: params.stage as AiGateStage,
36
+ });
37
+ return evaluation.violations
38
+ .filter((v) => REPO_POLICY_CODES.has(v.code))
39
+ .map((v) =>
40
+ toRepoPolicyFinding({
41
+ code: v.code,
42
+ message: v.message,
43
+ severity: v.severity === 'ERROR' ? 'ERROR' : 'WARN',
44
+ })
45
+ );
46
+ };
@@ -34,6 +34,7 @@ import { enforceTddBddPolicy } from '../tdd/enforcement';
34
34
  import type { TddBddSnapshot } from '../tdd/types';
35
35
  import { resolveSkillsEnforcement } from '../policy/skillsEnforcement';
36
36
  import { applyTddBddEnforcement } from '../policy/tddBddEnforcement';
37
+ import { collectAiGateRepoPolicyFindings } from './aiGateRepoPolicyFindings';
37
38
 
38
39
  export type OperationalMemoryShadowRecommendation = {
39
40
  recommendedOutcome: 'ALLOW' | 'WARN' | 'BLOCK';
@@ -151,13 +152,19 @@ const defaultDependencies: GateDependencies = {
151
152
  }),
152
153
  };
153
154
 
154
- const resolveCurrentBranch = (git: IGitService, repoRoot: string): string | null => {
155
+ const readSymbolicBranchRef = (git: IGitService, repoRoot: string): string | null => {
155
156
  try {
156
157
  const symbolicBranch = git.runGit(['symbolic-ref', '--short', 'HEAD'], repoRoot).trim();
157
- if (symbolicBranch.length > 0) {
158
- return symbolicBranch;
159
- }
158
+ return symbolicBranch.length > 0 ? symbolicBranch : null;
160
159
  } catch {
160
+ return null;
161
+ }
162
+ };
163
+
164
+ const resolveCurrentBranch = (git: IGitService, repoRoot: string): string | null => {
165
+ const symbolic = readSymbolicBranchRef(git, repoRoot);
166
+ if (symbolic !== null) {
167
+ return symbolic;
161
168
  }
162
169
  try {
163
170
  const branch = git.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).trim();
@@ -927,6 +934,15 @@ export async function runPlatformGate(params: {
927
934
  const filesScanned = countScannedFilesFromFacts(factsForPlatformEvaluation);
928
935
  const observedCodePaths = collectObservedCodePathsFromFacts(facts);
929
936
 
937
+ const platformEvaluation = dependencies.evaluatePlatformGateFindings({
938
+ facts: factsForPlatformEvaluation,
939
+ stage: params.policy.stage,
940
+ repoRoot,
941
+ });
942
+ const aiGateRepoPolicyFindings = collectAiGateRepoPolicyFindings({
943
+ repoRoot,
944
+ stage: params.policy.stage,
945
+ });
930
946
  const {
931
947
  detectedPlatforms,
932
948
  skillsRuleSet,
@@ -934,12 +950,9 @@ export async function runPlatformGate(params: {
934
950
  heuristicRules,
935
951
  coverage,
936
952
  evaluationFacts = factsForPlatformEvaluation,
937
- findings,
938
- } = dependencies.evaluatePlatformGateFindings({
939
- facts: factsForPlatformEvaluation,
940
- stage: params.policy.stage,
941
- repoRoot,
942
- });
953
+ findings: ruleEngineFindings,
954
+ } = platformEvaluation;
955
+ const findings = [...aiGateRepoPolicyFindings, ...ruleEngineFindings];
943
956
  const evaluationMetrics: SnapshotEvaluationMetrics = coverage
944
957
  ? {
945
958
  facts_total: coverage.factsTotal,
@@ -1,6 +1,7 @@
1
1
  import type { Finding } from '../../core/gate/Finding';
2
2
  import type { GateOutcome } from '../../core/gate/GateOutcome';
3
3
  import type { GateStage } from '../../core/gate/GateStage';
4
+ import { GitService } from './GitService';
4
5
  import { rulePackVersions } from '../../core/rules/presets/rulePackVersions';
5
6
  import type { RuleSet } from '../../core/rules/RuleSet';
6
7
  import type { SkillsRuleSetLoadResult } from '../config/skillsRuleSet';
@@ -17,14 +18,51 @@ import { normalizeSnapshotRulesCoverage } from '../evidence/rulesCoverage';
17
18
  import type { TddBddSnapshot } from '../tdd/types';
18
19
  import { emitGateTelemetryEvent } from '../telemetry/gateTelemetry';
19
20
 
21
+ const TRACKED_EVIDENCE_RELATIVE_PATH = '.ai_evidence.json';
22
+
23
+ const isTruthyEnvFlag = (value?: string): boolean => {
24
+ if (!value) {
25
+ return false;
26
+ }
27
+ const normalized = value.trim().toLowerCase();
28
+ return normalized === '1' || normalized === 'true' || normalized === 'yes';
29
+ };
30
+
31
+ const defaultIsEvidencePathTracked = (repoRoot: string, relativePath: string): boolean => {
32
+ try {
33
+ new GitService().runGit(['ls-files', '--error-unmatch', '--', relativePath], repoRoot);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+
40
+ const shouldSkipPrePushTrackedEvidenceDiskWrite = (params: {
41
+ stage: GateStage;
42
+ gateOutcome: GateOutcome;
43
+ repoRoot: string;
44
+ isEvidencePathTracked: (repoRoot: string, relativePath: string) => boolean;
45
+ }): boolean => {
46
+ if (isTruthyEnvFlag(process.env.PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE)) {
47
+ return false;
48
+ }
49
+ return (
50
+ params.stage === 'PRE_PUSH' &&
51
+ params.gateOutcome !== 'BLOCK' &&
52
+ params.isEvidencePathTracked(params.repoRoot, TRACKED_EVIDENCE_RELATIVE_PATH)
53
+ );
54
+ };
55
+
20
56
  export type PlatformGateEvidenceDependencies = {
21
57
  generateEvidence: typeof generateEvidence;
22
58
  emitGateTelemetryEvent: typeof emitGateTelemetryEvent;
59
+ isEvidencePathTracked: (repoRoot: string, relativePath: string) => boolean;
23
60
  };
24
61
 
25
62
  const defaultDependencies: PlatformGateEvidenceDependencies = {
26
63
  generateEvidence,
27
64
  emitGateTelemetryEvent,
65
+ isEvidencePathTracked: defaultIsEvidencePathTracked,
28
66
  };
29
67
 
30
68
  export const emitPlatformGateEvidence = (params: {
@@ -58,6 +96,12 @@ export const emitPlatformGateEvidence = (params: {
58
96
  const evaluationMetrics = normalizeSnapshotEvaluationMetrics(params.evaluationMetrics);
59
97
  const rulesCoverage = normalizeSnapshotRulesCoverage(params.stage, params.rulesCoverage);
60
98
  const repoState = captureRepoState(params.repoRoot);
99
+ const skipDiskWrite = shouldSkipPrePushTrackedEvidenceDiskWrite({
100
+ stage: params.stage,
101
+ gateOutcome: params.gateOutcome,
102
+ repoRoot: params.repoRoot,
103
+ isEvidencePathTracked: activeDependencies.isEvidencePathTracked,
104
+ });
61
105
 
62
106
  activeDependencies.generateEvidence({
63
107
  stage: params.stage,
@@ -70,6 +114,7 @@ export const emitPlatformGateEvidence = (params: {
70
114
  ...(params.tddBdd ? { tddBdd: params.tddBdd } : {}),
71
115
  ...(params.memoryShadow ? { memoryShadow: params.memoryShadow } : {}),
72
116
  repoRoot: params.repoRoot,
117
+ ...(skipDiskWrite ? { skipDiskWrite: true } : {}),
73
118
  previousEvidence: params.evidenceService.loadPreviousEvidence(params.repoRoot),
74
119
  detectedPlatforms: params.evidenceService.toDetectedPlatformsRecord(params.detectedPlatforms),
75
120
  loadedRulesets: params.evidenceService.buildRulesetState({
@@ -62,6 +62,10 @@ const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
62
62
  EVIDENCE_ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES:
63
63
  'Reconcilia policy/skills y revalida PRE_WRITE: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki sdd validate --stage=PRE_WRITE --json',
64
64
  GITFLOW_PROTECTED_BRANCH: 'Trabaja en feature/* y evita ramas protegidas.',
65
+ EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT:
66
+ 'Reduce archivos staged/unstaged por debajo del umbral (o ajusta PUMUKI_PREWRITE_WORKTREE_*); divide el trabajo en commits más pequeños.',
67
+ EVIDENCE_PREWRITE_WORKTREE_WARN:
68
+ 'El worktree supera el umbral de aviso; reduce alcance antes del siguiente commit/push.',
65
69
  PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>',
66
70
  PRE_PUSH_UPSTREAM_MISALIGNED:
67
71
  'Alinea upstream con la rama actual: git branch --unset-upstream && git push --set-upstream origin <branch>',
@@ -186,6 +190,7 @@ const defaultDependencies: StageRunnerDependencies = {
186
190
  try {
187
191
  ensureRuntimeArtifactsIgnored(repoRoot);
188
192
  } catch {
193
+ undefined;
189
194
  }
190
195
  },
191
196
  runPolicyReconcile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.68",
3
+ "version": "6.3.70",
4
4
  "description": "Enterprise-grade AST Intelligence System with multi-platform support (iOS, Android, Backend, Frontend) and Feature-First + DDD + Clean Architecture enforcement. Includes dynamic violations API for intelligent querying.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -6,16 +6,35 @@ import {
6
6
  } from './framework-menu-system-notifications-types';
7
7
  import { writeSystemNotificationsConfigFile } from './framework-menu-system-notifications-config-file';
8
8
 
9
+ export const normalizeBlockedDialogButtonLabel = (raw: string): string => {
10
+ const t = raw.replace(/\r/g, '').trim();
11
+ const lower = t.toLowerCase();
12
+ if (t === BLOCKED_DIALOG_DISABLE || lower.includes('desactivar')) {
13
+ return BLOCKED_DIALOG_DISABLE;
14
+ }
15
+ if (
16
+ t === BLOCKED_DIALOG_MUTE_30
17
+ || (lower.includes('silenciar') && lower.includes('30'))
18
+ ) {
19
+ return BLOCKED_DIALOG_MUTE_30;
20
+ }
21
+ if (t === BLOCKED_DIALOG_KEEP || (lower.includes('mantener') && lower.includes('activ'))) {
22
+ return BLOCKED_DIALOG_KEEP;
23
+ }
24
+ return t;
25
+ };
26
+
9
27
  export const applyDialogChoice = (params: {
10
28
  repoRoot: string;
11
29
  config: SystemNotificationsConfig;
12
30
  button: string;
13
31
  nowMs: number;
14
32
  }): void => {
15
- if (params.button === BLOCKED_DIALOG_KEEP) {
33
+ const button = normalizeBlockedDialogButtonLabel(params.button);
34
+ if (button === BLOCKED_DIALOG_KEEP) {
16
35
  return;
17
36
  }
18
- if (params.button === BLOCKED_DIALOG_DISABLE) {
37
+ if (button === BLOCKED_DIALOG_DISABLE) {
19
38
  writeSystemNotificationsConfigFile(params.repoRoot, {
20
39
  enabled: false,
21
40
  channel: params.config.channel,
@@ -23,7 +42,7 @@ export const applyDialogChoice = (params: {
23
42
  });
24
43
  return;
25
44
  }
26
- if (params.button === BLOCKED_DIALOG_MUTE_30) {
45
+ if (button === BLOCKED_DIALOG_MUTE_30) {
27
46
  const muteUntil = new Date(params.nowMs + 30 * 60_000).toISOString();
28
47
  writeSystemNotificationsConfigFile(params.repoRoot, {
29
48
  enabled: true,
@@ -13,16 +13,23 @@ export const buildSystemNotificationsConfigFromSelection = (
13
13
  ): SystemNotificationsConfig => ({
14
14
  enabled,
15
15
  channel: 'macos',
16
- blockedDialogEnabled: false,
16
+ blockedDialogEnabled: enabled,
17
17
  });
18
18
 
19
19
  export const normalizeSystemNotificationsConfig = (
20
20
  raw: RawSystemNotificationsConfig
21
21
  ): SystemNotificationsConfig => {
22
+ const enabled = raw.enabled === true;
23
+ const blockedDialogEnabled =
24
+ raw.blockedDialogEnabled === true
25
+ ? true
26
+ : raw.blockedDialogEnabled === false
27
+ ? false
28
+ : enabled;
22
29
  const config: SystemNotificationsConfig = {
23
- enabled: raw.enabled === true,
30
+ enabled,
24
31
  channel: 'macos',
25
- blockedDialogEnabled: raw.blockedDialogEnabled === true,
32
+ blockedDialogEnabled,
26
33
  };
27
34
  if (typeof raw.muteUntil === 'string' && raw.muteUntil.trim().length > 0) {
28
35
  config.muteUntil = raw.muteUntil;
@@ -14,6 +14,19 @@ import {
14
14
  isStderrNotificationFallbackDisabled,
15
15
  } from './framework-menu-system-notifications-stdio-fallback';
16
16
 
17
+ const shouldMirrorGateBlockedToStderr = (
18
+ event: PumukiCriticalNotificationEvent,
19
+ env: NodeJS.ProcessEnv
20
+ ): boolean => {
21
+ if (event.kind !== 'gate.blocked') {
22
+ return false;
23
+ }
24
+ if (isTruthyEnvValue(env.PUMUKI_DISABLE_GATE_BLOCKED_STDERR_MIRROR)) {
25
+ return false;
26
+ }
27
+ return true;
28
+ };
29
+
17
30
  export const dispatchSystemNotification = (params: {
18
31
  event: PumukiCriticalNotificationEvent;
19
32
  payload: SystemNotificationPayload;
@@ -46,7 +59,12 @@ export const dispatchSystemNotification = (params: {
46
59
  applyDialogChoice,
47
60
  });
48
61
 
49
- if (macResult.delivered && isTruthyEnvValue(params.env.PUMUKI_NOTIFICATION_STDERR_MIRROR)) {
62
+ const mirrorStderrForVisibility =
63
+ macResult.delivered &&
64
+ !stderrOff &&
65
+ (isTruthyEnvValue(params.env.PUMUKI_NOTIFICATION_STDERR_MIRROR) ||
66
+ shouldMirrorGateBlockedToStderr(params.event, params.env));
67
+ if (mirrorStderrForVisibility) {
50
68
  deliverStderrNotificationBanner({ payload: params.payload });
51
69
  }
52
70
 
@@ -1,4 +1,5 @@
1
1
  import type { SystemNotificationsConfig } from './framework-menu-system-notifications-types';
2
+ import { normalizeBlockedDialogButtonLabel } from './framework-menu-system-notifications-config-choice';
2
3
 
3
4
  export const applyBlockedDialogSelection = (params: {
4
5
  repoRoot: string;
@@ -19,7 +20,7 @@ export const applyBlockedDialogSelection = (params: {
19
20
  params.applyDialogChoice({
20
21
  repoRoot: params.repoRoot,
21
22
  config: params.config,
22
- button: params.selectedButton,
23
+ button: normalizeBlockedDialogButtonLabel(params.selectedButton),
23
24
  nowMs: params.nowMs,
24
25
  });
25
26
  };
@@ -1,7 +1,12 @@
1
1
  export const extractDialogButton = (stdout: string): string | null => {
2
- const match = stdout.match(/button returned:(.+)/i);
3
- if (!match || !match[1]) {
2
+ const matches = [...stdout.matchAll(/button returned:\s*(.+)/gi)];
3
+ if (matches.length === 0) {
4
4
  return null;
5
5
  }
6
- return match[1].trim();
6
+ const raw = matches[matches.length - 1][1]?.trim() ?? '';
7
+ const cleaned = raw
8
+ .replace(/[,}]\s*$/g, '')
9
+ .replace(/^["'\u201c]|[\u201d"']$/g, '')
10
+ .trim();
11
+ return cleaned.length > 0 ? cleaned : null;
7
12
  };
@@ -1,6 +1,10 @@
1
1
  export const SWIFT_BLOCKED_DIALOG_SOURCE = String.raw`import AppKit
2
2
  import Foundation
3
3
 
4
+ final class KeyableFloatingPanel: NSPanel {
5
+ override var canBecomeKey: Bool { true }
6
+ }
7
+
4
8
  struct DialogConfig {
5
9
  let title: String
6
10
  let cause: String
@@ -108,12 +112,13 @@ final class DialogController: NSObject, NSApplicationDelegate, NSWindowDelegate
108
112
  y: screenFrame.minY + margin
109
113
  )
110
114
 
111
- let panel = NSPanel(
115
+ let panel = KeyableFloatingPanel(
112
116
  contentRect: NSRect(x: origin.x, y: origin.y, width: width, height: height),
113
117
  styleMask: [.titled, .closable, .fullSizeContentView],
114
118
  backing: .buffered,
115
119
  defer: false
116
120
  )
121
+ panel.becomesKeyOnlyIfNeeded = false
117
122
  panel.level = .floating
118
123
  panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
119
124
  panel.titleVisibility = .hidden
@@ -5,7 +5,7 @@ import {
5
5
  type SystemNotificationCommandRunnerWithOutput,
6
6
  type SystemNotificationsConfig,
7
7
  } from './framework-menu-system-notifications-types';
8
- import { resolveBlockedDialogEnabled } from './framework-menu-system-notifications-macos-dialog';
8
+ import { resolveBlockedDialogEnabled } from './framework-menu-system-notifications-macos-dialog-enabled';
9
9
  import { emitMacOsBannerStage } from './framework-menu-system-notifications-macos-banner-stage';
10
10
  import { emitMacOsBlockedDialogStage } from './framework-menu-system-notifications-macos-blocked-stage';
11
11
  import { finalizeMacOsNotificationDelivery } from './framework-menu-system-notifications-macos-result';
@@ -13,6 +13,21 @@ import { finalizeMacOsNotificationDelivery } from './framework-menu-system-notif
13
13
  export { runSystemCommand, runSystemCommandWithOutput } from './framework-menu-system-notifications-macos-runner';
14
14
  export { resolveBlockedDialogEnabled } from './framework-menu-system-notifications-macos-dialog';
15
15
 
16
+ const shouldSkipMacOsBannerForInteractiveBlockedDialog = (params: {
17
+ event: PumukiCriticalNotificationEvent;
18
+ repoRoot?: string;
19
+ config: SystemNotificationsConfig;
20
+ env: NodeJS.ProcessEnv;
21
+ }): boolean => {
22
+ if (params.event.kind !== 'gate.blocked') {
23
+ return false;
24
+ }
25
+ if (typeof params.repoRoot !== 'string') {
26
+ return false;
27
+ }
28
+ return resolveBlockedDialogEnabled({ env: params.env, config: params.config });
29
+ };
30
+
16
31
  export const deliverMacOsNotification = (params: {
17
32
  event: PumukiCriticalNotificationEvent;
18
33
  payload: Parameters<typeof emitMacOsBannerStage>[0]['payload'];
@@ -29,11 +44,20 @@ export const deliverMacOsNotification = (params: {
29
44
  nowMs: number;
30
45
  }) => void;
31
46
  }): SystemNotificationEmitResult => {
32
- const bannerResult = emitMacOsBannerStage({
33
- payload: params.payload,
34
- runCommand: params.runCommand,
47
+ const skipBanner = shouldSkipMacOsBannerForInteractiveBlockedDialog({
48
+ event: params.event,
49
+ repoRoot: params.repoRoot,
50
+ config: params.config,
51
+ env: params.env,
35
52
  });
36
53
 
54
+ const bannerResult = skipBanner
55
+ ? { delivered: true as const, reason: 'delivered' as const }
56
+ : emitMacOsBannerStage({
57
+ payload: params.payload,
58
+ runCommand: params.runCommand,
59
+ });
60
+
37
61
  emitMacOsBlockedDialogStage({
38
62
  event: params.event,
39
63
  repoRoot: params.repoRoot,