pumuki 6.3.69 → 6.3.71

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`. 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).
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,20 @@ 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.71)
10
+
11
+ - **Evidencia v2.1**: bloque `operational_hints` (`requires_second_pass`, resumen operativo, desglose por severidad de reglas). Alineado con PRE_COMMIT solo-docs + evidencia trackeada (INC-069) cuando no se re-stagea el JSON automáticamente.
12
+ - **Monorepo**: `PUMUKI_GATE_SCOPE_PATH_PREFIXES` acota el primer alcance de hechos por prefijos de ruta.
13
+ - **Paridad CI/local**: `pumuki doctor --parity` y fichero opcional `.pumuki/ci-parity-expected.json` (fallo con exit 1 si hay drift respecto al esperado).
14
+ - **MCP y hooks**: mismas pistas de remediación por código de violación vía catálogo compartido.
15
+ - **Rollout**: `pumuki@6.3.71`; repin en consumidores cuando se publique npm; validar hooks y `doctor --parity` si fijáis expectativas de CI.
16
+
17
+ ### 2026-04-06 (v6.3.70)
18
+
19
+ - **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).
20
+ - **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.
21
+ - **Rollout**: `pumuki@6.3.70`, repin en monorepos (p. ej. RuralGO); `npm test` / `git push` con hooks encadenados como validación.
22
+
9
23
  ### 2026-04-05 (v6.3.69)
10
24
 
11
25
  - **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`.
@@ -317,6 +317,22 @@ Environment variables:
317
317
  - `PUMUKI_PREWRITE_WORKTREE_WARN_THRESHOLD` (default: `12`)
318
318
  - `PUMUKI_PREWRITE_WORKTREE_BLOCK_THRESHOLD` (default: `24`)
319
319
 
320
+ ## Alcance del gate por prefijos (monorepos)
321
+
322
+ - `PUMUKI_GATE_SCOPE_PATH_PREFIXES`: prefijos de ruta separados por **coma** o **punto y coma** (p. ej. `apps/backend,apps/web-app`). Se normalizan barras invertidas a `/`. El **primer** conjunto de hechos del alcance del stage (p. ej. staged o rango de commits) se filtra a rutas bajo esos prefijos. Hechos sin ruta de archivo reconocible (p. ej. algunas dependencias) se conservan para no romper reglas transversales.
323
+
324
+ ## Paridad local vs CI (`pumuki doctor`)
325
+
326
+ - Fichero opcional **`.pumuki/ci-parity-expected.json`** (commit en el repo consumer): JSON mínimo con los campos que quieras fijar, p. ej. `pumuki_package_version`, `pre_commit_policy_hash`, `pre_commit_policy_bundle`. Con `pumuki doctor --parity` (y opcionalmente `--json`), Pumuki calcula el perfil actual y, si existe el fichero esperado, rellena `parity_comparison`; cualquier desajuste hace **exit code 1**.
327
+
328
+ ## Evidencia en PRE_COMMIT con índice solo documentación
329
+
330
+ - `PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE` (`1|true|yes`): fuerza el `git add` automático de `.ai_evidence.json` después de un `PRE_COMMIT` exitoso aunque el índice solo contenga rutas `*.md` / `*.mdx` (además de la propia evidencia). Por defecto, en ese alcance Pumuki **no** ensarta la evidencia en el commit: el snapshot se refresca en disco y puedes decidir si lo incluyes (`git add -- .ai_evidence.json`). Cubre el caso de commits puramente documentales sin mezclar un diff grande de evidencia (p. ej. informes upstream tipo PUMUKI-INC-069).
331
+
332
+ ## Evidencia en PRE_PUSH con `.ai_evidence.json` trackeado
333
+
334
+ - `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`).
335
+
320
336
  Codes emitted:
321
337
 
322
338
  - `EVIDENCE_PREWRITE_WORKTREE_WARN` (warning, still `ALLOWED`)
@@ -287,6 +287,8 @@ npx --yes pumuki install --with-mcp --agent=codex
287
287
  npx --yes pumuki doctor
288
288
  # include deterministic adapter/mcp wiring health checks
289
289
  npx --yes pumuki doctor --deep --json
290
+ # parity profile vs .pumuki/ci-parity-expected.json (CI/local alignment)
291
+ npx --yes pumuki doctor --parity --json
290
292
 
291
293
  # show lifecycle status
292
294
  npx --yes pumuki status
@@ -671,7 +673,9 @@ npm run toolkit:clean-artifacts -- --dry-run
671
673
 
672
674
  - Reads staged changes with `git diff --cached --name-status`.
673
675
  - Builds facts from staged content.
676
+ - Opcional: `PUMUKI_GATE_SCOPE_PATH_PREFIXES` acota el primer alcance de hechos a prefijos del monorepo (ver `docs/product/CONFIGURATION.md`).
674
677
  - Requires valid SDD/OpenSpec status (session + active change + validation).
678
+ - Si `.ai_evidence.json` está **versionado** y el hook refresca el snapshot en disco tras un gate **no** bloqueante, por defecto Pumuki vuelve a hacer `git add` de ese fichero **salvo** que lo único en el índice (ignorando `.ai_evidence.json` / `.AI_EVIDENCE.json`) sean rutas de documentación (`*.md`, `*.mdx`). En ese caso la evidencia se actualiza en disco pero **no** se ensarta en el commit: puedes añadirla manualmente (`git add -- .ai_evidence.json`) o activar el comportamiento anterior con `PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE=1` (ver `docs/product/CONFIGURATION.md`). En stderr (si no va en modo silencioso) verás un aviso `[pumuki][evidence-sync]`.
675
679
 
676
680
  ### PRE_PUSH
677
681
 
@@ -679,6 +683,7 @@ npm run toolkit:clean-artifacts -- --dry-run
679
683
  - Fails safe (`exit 1`) with guidance when no upstream is configured.
680
684
  - Evaluates `upstream..HEAD` commit range.
681
685
  - Requires valid SDD/OpenSpec status (session + active change + validation).
686
+ - 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
687
 
683
688
  ### CI
684
689
 
@@ -706,13 +711,19 @@ Resolver source: `integrations/git/resolveGitRefs.ts`.
706
711
 
707
712
  ## Evidence output
708
713
 
709
- Each run writes deterministic evidence to:
714
+ Cada ejecución del gate escribe evidencia determinista en:
710
715
 
711
716
  - `.ai_evidence.json`
712
717
 
718
+ Excepciones:
719
+
720
+ - 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.
721
+ - En `PRE_COMMIT`, si el fichero está trackeado y el índice es solo documentación (`*.md` / `*.mdx`), el fichero puede actualizarse en disco sin auto-`git add` (ver sección PRE_COMMIT arriba).
722
+
713
723
  Schema and behavior:
714
724
 
715
725
  - `version: "2.1"` is the source of truth
726
+ - `operational_hints` (opcional pero recomendado en runs recientes): `requires_second_pass` (boolean), `second_pass_reason` (string o null), `human_summary_lines` (1–4 líneas legibles), `rule_execution_breakdown` (conteos evaluated / blocking / warn / info / skipped out-of-scope). Útil para tooling y para entender el resultado sin abrir todo el snapshot.
716
727
  - `snapshot` + `ledger`
717
728
  - `platforms` and `rulesets` tracking
718
729
  - `snapshot.sdd_metrics` tracks stage-level SDD enforcement metadata
@@ -7,6 +7,7 @@ import type {
7
7
  ConsolidationSuppressedFinding,
8
8
  CompatibilityViolation,
9
9
  EvidenceLines,
10
+ EvidenceOperationalHints,
10
11
  HumanIntentState,
11
12
  LedgerEntry,
12
13
  PlatformState,
@@ -23,6 +24,7 @@ import { buildSnapshotPlatformSummaries } from './platformSummary';
23
24
  import { resolveHumanIntent } from './humanIntent';
24
25
  import { normalizeSnapshotEvaluationMetrics } from './evaluationMetrics';
25
26
  import { normalizeSnapshotRulesCoverage } from './rulesCoverage';
27
+ import { buildEvidenceOperationalHints } from './operationalHints';
26
28
 
27
29
  type BuildFindingInput = Finding & {
28
30
  file?: string;
@@ -50,6 +52,9 @@ export type BuildEvidenceParams = {
50
52
  };
51
53
  sddMetrics?: SddMetrics;
52
54
  repoState?: RepoState;
55
+ operationalHintsExtra?: Partial<
56
+ Pick<EvidenceOperationalHints, 'requires_second_pass' | 'second_pass_reason'>
57
+ >;
53
58
  };
54
59
 
55
60
  const normalizeLines = (lines?: EvidenceLines): EvidenceLines | undefined => {
@@ -766,9 +771,19 @@ export function buildEvidence(params: BuildEvidenceParams): AiEvidenceV2_1 {
766
771
  previousEvidence: params.previousEvidence,
767
772
  });
768
773
 
774
+ const operational_hints = buildEvidenceOperationalHints({
775
+ stage: params.stage,
776
+ outcome,
777
+ findings: normalizedFindings,
778
+ rulesCoverage: normalizedRulesCoverage,
779
+ evaluationMetrics: normalizedEvaluationMetrics,
780
+ extra: params.operationalHintsExtra,
781
+ });
782
+
769
783
  return {
770
784
  version: '2.1',
771
785
  timestamp: now,
786
+ operational_hints,
772
787
  snapshot: {
773
788
  stage: params.stage,
774
789
  audit_mode: params.auditMode ?? 'gate',
@@ -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
  };
@@ -0,0 +1,110 @@
1
+ import type { GateOutcome } from '../../core/gate/GateOutcome';
2
+ import type { GateStage } from '../../core/gate/GateStage';
3
+ import { resolveRemediationHintOrDefault } from '../gate/remediationCatalog';
4
+ import type {
5
+ EvidenceOperationalHints,
6
+ EvidenceRuleExecutionBreakdown,
7
+ SnapshotEvaluationMetrics,
8
+ SnapshotFinding,
9
+ SnapshotRulesCoverage,
10
+ } from './schema';
11
+
12
+ const truncate = (value: string, max: number): string => {
13
+ const t = value.trim();
14
+ if (t.length <= max) {
15
+ return t;
16
+ }
17
+ return `${t.slice(0, max - 1)}…`;
18
+ };
19
+
20
+ const buildRuleExecutionBreakdown = (params: {
21
+ findings: ReadonlyArray<SnapshotFinding>;
22
+ rulesCoverage?: SnapshotRulesCoverage;
23
+ evaluationMetrics?: SnapshotEvaluationMetrics;
24
+ }): EvidenceRuleExecutionBreakdown => {
25
+ const matched_blocking_count = params.findings.filter(
26
+ (f) =>
27
+ f.severity === 'ERROR' ||
28
+ f.severity === 'CRITICAL' ||
29
+ f.blocking === true
30
+ ).length;
31
+ const matched_warn_count = params.findings.filter((f) => f.severity === 'WARN').length;
32
+ const matched_info_count = params.findings.filter((f) => f.severity === 'INFO').length;
33
+ const evaluated_count =
34
+ params.rulesCoverage?.counts?.evaluated ?? params.evaluationMetrics?.evaluated_rule_ids.length ?? 0;
35
+ const active = params.rulesCoverage?.counts?.active ?? 0;
36
+ const evaluatedFromCoverage = params.rulesCoverage?.counts?.evaluated;
37
+ const skipped_out_of_scope_count =
38
+ typeof params.rulesCoverage?.counts?.unevaluated === 'number'
39
+ ? params.rulesCoverage.counts.unevaluated
40
+ : active > 0 && typeof evaluatedFromCoverage === 'number'
41
+ ? Math.max(0, active - evaluatedFromCoverage)
42
+ : 0;
43
+
44
+ return {
45
+ evaluated_count: Math.max(0, evaluated_count),
46
+ matched_blocking_count,
47
+ matched_warn_count,
48
+ matched_info_count,
49
+ skipped_out_of_scope_count: Math.max(0, skipped_out_of_scope_count),
50
+ };
51
+ };
52
+
53
+ const buildHumanSummaryLines = (params: {
54
+ stage: GateStage;
55
+ outcome: GateOutcome;
56
+ findings: ReadonlyArray<SnapshotFinding>;
57
+ }): string[] => {
58
+ const lines: string[] = [`${params.stage}: outcome=${params.outcome}.`];
59
+ if (params.outcome === 'BLOCK') {
60
+ const blocking =
61
+ params.findings.find((f) => f.severity === 'CRITICAL' || f.severity === 'ERROR') ??
62
+ params.findings.find((f) => f.blocking === true) ??
63
+ params.findings[0];
64
+ if (blocking) {
65
+ lines.push(`${blocking.code}: ${truncate(blocking.message, 140)}`);
66
+ lines.push(resolveRemediationHintOrDefault(blocking.code));
67
+ }
68
+ return lines.slice(0, 3);
69
+ }
70
+ const warn = params.findings.find((f) => f.severity === 'WARN');
71
+ if (warn) {
72
+ lines.push(`WARN ${warn.code}: ${truncate(warn.message, 120)}`);
73
+ }
74
+ return lines.slice(0, 3);
75
+ };
76
+
77
+ export const buildEvidenceOperationalHints = (params: {
78
+ stage: GateStage;
79
+ outcome: GateOutcome;
80
+ findings: ReadonlyArray<SnapshotFinding>;
81
+ rulesCoverage?: SnapshotRulesCoverage;
82
+ evaluationMetrics?: SnapshotEvaluationMetrics;
83
+ extra?: Partial<Pick<EvidenceOperationalHints, 'requires_second_pass' | 'second_pass_reason'>>;
84
+ }): EvidenceOperationalHints => {
85
+ const breakdown = buildRuleExecutionBreakdown({
86
+ findings: params.findings,
87
+ rulesCoverage: params.rulesCoverage,
88
+ evaluationMetrics: params.evaluationMetrics,
89
+ });
90
+ let human_summary_lines = buildHumanSummaryLines({
91
+ stage: params.stage,
92
+ outcome: params.outcome,
93
+ findings: params.findings,
94
+ });
95
+ if (params.extra?.requires_second_pass === true) {
96
+ human_summary_lines = [
97
+ ...human_summary_lines,
98
+ 'La evidencia trackeada se actualizó en disco pero no entró en el índice; si debe ir en este commit: git add -- .ai_evidence.json',
99
+ ];
100
+ }
101
+ return {
102
+ requires_second_pass: params.extra?.requires_second_pass ?? false,
103
+ second_pass_reason:
104
+ params.extra?.requires_second_pass === true
105
+ ? (params.extra.second_pass_reason ?? null)
106
+ : null,
107
+ human_summary_lines: human_summary_lines.slice(0, 4),
108
+ rule_execution_breakdown: breakdown,
109
+ };
110
+ };
@@ -206,10 +206,26 @@ export type EvidenceChain = {
206
206
  sequence: number;
207
207
  };
208
208
 
209
+ export type EvidenceRuleExecutionBreakdown = {
210
+ evaluated_count: number;
211
+ matched_blocking_count: number;
212
+ matched_warn_count: number;
213
+ matched_info_count: number;
214
+ skipped_out_of_scope_count: number;
215
+ };
216
+
217
+ export type EvidenceOperationalHints = {
218
+ requires_second_pass: boolean;
219
+ second_pass_reason: string | null;
220
+ human_summary_lines: string[];
221
+ rule_execution_breakdown?: EvidenceRuleExecutionBreakdown;
222
+ };
223
+
209
224
  export type AiEvidenceV2_1 = {
210
225
  version: '2.1';
211
226
  timestamp: string;
212
227
  evidence_chain?: EvidenceChain;
228
+ operational_hints?: EvidenceOperationalHints;
213
229
  snapshot: Snapshot;
214
230
  ledger: LedgerEntry[];
215
231
  platforms: Record<string, PlatformState>;
@@ -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';
@@ -341,6 +342,9 @@ const toStableEvidence = (
341
342
  return {
342
343
  version: '2.1',
343
344
  timestamp: evidence.timestamp,
345
+ ...(typeof evidence.operational_hints !== 'undefined'
346
+ ? { operational_hints: evidence.operational_hints }
347
+ : {}),
344
348
  snapshot: {
345
349
  stage: evidence.snapshot.stage,
346
350
  audit_mode: normalizedAuditMode,
@@ -0,0 +1,40 @@
1
+ export const DEFAULT_GATE_REMEDIATION =
2
+ 'Corrige la causa del bloqueo y vuelve a ejecutar el gate.';
3
+
4
+ export const REMEDIATION_HINT_BY_CODE: Readonly<Record<string, string>> = {
5
+ EVIDENCE_MISSING: 'Regenera .ai_evidence.json ejecutando una auditoría.',
6
+ EVIDENCE_INVALID: 'Corrige/regenera .ai_evidence.json y vuelve a ejecutar el gate.',
7
+ EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para restaurar la cadena criptográfica.',
8
+ EVIDENCE_STAGE_SYNC_FAILED:
9
+ 'Sincroniza la evidencia trackeada y reintenta: git add -- .ai_evidence.json && git commit --amend --no-edit',
10
+ EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
11
+ EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este mismo repositorio.',
12
+ EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual y reintenta.',
13
+ EVIDENCE_RULES_COVERAGE_MISSING: 'Ejecuta auditoría completa para recalcular rules_coverage.',
14
+ EVIDENCE_RULES_COVERAGE_INCOMPLETE: 'Asegura coverage_ratio=1 y unevaluated=0.',
15
+ ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES_HIGH:
16
+ 'Reconcilia policy/skills y reintenta PRE_COMMIT: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki-pre-commit',
17
+ EVIDENCE_ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES:
18
+ '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',
19
+ GITFLOW_PROTECTED_BRANCH: 'Trabaja en feature/* y evita ramas protegidas.',
20
+ EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT:
21
+ 'Reduce archivos staged/unstaged por debajo del umbral (o ajusta PUMUKI_PREWRITE_WORKTREE_*); divide el trabajo en commits más pequeños.',
22
+ EVIDENCE_PREWRITE_WORKTREE_WARN:
23
+ 'El worktree supera el umbral de aviso; reduce alcance antes del siguiente commit/push.',
24
+ PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>',
25
+ PRE_PUSH_UPSTREAM_MISALIGNED:
26
+ 'Alinea upstream con la rama actual: git branch --unset-upstream && git push --set-upstream origin <branch>',
27
+ MANIFEST_MUTATION_DETECTED:
28
+ 'Los hooks/gates no deben modificar manifests. Revisa wiring y ejecuta upgrade explícito solo cuando aplique (por ejemplo: pumuki update --latest).',
29
+ };
30
+
31
+ export const resolveRemediationHintForViolationCode = (code: string): string | undefined => {
32
+ const trimmed = code.trim();
33
+ if (trimmed.length === 0) {
34
+ return undefined;
35
+ }
36
+ return REMEDIATION_HINT_BY_CODE[trimmed];
37
+ };
38
+
39
+ export const resolveRemediationHintOrDefault = (code: string): string =>
40
+ resolveRemediationHintForViolationCode(code) ?? DEFAULT_GATE_REMEDIATION;
@@ -0,0 +1,61 @@
1
+ import type { Fact } from '../../core/facts/Fact';
2
+
3
+ const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '');
4
+
5
+ export const resolveGateScopePathPrefixesFromEnv = (): string[] => {
6
+ const raw = process.env.PUMUKI_GATE_SCOPE_PATH_PREFIXES?.trim();
7
+ if (!raw) {
8
+ return [];
9
+ }
10
+ return Array.from(
11
+ new Set(
12
+ raw
13
+ .split(/[,;]/)
14
+ .map((segment) => normalizePath(segment.trim()))
15
+ .filter((segment) => segment.length > 0)
16
+ )
17
+ ).sort((a, b) => a.localeCompare(b));
18
+ };
19
+
20
+ const primaryPathForFact = (fact: Fact): string | null => {
21
+ if (fact.kind === 'FileContent' || fact.kind === 'FileChange') {
22
+ return fact.path;
23
+ }
24
+ if (fact.kind === 'Heuristic') {
25
+ return fact.filePath ?? null;
26
+ }
27
+ if (fact.kind === 'Dependency') {
28
+ return fact.from;
29
+ }
30
+ return null;
31
+ };
32
+
33
+ const pathMatchesAnyPrefix = (path: string, prefixes: ReadonlyArray<string>): boolean => {
34
+ const normalized = normalizePath(path);
35
+ for (const prefix of prefixes) {
36
+ if (normalized === prefix) {
37
+ return true;
38
+ }
39
+ const withSlash = prefix.endsWith('/') ? prefix : `${prefix}/`;
40
+ if (normalized.startsWith(withSlash)) {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ };
46
+
47
+ export const filterFactsByPathPrefixes = (
48
+ facts: ReadonlyArray<Fact>,
49
+ prefixes: ReadonlyArray<string>
50
+ ): Fact[] => {
51
+ if (prefixes.length === 0) {
52
+ return [...facts];
53
+ }
54
+ return facts.filter((fact) => {
55
+ const primary = primaryPathForFact(fact);
56
+ if (primary === null) {
57
+ return true;
58
+ }
59
+ return pathMatchesAnyPrefix(primary, prefixes);
60
+ });
61
+ };
@@ -35,6 +35,10 @@ import type { TddBddSnapshot } from '../tdd/types';
35
35
  import { resolveSkillsEnforcement } from '../policy/skillsEnforcement';
36
36
  import { applyTddBddEnforcement } from '../policy/tddBddEnforcement';
37
37
  import { collectAiGateRepoPolicyFindings } from './aiGateRepoPolicyFindings';
38
+ import {
39
+ filterFactsByPathPrefixes,
40
+ resolveGateScopePathPrefixesFromEnv,
41
+ } from './filterFactsByPathPrefixes';
38
42
 
39
43
  export type OperationalMemoryShadowRecommendation = {
40
44
  recommendedOutcome: 'ALLOW' | 'WARN' | 'BLOCK';
@@ -911,10 +915,14 @@ export async function runPlatformGate(params: {
911
915
  }
912
916
  }
913
917
 
914
- const facts = await dependencies.resolveFactsForGateScope({
915
- scope: params.scope,
916
- git,
917
- });
918
+ const gateScopePathPrefixes = resolveGateScopePathPrefixesFromEnv();
919
+ const facts = filterFactsByPathPrefixes(
920
+ await dependencies.resolveFactsForGateScope({
921
+ scope: params.scope,
922
+ git,
923
+ }),
924
+ gateScopePathPrefixes
925
+ );
918
926
  const stagedPaths = collectStagedPaths(git, repoRoot);
919
927
  const factsForPlatformEvaluation = shouldAugmentStagedSkillsContractFactsWithRepoFacts({
920
928
  scope: params.scope,
@@ -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({
@@ -20,7 +20,9 @@ import {
20
20
  } from '../notifications/emitAuditSummaryNotification';
21
21
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
+ import { buildEvidenceOperationalHints } from '../evidence/operationalHints';
23
24
  import { readEvidence, readEvidenceResult } from '../evidence/readEvidence';
25
+ import { writeEvidence } from '../evidence/writeEvidence';
24
26
  import type { EvidenceReadResult } from '../evidence/readEvidence';
25
27
  import type { SnapshotFinding } from '../evidence/schema';
26
28
  import { ensureRuntimeArtifactsIgnored } from '../lifecycle/artifacts';
@@ -31,6 +33,10 @@ import {
31
33
  resolveGitAtomicityEnforcement,
32
34
  type GitAtomicityEnforcementResolution,
33
35
  } from '../policy/gitAtomicityEnforcement';
36
+ import {
37
+ DEFAULT_GATE_REMEDIATION as DEFAULT_BLOCKED_REMEDIATION,
38
+ REMEDIATION_HINT_BY_CODE as BLOCKED_REMEDIATION_BY_CODE,
39
+ } from '../gate/remediationCatalog';
34
40
 
35
41
  const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
36
42
  'pumuki pre-push blocked: branch has no upstream tracking reference. Configure upstream first (for example: git push --set-upstream origin <branch>) and retry.';
@@ -40,39 +46,43 @@ const PRE_PUSH_MANUAL_FALLBACK_MESSAGE =
40
46
  '[pumuki][pre-push] branch has no upstream and stdin is empty; using working-tree fallback scope.';
41
47
  const PRE_PUSH_UPSTREAM_MISALIGNED_AHEAD_THRESHOLD = 5;
42
48
 
49
+ const isTruthyEnvFlag = (value?: string): boolean => {
50
+ if (!value) {
51
+ return false;
52
+ }
53
+ const normalized = value.trim().toLowerCase();
54
+ return normalized === '1' || normalized === 'true' || normalized === 'yes';
55
+ };
56
+
57
+ const isDocumentationOnlyStagedPath = (relativePath: string): boolean => {
58
+ const normalized = relativePath.replace(/\\/g, '/').trim();
59
+ if (normalized.length === 0) {
60
+ return false;
61
+ }
62
+ return /\.(md|mdx)$/i.test(normalized);
63
+ };
64
+
65
+ const shouldSkipRestagingTrackedEvidenceForDocumentationOnlyScope = (params: {
66
+ listStagedIndexPaths: (repoRoot: string) => ReadonlyArray<string>;
67
+ repoRoot: string;
68
+ }): boolean => {
69
+ if (isTruthyEnvFlag(process.env.PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE)) {
70
+ return false;
71
+ }
72
+ const paths = params.listStagedIndexPaths(params.repoRoot).filter(
73
+ (p) => p !== '.ai_evidence.json' && p !== '.AI_EVIDENCE.json'
74
+ );
75
+ if (paths.length === 0) {
76
+ return true;
77
+ }
78
+ return paths.every(isDocumentationOnlyStagedPath);
79
+ };
80
+
43
81
  const PRE_COMMIT_EVIDENCE_MAX_AGE_SECONDS = 900;
44
82
  const PRE_PUSH_EVIDENCE_MAX_AGE_SECONDS = 1800;
45
83
  const HOOK_GATE_PROGRESS_REMINDER_MS = 2000;
46
- const DEFAULT_BLOCKED_REMEDIATION = 'Corrige la causa del bloqueo y vuelve a ejecutar el gate.';
47
84
  const EVIDENCE_FILE_PATH = '.ai_evidence.json';
48
85
 
49
- const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
50
- EVIDENCE_MISSING: 'Regenera .ai_evidence.json ejecutando una auditoría.',
51
- EVIDENCE_INVALID: 'Corrige/regenera .ai_evidence.json y vuelve a ejecutar el gate.',
52
- EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para restaurar la cadena criptográfica.',
53
- EVIDENCE_STAGE_SYNC_FAILED:
54
- 'Sincroniza la evidencia trackeada y reintenta: git add -- .ai_evidence.json && git commit --amend --no-edit',
55
- EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
56
- EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este mismo repositorio.',
57
- EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual y reintenta.',
58
- EVIDENCE_RULES_COVERAGE_MISSING: 'Ejecuta auditoría completa para recalcular rules_coverage.',
59
- EVIDENCE_RULES_COVERAGE_INCOMPLETE: 'Asegura coverage_ratio=1 y unevaluated=0.',
60
- ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES_HIGH:
61
- 'Reconcilia policy/skills y reintenta PRE_COMMIT: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki-pre-commit',
62
- EVIDENCE_ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES:
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
- 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.',
69
- PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>',
70
- PRE_PUSH_UPSTREAM_MISALIGNED:
71
- 'Alinea upstream con la rama actual: git branch --unset-upstream && git push --set-upstream origin <branch>',
72
- MANIFEST_MUTATION_DETECTED:
73
- 'Los hooks/gates no deben modificar manifests. Revisa wiring y ejecuta upgrade explícito solo cuando aplique (por ejemplo: pumuki update --latest).',
74
- };
75
-
76
86
  const HOOK_POLICY_RECONCILE_CODES = new Set<string>([
77
87
  'SKILLS_PLATFORM_COVERAGE_INCOMPLETE_HIGH',
78
88
  'SKILLS_SCOPE_COMPLIANCE_INCOMPLETE_HIGH',
@@ -123,6 +133,7 @@ type StageRunnerDependencies = {
123
133
  ensureRuntimeArtifactsIgnored: (repoRoot: string) => void;
124
134
  runPolicyReconcile: typeof runPolicyReconcile;
125
135
  isPathTracked: (repoRoot: string, relativePath: string) => boolean;
136
+ listStagedIndexPaths: (repoRoot: string) => ReadonlyArray<string>;
126
137
  stagePath: (repoRoot: string, relativePath: string) => void;
127
138
  resolveHeadOid: (repoRoot: string) => string | null;
128
139
  resolveGitAtomicityEnforcement: () => GitAtomicityEnforcementResolution;
@@ -202,6 +213,13 @@ const defaultDependencies: StageRunnerDependencies = {
202
213
  return false;
203
214
  }
204
215
  },
216
+ listStagedIndexPaths: (repoRoot) => {
217
+ const raw = new GitService().runGit(['diff', '--cached', '--name-only'], repoRoot);
218
+ return raw
219
+ .split('\n')
220
+ .map((line) => line.trim())
221
+ .filter((line) => line.length > 0);
222
+ },
205
223
  stagePath: (repoRoot, relativePath) => {
206
224
  new GitService().runGit(['add', '--', relativePath], repoRoot);
207
225
  },
@@ -489,6 +507,26 @@ const runHookGateWithPolicyRetry = async (params: {
489
507
  }
490
508
  };
491
509
 
510
+ const patchOperationalHintsAfterDocumentationOnlyEvidenceSync = (repoRoot: string): void => {
511
+ const evidenceRead = readEvidenceResult(repoRoot);
512
+ if (evidenceRead.kind !== 'valid') {
513
+ return;
514
+ }
515
+ const evidence = evidenceRead.evidence;
516
+ const hints = buildEvidenceOperationalHints({
517
+ stage: evidence.snapshot.stage,
518
+ outcome: evidence.snapshot.outcome,
519
+ findings: evidence.snapshot.findings,
520
+ rulesCoverage: evidence.snapshot.rules_coverage,
521
+ evaluationMetrics: evidence.snapshot.evaluation_metrics,
522
+ extra: {
523
+ requires_second_pass: true,
524
+ second_pass_reason: 'tracked_evidence_refreshed_on_disk_not_staged_documentation_only_commit',
525
+ },
526
+ });
527
+ writeEvidence({ ...evidence, operational_hints: hints }, { repoRoot });
528
+ };
529
+
492
530
  const syncTrackedEvidenceAfterSuccessfulPreCommit = (params: {
493
531
  dependencies: StageRunnerDependencies;
494
532
  repoRoot: string;
@@ -500,6 +538,22 @@ const syncTrackedEvidenceAfterSuccessfulPreCommit = (params: {
500
538
  if (!params.dependencies.isPathTracked(params.repoRoot, EVIDENCE_FILE_PATH)) {
501
539
  return false;
502
540
  }
541
+ if (
542
+ shouldSkipRestagingTrackedEvidenceForDocumentationOnlyScope({
543
+ repoRoot: params.repoRoot,
544
+ listStagedIndexPaths: params.dependencies.listStagedIndexPaths,
545
+ })
546
+ ) {
547
+ if (!params.dependencies.isQuietMode()) {
548
+ process.stderr.write(
549
+ `[pumuki][evidence-sync] tracked ${EVIDENCE_FILE_PATH} updated on disk but not auto-staged (documentation-only staged paths: *.md / *.mdx). ` +
550
+ `Include in this commit if needed: git add -- ${EVIDENCE_FILE_PATH}. ` +
551
+ `Force previous behavior: PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE=1\n`
552
+ );
553
+ }
554
+ patchOperationalHintsAfterDocumentationOnlyEvidenceSync(params.repoRoot);
555
+ return false;
556
+ }
503
557
  try {
504
558
  params.dependencies.stagePath(params.repoRoot, EVIDENCE_FILE_PATH);
505
559
  return false;
@@ -5,6 +5,7 @@ import { runPlatformGate } from '../git/runPlatformGate';
5
5
  import { collectWorktreeAtomicSlices } from '../git/worktreeAtomicSlices';
6
6
  import {
7
7
  doctorHasBlockingIssues,
8
+ doctorHasParityMismatch,
8
9
  runLifecycleDoctor,
9
10
  type LifecycleDoctorReport,
10
11
  } from './doctor';
@@ -115,6 +116,7 @@ export type ParsedArgs = {
115
116
  installMcpAgent?: AdapterAgent;
116
117
  remoteChecks?: boolean;
117
118
  doctorDeep?: boolean;
119
+ doctorParity?: boolean;
118
120
  sddCommand?: SddCommand;
119
121
  loopCommand?: LoopCommand;
120
122
  loopSessionId?: string;
@@ -180,7 +182,7 @@ Pumuki lifecycle commands:
180
182
  pumuki uninstall [--purge-artifacts]
181
183
  pumuki remove
182
184
  pumuki update [--latest|--spec=<package-spec>]
183
- pumuki doctor [--remote-checks] [--deep] [--json]
185
+ pumuki doctor [--remote-checks] [--deep] [--parity] [--json]
184
186
  pumuki status [--json] [--remote-checks]
185
187
  pumuki watch [--stage=PRE_COMMIT|PRE_PUSH|CI] [--scope=workingTree|staged|repoAndStaged|repo] [--severity=critical|high|medium|low] [--interval-ms=<n>] [--notify-cooldown-ms=<n>] [--no-notify] [--once|--iterations=<n>] [--json]
186
188
  pumuki loop run --objective=<text> [--max-attempts=<n>] [--json]
@@ -567,6 +569,7 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
567
569
  let installMcpAgent: ParsedArgs['installMcpAgent'];
568
570
  let remoteChecks = false;
569
571
  let doctorDeep = false;
572
+ let doctorParity = false;
570
573
  let watchStage: ParsedArgs['watchStage'];
571
574
  let watchScope: ParsedArgs['watchScope'];
572
575
  let watchIntervalMs: ParsedArgs['watchIntervalMs'];
@@ -1395,6 +1398,10 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
1395
1398
  doctorDeep = true;
1396
1399
  continue;
1397
1400
  }
1401
+ if (arg === '--parity') {
1402
+ doctorParity = true;
1403
+ continue;
1404
+ }
1398
1405
  if (arg === '--purge-artifacts') {
1399
1406
  purgeArtifacts = true;
1400
1407
  continue;
@@ -1417,6 +1424,9 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
1417
1424
  if (doctorDeep && commandRaw !== 'doctor') {
1418
1425
  throw new Error(`--deep is only supported with "pumuki doctor".\n\n${HELP_TEXT}`);
1419
1426
  }
1427
+ if (doctorParity && commandRaw !== 'doctor') {
1428
+ throw new Error(`--parity is only supported with "pumuki doctor".\n\n${HELP_TEXT}`);
1429
+ }
1420
1430
  if (commandRaw !== 'bootstrap' && bootstrapEnterprise) {
1421
1431
  throw new Error(`--enterprise is only supported with "pumuki bootstrap".\n\n${HELP_TEXT}`);
1422
1432
  }
@@ -1444,6 +1454,7 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
1444
1454
  ...(installMcpAgent ? { installMcpAgent } : {}),
1445
1455
  ...(remoteChecks ? { remoteChecks: true } : {}),
1446
1456
  ...(doctorDeep ? { doctorDeep: true } : {}),
1457
+ ...(doctorParity ? { doctorParity: true } : {}),
1447
1458
  };
1448
1459
  };
1449
1460
 
@@ -1516,6 +1527,22 @@ const printDoctorReport = (
1516
1527
  writeInfo(`[pumuki] ${issue.severity.toUpperCase()}: ${issue.message}`);
1517
1528
  }
1518
1529
 
1530
+ if (report.parity_profile) {
1531
+ writeInfo(
1532
+ `[pumuki][doctor][parity] pumuki=${report.parity_profile.pumuki_package_version} bundle=${report.parity_profile.pre_commit_policy_bundle} hash=${report.parity_profile.pre_commit_policy_hash}`
1533
+ );
1534
+ }
1535
+ if (report.parity_comparison) {
1536
+ writeInfo(
1537
+ `[pumuki][doctor][parity] expected_file=${report.parity_comparison.expected_path} matches=${report.parity_comparison.matches ? 'yes' : 'no'}`
1538
+ );
1539
+ for (const mismatch of report.parity_comparison.mismatches) {
1540
+ writeInfo(
1541
+ `[pumuki][doctor][parity] mismatch ${mismatch.field}: expected=${mismatch.expected} actual=${mismatch.actual}`
1542
+ );
1543
+ }
1544
+ }
1545
+
1519
1546
  if (report.deep?.enabled) {
1520
1547
  for (const check of report.deep.checks) {
1521
1548
  writeInfo(
@@ -1527,7 +1554,8 @@ const printDoctorReport = (
1527
1554
  }
1528
1555
  }
1529
1556
 
1530
- const hasBlocking = doctorHasBlockingIssues(report);
1557
+ const hasBlocking =
1558
+ doctorHasBlockingIssues(report) || doctorHasParityMismatch(report);
1531
1559
  const hasWarnings =
1532
1560
  report.issues.length > 0 ||
1533
1561
  report.deep?.checks.some((check) => check.status !== 'pass') === true;
@@ -2225,6 +2253,7 @@ export const runLifecycleCli = async (
2225
2253
  case 'doctor': {
2226
2254
  const report = runLifecycleDoctor({
2227
2255
  deep: parsed.doctorDeep === true,
2256
+ parity: parsed.doctorParity === true,
2228
2257
  });
2229
2258
  const remoteCiDiagnostics = parsed.remoteChecks
2230
2259
  ? activeDependencies.collectRemoteCiDiagnostics({
@@ -2247,7 +2276,7 @@ export const runLifecycleCli = async (
2247
2276
  } else {
2248
2277
  printDoctorReport(report, remoteCiDiagnostics);
2249
2278
  }
2250
- return doctorHasBlockingIssues(report) ? 1 : 0;
2279
+ return doctorHasBlockingIssues(report) || doctorHasParityMismatch(report) ? 1 : 0;
2251
2280
  }
2252
2281
  case 'status': {
2253
2282
  const status = readLifecycleStatus();
@@ -69,6 +69,22 @@ export type DoctorCompatibilityContract = {
69
69
  };
70
70
  };
71
71
 
72
+ export type DoctorParityProfile = {
73
+ schema_version: '1';
74
+ pumuki_package_version: string;
75
+ pre_commit_policy_bundle: string;
76
+ pre_commit_policy_hash: string;
77
+ pre_commit_policy_signature: string | null;
78
+ pre_commit_policy_version: string | null;
79
+ skills_policy_present: boolean;
80
+ };
81
+
82
+ export type DoctorParityComparison = {
83
+ expected_path: string;
84
+ matches: boolean;
85
+ mismatches: ReadonlyArray<{ field: string; expected: string; actual: string }>;
86
+ };
87
+
72
88
  export type LifecycleDoctorReport = {
73
89
  repoRoot: string;
74
90
  packageVersion: string;
@@ -81,6 +97,8 @@ export type LifecycleDoctorReport = {
81
97
  policyValidation: LifecyclePolicyValidationSnapshot;
82
98
  issues: ReadonlyArray<DoctorIssue>;
83
99
  deep?: DoctorDeepReport;
100
+ parity_profile?: DoctorParityProfile;
101
+ parity_comparison?: DoctorParityComparison;
84
102
  };
85
103
 
86
104
  const buildDoctorIssues = (params: {
@@ -698,10 +716,87 @@ const buildDoctorDeepReport = (params: {
698
716
  };
699
717
  };
700
718
 
719
+ const buildDoctorParityProfile = (params: {
720
+ repoRoot: string;
721
+ packageVersion: string;
722
+ }): DoctorParityProfile => {
723
+ const policy = resolvePolicyForStage('PRE_COMMIT', params.repoRoot);
724
+ const skillsPolicyPath = join(params.repoRoot, 'skills.policy.json');
725
+ return {
726
+ schema_version: '1',
727
+ pumuki_package_version: params.packageVersion,
728
+ pre_commit_policy_bundle: policy.trace.bundle,
729
+ pre_commit_policy_hash: policy.trace.hash,
730
+ pre_commit_policy_signature: policy.trace.signature ?? null,
731
+ pre_commit_policy_version: policy.trace.version ?? null,
732
+ skills_policy_present: existsSync(skillsPolicyPath),
733
+ };
734
+ };
735
+
736
+ const compareDoctorParityProfile = (params: {
737
+ repoRoot: string;
738
+ actual: DoctorParityProfile;
739
+ }): DoctorParityComparison | undefined => {
740
+ const expectedPath = join(params.repoRoot, '.pumuki', 'ci-parity-expected.json');
741
+ if (!existsSync(expectedPath)) {
742
+ return undefined;
743
+ }
744
+ let raw: unknown;
745
+ try {
746
+ raw = JSON.parse(readFileSync(expectedPath, 'utf8')) as unknown;
747
+ } catch {
748
+ return {
749
+ expected_path: expectedPath,
750
+ matches: false,
751
+ mismatches: [
752
+ {
753
+ field: 'ci-parity-expected.json',
754
+ expected: 'valid-json',
755
+ actual: 'parse-error',
756
+ },
757
+ ],
758
+ };
759
+ }
760
+ if (!isRecord(raw)) {
761
+ return {
762
+ expected_path: expectedPath,
763
+ matches: false,
764
+ mismatches: [{ field: 'root', expected: 'object', actual: 'non-object' }],
765
+ };
766
+ }
767
+ const mismatches: Array<{ field: string; expected: string; actual: string }> = [];
768
+ const expectField = (field: string, expected: unknown, actual: string) => {
769
+ if (typeof expected === 'string' && expected.trim().length > 0 && expected !== actual) {
770
+ mismatches.push({ field, expected, actual });
771
+ }
772
+ };
773
+ expectField(
774
+ 'pumuki_package_version',
775
+ raw.pumuki_package_version,
776
+ params.actual.pumuki_package_version
777
+ );
778
+ expectField(
779
+ 'pre_commit_policy_hash',
780
+ raw.pre_commit_policy_hash,
781
+ params.actual.pre_commit_policy_hash
782
+ );
783
+ expectField(
784
+ 'pre_commit_policy_bundle',
785
+ raw.pre_commit_policy_bundle,
786
+ params.actual.pre_commit_policy_bundle
787
+ );
788
+ return {
789
+ expected_path: expectedPath,
790
+ matches: mismatches.length === 0,
791
+ mismatches,
792
+ };
793
+ };
794
+
701
795
  export const runLifecycleDoctor = (params?: {
702
796
  cwd?: string;
703
797
  git?: ILifecycleGitService;
704
798
  deep?: boolean;
799
+ parity?: boolean;
705
800
  }): LifecycleDoctorReport => {
706
801
  const git = params?.git ?? new LifecycleGitService();
707
802
  const cwd = params?.cwd ?? process.cwd();
@@ -730,6 +825,18 @@ export const runLifecycleDoctor = (params?: {
730
825
  lifecycleVersion: lifecycleState.version,
731
826
  });
732
827
 
828
+ const parity_profile =
829
+ params?.parity === true
830
+ ? buildDoctorParityProfile({
831
+ repoRoot,
832
+ packageVersion: version.effective,
833
+ })
834
+ : undefined;
835
+ const parity_comparison =
836
+ typeof parity_profile !== 'undefined'
837
+ ? compareDoctorParityProfile({ repoRoot, actual: parity_profile })
838
+ : undefined;
839
+
733
840
  return {
734
841
  repoRoot,
735
842
  packageVersion: version.effective,
@@ -742,8 +849,13 @@ export const runLifecycleDoctor = (params?: {
742
849
  policyValidation: readLifecyclePolicyValidationSnapshot(repoRoot),
743
850
  issues,
744
851
  deep,
852
+ parity_profile,
853
+ parity_comparison,
745
854
  };
746
855
  };
747
856
 
748
857
  export const doctorHasBlockingIssues = (report: LifecycleDoctorReport): boolean =>
749
858
  report.issues.some((issue) => issue.severity === 'error') || report.deep?.blocking === true;
859
+
860
+ export const doctorHasParityMismatch = (report: LifecycleDoctorReport): boolean =>
861
+ typeof report.parity_comparison !== 'undefined' && report.parity_comparison.matches === false;
@@ -1,17 +1,8 @@
1
1
  import { evaluateAiGate, type AiGateStage } from '../gate/evaluateAiGate';
2
+ import { resolveRemediationHintForViolationCode } from '../gate/remediationCatalog';
2
3
  import { resolveLearningContextExperimentalFeature } from '../policy/experimentalFeatures';
3
4
  import { readSddLearningContext, type SddLearningContext } from '../sdd/learningInsights';
4
5
 
5
- const AUTO_FIX_BY_CODE: Readonly<Record<string, string>> = {
6
- EVIDENCE_MISSING: 'Ejecuta una auditoría para generar .ai_evidence.json.',
7
- EVIDENCE_INVALID: 'Regenera .ai_evidence.json y vuelve a evaluar.',
8
- EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
9
- EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual.',
10
- EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este repositorio.',
11
- PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta git push --set-upstream origin <branch>.',
12
- GITFLOW_PROTECTED_BRANCH: 'Crea una rama feature/* y mueve el trabajo allí.',
13
- };
14
-
15
6
  const PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
16
7
 
17
8
  export type EnterpriseAiGateCheckResult = {
@@ -112,7 +103,7 @@ const buildAutoFixes = (
112
103
  if (emittedCodes.has(violation.code)) {
113
104
  continue;
114
105
  }
115
- const fix = AUTO_FIX_BY_CODE[violation.code];
106
+ const fix = resolveRemediationHintForViolationCode(violation.code);
116
107
  if (!fix) {
117
108
  continue;
118
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.69",
3
+ "version": "6.3.71",
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": {
@@ -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,